feat: Schlüssel und gemeinsamer Kern für Sprachnotizen (T7 L2 Tasks 5+6)
Task 5: Schlüsselbund-Zweck voice (VOICE_ENCRYPTION_KEY), AES-256-GCM- Hilfen für Text und Bytes mit AAD capture:<id>:<art>, voice-config (Modell, Tageslimit, Aufnahme, Whisper-Grösse) und anthropic-config (nie geloggt, nur worker/all), Startprüfung verlangt den Schlüssel in Produktion in jeder Rolle, rekey versiegelt offene Sprachnotizen neu (Audio einzeln gelesen, CAS mit Status), Secret-Vorlagen und ensure-secrets.sh ergänzt. Task 6: Text-Normalisierung und Bereinigung (Steuer- und Formatzeichen, Codepoint-Kürzung), Zitatprüfung mit Dezimalzahlen als ein Wort (Ruling 7), Felderliste passend zu CreateInspectionDto, versiegeltes Transkript mit Grenzen, Fachwörter und Skalen mit Paritätstests. Rollout: Ein Image mit diesem Stand startet in Produktion nur mit VOICE_ENCRYPTION_KEY in apicore-backend-secret (ARCHITECTURE Annahme 22). Gates: Backend tsc, eslint, 922 Unit-Tests, e2e 100 Suites / 1004 Tests. Refs #27 #28 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2a4dc868d9
commit
9795f98006
27 files changed
+2212
-31
No files matched your search
+1
-1
@@ -141,7 +141,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||||
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
|
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
|
||||||
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
||||||
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt.
|
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien), `VOICE_ENCRYPTION_KEY` (Sprachnotizen: Transkript, Vorschlag, Aufnahme; seit T7, AAD `capture:<id>:<art>`). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die vier Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. Der Job `rekey` versiegelt beim Schlüsseltausch nur offene Sprachnotizen neu (bestätigte, verworfene und abgelaufene haben keinen Inhalt mehr). **Rollout:** Ein Image mit T7 startet in Produktion in keiner Rolle ohne `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret`; vorher `deploy/k8s/scripts/ensure-secrets.sh` ausführen und den Schlüssel wie die anderen sichern.
|
||||||
23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden.
|
23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden.
|
||||||
**Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
|
**Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
|
||||||
**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
|
**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
|
||||||
|
|||||||
@@ -82,9 +82,12 @@ BACKUP_RETENTION_COUNT=14
|
|||||||
# MFA_ENCRYPTION_KEY=""
|
# MFA_ENCRYPTION_KEY=""
|
||||||
# WEBHOOK_ENCRYPTION_KEY=""
|
# WEBHOOK_ENCRYPTION_KEY=""
|
||||||
# BACKUP_ENCRYPTION_KEY=""
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
# Seals voice notes (audio, transcripts, suggestions); required in production.
|
||||||
|
# VOICE_ENCRYPTION_KEY=""
|
||||||
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# VOICE_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
|
||||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
|
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
|
||||||
* backup files) are encrypted with a key of their own per purpose, never
|
* voice notes, backup files) are encrypted with a key of their own per purpose, never
|
||||||
* with a JWT secret. Each purpose has a key ring: the current key from
|
* with a JWT secret. Each purpose has a key ring: the current key from
|
||||||
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
|
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
|
||||||
* opens, so a key can be rotated without losing data. A sealed value names
|
* opens, so a key can be rotated without losing data. A sealed value names
|
||||||
@@ -33,13 +33,14 @@ export const ENCRYPTION_KEY_VARIABLES = {
|
|||||||
mfa: 'MFA_ENCRYPTION_KEY',
|
mfa: 'MFA_ENCRYPTION_KEY',
|
||||||
webhook: 'WEBHOOK_ENCRYPTION_KEY',
|
webhook: 'WEBHOOK_ENCRYPTION_KEY',
|
||||||
backup: 'BACKUP_ENCRYPTION_KEY',
|
backup: 'BACKUP_ENCRYPTION_KEY',
|
||||||
|
voice: 'VOICE_ENCRYPTION_KEY',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EncryptionKeyVariable =
|
export type EncryptionKeyVariable =
|
||||||
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
|
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
|
||||||
|
|
||||||
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
|
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
|
||||||
export type SecretPurpose = 'mfa' | 'webhook';
|
export type SecretPurpose = 'mfa' | 'webhook' | 'voice';
|
||||||
|
|
||||||
export interface RingKey {
|
export interface RingKey {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import {
|
|||||||
openWebhookSecret,
|
openWebhookSecret,
|
||||||
sealWebhookSecret,
|
sealWebhookSecret,
|
||||||
} from '../webhooks/webhook-secret';
|
} from '../webhooks/webhook-secret';
|
||||||
|
import {
|
||||||
|
isVoiceSealedWithCurrentKey,
|
||||||
|
openVoiceBytes,
|
||||||
|
openVoiceText,
|
||||||
|
sealVoiceBytes,
|
||||||
|
sealVoiceText,
|
||||||
|
voiceAad,
|
||||||
|
} from '../voice/core/voice-crypto';
|
||||||
import { RekeyJob } from './rekey.job';
|
import { RekeyJob } from './rekey.job';
|
||||||
|
|
||||||
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
|
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
|
||||||
@@ -67,11 +75,139 @@ function sealedWithPrevious(variable: string, seal: () => string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CaptureRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
transcriptEnc: string | null;
|
||||||
|
suggestionEnc: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioRow {
|
||||||
|
captureId: string;
|
||||||
|
dataEnc: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceFixture {
|
||||||
|
captures: CaptureRow[];
|
||||||
|
audio: AudioRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusFilter {
|
||||||
|
status?: { notIn: string[] };
|
||||||
|
capture?: { status: { notIn: string[] } };
|
||||||
|
transcriptEnc?: { not: null } | string;
|
||||||
|
suggestionEnc?: { not: null } | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaptureUpdate {
|
||||||
|
where: { id: string; status: { notIn: string[] } } & Record<string, unknown>;
|
||||||
|
data: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioUpdate {
|
||||||
|
where: {
|
||||||
|
captureId: string;
|
||||||
|
dataEnc: Buffer;
|
||||||
|
capture: { status: { notIn: string[] } };
|
||||||
|
};
|
||||||
|
data: { dataEnc: Buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voice tables, in memory, with the where-shapes the job uses. */
|
||||||
|
function fakeVoice({ captures, audio }: VoiceFixture) {
|
||||||
|
const captureRows = new Map(captures.map((row) => [row.id, { ...row }]));
|
||||||
|
const audioRows = new Map(audio.map((row) => [row.captureId, { ...row }]));
|
||||||
|
const open = (id: string, notIn: string[]): boolean => {
|
||||||
|
const row = captureRows.get(id);
|
||||||
|
return row !== undefined && !notIn.includes(row.status);
|
||||||
|
};
|
||||||
|
const voiceCapture = {
|
||||||
|
findMany: jest.fn(({ where }: { where: StatusFilter }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
[...captureRows.values()].filter(
|
||||||
|
(row) =>
|
||||||
|
open(row.id, where.status?.notIn ?? []) &&
|
||||||
|
(where.transcriptEnc === undefined || row.transcriptEnc !== null) &&
|
||||||
|
(where.suggestionEnc === undefined || row.suggestionEnc !== null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updateMany: jest.fn<Promise<UpdateResult>, [CaptureUpdate]>(
|
||||||
|
({ where, data }) => {
|
||||||
|
const row = captureRows.get(where.id);
|
||||||
|
const column =
|
||||||
|
'transcriptEnc' in where ? 'transcriptEnc' : 'suggestionEnc';
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
!open(where.id, where.status.notIn) ||
|
||||||
|
row[column] !== where[column]
|
||||||
|
) {
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
captureRows.set(where.id, { ...row, [column]: data[column] });
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const voiceCaptureAudio = {
|
||||||
|
findMany: jest.fn(({ where }: { where: StatusFilter }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
[...audioRows.values()]
|
||||||
|
.filter((row) =>
|
||||||
|
open(row.captureId, where.capture?.status.notIn ?? []),
|
||||||
|
)
|
||||||
|
.map((row) => ({ captureId: row.captureId })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
findFirst: jest.fn(
|
||||||
|
({ where }: { where: { captureId: string } & StatusFilter }) => {
|
||||||
|
const row = audioRows.get(where.captureId);
|
||||||
|
return Promise.resolve(
|
||||||
|
row && open(row.captureId, where.capture?.status.notIn ?? [])
|
||||||
|
? { ...row }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
updateMany: jest.fn<Promise<UpdateResult>, [AudioUpdate]>(
|
||||||
|
({ where, data }) => {
|
||||||
|
const row = audioRows.get(where.captureId);
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
!open(where.captureId, where.capture.status.notIn) ||
|
||||||
|
!row.dataEnc.equals(where.dataEnc)
|
||||||
|
) {
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
audioRows.set(where.captureId, { ...row, dataEnc: data.dataEnc });
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
voiceCapture,
|
||||||
|
voiceCaptureAudio,
|
||||||
|
transcript: (id: string): string =>
|
||||||
|
captureRows.get(id)?.transcriptEnc ?? '',
|
||||||
|
suggestion: (id: string): string =>
|
||||||
|
captureRows.get(id)?.suggestionEnc ?? '',
|
||||||
|
audio: (id: string): Buffer =>
|
||||||
|
audioRows.get(id)?.dataEnc ?? Buffer.alloc(0),
|
||||||
|
captureRows,
|
||||||
|
audioRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two tables, in memory. updateMany behaves like the SQL compare-and-
|
* The tables, in memory. updateMany behaves like the SQL compare-and-
|
||||||
* swap: it writes only while id and old value still match.
|
* swap: it writes only while id and old value still match.
|
||||||
*/
|
*/
|
||||||
function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
function fakeDb(
|
||||||
|
users: UserRow[],
|
||||||
|
subscriptions: SubscriptionRow[],
|
||||||
|
voiceFixture: VoiceFixture = { captures: [], audio: [] },
|
||||||
|
) {
|
||||||
|
const voice = fakeVoice(voiceFixture);
|
||||||
const userRows = new Map(users.map((row) => [row.id, row]));
|
const userRows = new Map(users.map((row) => [row.id, row]));
|
||||||
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
|
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
|
||||||
const user = {
|
const user = {
|
||||||
@@ -105,9 +241,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
prisma: { user, webhookSubscription } as unknown as PrismaService,
|
prisma: {
|
||||||
user,
|
user,
|
||||||
webhookSubscription,
|
webhookSubscription,
|
||||||
|
voiceCapture: voice.voiceCapture,
|
||||||
|
voiceCaptureAudio: voice.voiceCaptureAudio,
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
user,
|
||||||
|
webhookSubscription,
|
||||||
|
voice,
|
||||||
userRows,
|
userRows,
|
||||||
subscriptionRows,
|
subscriptionRows,
|
||||||
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
|
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
|
||||||
@@ -118,6 +260,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
|||||||
|
|
||||||
const keyIdOf = (stored: string): string => stored.split(':')[0];
|
const keyIdOf = (stored: string): string => stored.split(':')[0];
|
||||||
|
|
||||||
|
const NO_VOICE = {
|
||||||
|
'voice.total': 0,
|
||||||
|
'voice.rekeyed': 0,
|
||||||
|
'voice.alreadyCurrent': 0,
|
||||||
|
'voice.plaintextSealed': 0,
|
||||||
|
'voice.changedMeanwhile': 0,
|
||||||
|
'voice.failed': 0,
|
||||||
|
};
|
||||||
|
|
||||||
describe('RekeyJob', () => {
|
describe('RekeyJob', () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
let warn: jest.SpyInstance<void, unknown[]>;
|
let warn: jest.SpyInstance<void, unknown[]>;
|
||||||
@@ -129,6 +280,8 @@ describe('RekeyJob', () => {
|
|||||||
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
|
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
|
||||||
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
|
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
|
||||||
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
|
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = 'c'.repeat(40);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = 'p'.repeat(40);
|
||||||
warn = jest
|
warn = jest
|
||||||
.spyOn(Logger.prototype, 'warn')
|
.spyOn(Logger.prototype, 'warn')
|
||||||
.mockImplementation(() => undefined);
|
.mockImplementation(() => undefined);
|
||||||
@@ -192,6 +345,7 @@ describe('RekeyJob', () => {
|
|||||||
'webhook.plaintextSealed': 1,
|
'webhook.plaintextSealed': 1,
|
||||||
'webhook.changedMeanwhile': 0,
|
'webhook.changedMeanwhile': 0,
|
||||||
'webhook.failed': 0,
|
'webhook.failed': 0,
|
||||||
|
...NO_VOICE,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
|
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
|
||||||
@@ -411,7 +565,7 @@ describe('RekeyJob', () => {
|
|||||||
|
|
||||||
it('dry-run opens them and fails the run on a value that does not open', async () => {
|
it('dry-run opens them and fails the run on a value that does not open', async () => {
|
||||||
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
||||||
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/,
|
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1, voice\..*$/,
|
||||||
);
|
);
|
||||||
expect(db.user.updateMany).not.toHaveBeenCalled();
|
expect(db.user.updateMany).not.toHaveBeenCalled();
|
||||||
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
||||||
@@ -490,7 +644,7 @@ describe('RekeyJob', () => {
|
|||||||
|
|
||||||
it('dry-run reports them too and writes nothing', async () => {
|
it('dry-run reports them too and writes nothing', async () => {
|
||||||
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
||||||
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/,
|
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1, voice\..*$/,
|
||||||
);
|
);
|
||||||
expect(db.user.updateMany).not.toHaveBeenCalled();
|
expect(db.user.updateMany).not.toHaveBeenCalled();
|
||||||
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
||||||
@@ -566,4 +720,253 @@ describe('RekeyJob', () => {
|
|||||||
expect(db.user.findMany).not.toHaveBeenCalled();
|
expect(db.user.findMany).not.toHaveBeenCalled();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
describe('voice content', () => {
|
||||||
|
const TRANSCRIPT = 'Koenigin gesehen, acht Waben besetzt';
|
||||||
|
const SUGGESTION = '{"suggestions":[{"field":"queenSeen"}]}';
|
||||||
|
const AUDIO = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 9, 8, 7]);
|
||||||
|
|
||||||
|
/** Seals with the key that VOICE_ENCRYPTION_KEY_PREVIOUS names, as before a rotation. */
|
||||||
|
function underPrevious<T>(seal: () => T): T {
|
||||||
|
const current = process.env.VOICE_ENCRYPTION_KEY;
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY =
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
try {
|
||||||
|
return seal();
|
||||||
|
} finally {
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFixture(): ReturnType<typeof fakeDb> {
|
||||||
|
return fakeDb([], [], {
|
||||||
|
captures: [
|
||||||
|
{
|
||||||
|
// READY, everything under the previous key.
|
||||||
|
id: 'c1',
|
||||||
|
status: 'READY',
|
||||||
|
transcriptEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(TRANSCRIPT, voiceAad('c1', 'transcript')),
|
||||||
|
),
|
||||||
|
suggestionEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(SUGGESTION, voiceAad('c1', 'suggestion')),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// QUEUED, audio under the previous key, no text yet.
|
||||||
|
id: 'c2',
|
||||||
|
status: 'QUEUED',
|
||||||
|
transcriptEnc: null,
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// TRANSCRIBED, already current.
|
||||||
|
id: 'c3',
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
transcriptEnc: sealVoiceText(
|
||||||
|
TRANSCRIPT,
|
||||||
|
voiceAad('c3', 'transcript'),
|
||||||
|
),
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// CONFIRMED rows hold no content any more; a leftover is not touched.
|
||||||
|
id: 'c4',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
transcriptEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(TRANSCRIPT, voiceAad('c4', 'transcript')),
|
||||||
|
),
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audio: [
|
||||||
|
{
|
||||||
|
captureId: 'c2',
|
||||||
|
dataEnc: underPrevious(() =>
|
||||||
|
sealVoiceBytes(AUDIO, voiceAad('c2', 'audio')),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'c4',
|
||||||
|
dataEnc: underPrevious(() =>
|
||||||
|
sealVoiceBytes(AUDIO, voiceAad('c4', 'audio')),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rekey reseals open voice content and skips confirmed rows', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const confirmedTranscript = db.voice.transcript('c4');
|
||||||
|
const confirmedAudio = db.voice.audio('c4');
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
// c1: transcript + suggestion, c2: audio, c3: already current.
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
'voice.total': 4,
|
||||||
|
'voice.rekeyed': 3,
|
||||||
|
'voice.alreadyCurrent': 1,
|
||||||
|
'voice.plaintextSealed': 0,
|
||||||
|
'voice.changedMeanwhile': 0,
|
||||||
|
'voice.failed': 0,
|
||||||
|
});
|
||||||
|
expect(isVoiceSealedWithCurrentKey(db.voice.transcript('c1'))).toBe(true);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(db.voice.suggestion('c1'))).toBe(true);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(db.voice.audio('c2'))).toBe(true);
|
||||||
|
expect(db.voice.transcript('c4')).toBe(confirmedTranscript);
|
||||||
|
expect(db.voice.audio('c4').equals(confirmedAudio)).toBe(true);
|
||||||
|
// Every filter excludes the three finished states.
|
||||||
|
expect(db.voice.voiceCapture.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] },
|
||||||
|
}) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves voice content that opens to the original after the previous key is gone', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
await new RekeyJob(db.prisma).run([]);
|
||||||
|
delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
openVoiceText(db.voice.transcript('c1'), voiceAad('c1', 'transcript')),
|
||||||
|
).toBe(TRANSCRIPT);
|
||||||
|
expect(
|
||||||
|
openVoiceText(db.voice.suggestion('c1'), voiceAad('c1', 'suggestion')),
|
||||||
|
).toBe(SUGGESTION);
|
||||||
|
expect(
|
||||||
|
openVoiceBytes(db.voice.audio('c2'), voiceAad('c2', 'audio')).equals(
|
||||||
|
AUDIO,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dry-run counts voice content and writes nothing', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const before = db.voice.transcript('c1');
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run(['--dry-run']);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
dryRun: true,
|
||||||
|
'voice.total': 4,
|
||||||
|
'voice.rekeyed': 3,
|
||||||
|
'voice.alreadyCurrent': 1,
|
||||||
|
});
|
||||||
|
expect(db.voice.voiceCapture.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(db.voice.voiceCaptureAudio.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(db.voice.transcript('c1')).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite voice content that changed since it was read', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const newer = sealVoiceText('neu', voiceAad('c1', 'transcript'));
|
||||||
|
db.voice.voiceCapture.findMany.mockImplementationOnce(() => {
|
||||||
|
const read = [...db.voice.captureRows.values()]
|
||||||
|
.filter(
|
||||||
|
(row) => row.transcriptEnc !== null && row.status !== 'CONFIRMED',
|
||||||
|
)
|
||||||
|
.map((row) => ({ ...row }));
|
||||||
|
const row = db.voice.captureRows.get('c1');
|
||||||
|
if (row) {
|
||||||
|
db.voice.captureRows.set('c1', { ...row, transcriptEnc: newer });
|
||||||
|
}
|
||||||
|
return Promise.resolve(read);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 });
|
||||||
|
expect(db.voice.transcript('c1')).toBe(newer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the run on a voice value that cannot be opened and never logs content', async () => {
|
||||||
|
const db = fakeDb([], [], {
|
||||||
|
captures: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
status: 'READY',
|
||||||
|
// Bound to another capture: does not authenticate here.
|
||||||
|
transcriptEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(TRANSCRIPT, voiceAad('other', 'transcript')),
|
||||||
|
),
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audio: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = await new RekeyJob(db.prisma).run([]).then(
|
||||||
|
() => '',
|
||||||
|
(failure: Error) => failure.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message).toContain('voice.failed=1');
|
||||||
|
expect(logged()).toContainEqual(
|
||||||
|
expect.stringContaining(
|
||||||
|
'VoiceCapture c1: voice secret not rekeyed: ciphertext failed authentication',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const text of [message, ...logged()]) {
|
||||||
|
expect(text).not.toContain('Koenigin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads audio one blob at a time and never selects the data in the id listing', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
|
||||||
|
await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
expect(db.voice.voiceCaptureAudio.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ select: { captureId: true } }),
|
||||||
|
);
|
||||||
|
expect(db.voice.voiceCaptureAudio.findFirst).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write audio of a capture that was confirmed since it was read', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const before = db.voice.audio('c2');
|
||||||
|
db.voice.voiceCaptureAudio.findFirst.mockImplementationOnce(
|
||||||
|
({ where }: { where: { captureId: string } }) => {
|
||||||
|
const read = { captureId: where.captureId, dataEnc: before };
|
||||||
|
const row = db.voice.captureRows.get(where.captureId);
|
||||||
|
if (row) {
|
||||||
|
db.voice.captureRows.set(where.captureId, {
|
||||||
|
...row,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(read);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 });
|
||||||
|
expect(db.voice.audio('c2').equals(before)).toBe(true);
|
||||||
|
expect(db.voice.voiceCaptureAudio.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
capture: {
|
||||||
|
status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] },
|
||||||
|
},
|
||||||
|
}) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails before reading any row when the voice key is unusable', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = 'too-short';
|
||||||
|
|
||||||
|
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
|
||||||
|
'VOICE_ENCRYPTION_KEY must be at least 32 characters',
|
||||||
|
);
|
||||||
|
expect(db.voice.voiceCapture.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -13,13 +13,29 @@ import {
|
|||||||
sealWebhookSecret,
|
sealWebhookSecret,
|
||||||
webhookSecretNeedsRekey,
|
webhookSecretNeedsRekey,
|
||||||
} from '../webhooks/webhook-secret';
|
} from '../webhooks/webhook-secret';
|
||||||
|
import { VoiceCaptureStatus } from '../../generated/prisma';
|
||||||
|
import {
|
||||||
|
isVoiceSealedWithCurrentKey,
|
||||||
|
openVoiceBytes,
|
||||||
|
openVoiceText,
|
||||||
|
sealVoiceBytes,
|
||||||
|
sealVoiceText,
|
||||||
|
voiceAad,
|
||||||
|
} from '../voice/core/voice-crypto';
|
||||||
import { Job, JobResult } from './job';
|
import { Job, JobResult } from './job';
|
||||||
import { JobName } from './job-names';
|
import { JobName } from './job-names';
|
||||||
|
|
||||||
/** `rekey --dry-run`: open and count every secret, write none. */
|
/** `rekey --dry-run`: open and count every secret, write none. */
|
||||||
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
||||||
|
|
||||||
/** What one run did with the secrets of one purpose (mfa, webhook). */
|
/** Voice content of these states is already deleted (or about to be) and is not resealed. */
|
||||||
|
const FINISHED_VOICE_STATUSES: VoiceCaptureStatus[] = [
|
||||||
|
VoiceCaptureStatus.CONFIRMED,
|
||||||
|
VoiceCaptureStatus.DISCARDED,
|
||||||
|
VoiceCaptureStatus.EXPIRED,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What one run did with the secrets of one purpose (mfa, webhook, voice). */
|
||||||
export interface RekeyCounts {
|
export interface RekeyCounts {
|
||||||
/** Rows with a stored secret. */
|
/** Rows with a stored secret. */
|
||||||
total: number;
|
total: number;
|
||||||
@@ -51,6 +67,13 @@ interface SecretColumn {
|
|||||||
/** How log lines name a row: "<entity> <id>". */
|
/** How log lines name a row: "<entity> <id>". */
|
||||||
entity: string;
|
entity: string;
|
||||||
read(): Promise<StoredSecret[]>;
|
read(): Promise<StoredSecret[]>;
|
||||||
|
/**
|
||||||
|
* Instead of read(): the ids first, then one row at a time with readOne(),
|
||||||
|
* so large values (audio) are never held all at once. readOne gives null
|
||||||
|
* for a row that is gone or no longer eligible.
|
||||||
|
*/
|
||||||
|
readIds?(): Promise<string[]>;
|
||||||
|
readOne?(id: string): Promise<StoredSecret | null>;
|
||||||
/**
|
/**
|
||||||
* Writes `next` only while the row still holds `previous` - one UPDATE
|
* Writes `next` only while the row still holds `previous` - one UPDATE
|
||||||
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
||||||
@@ -59,8 +82,9 @@ interface SecretColumn {
|
|||||||
swap(id: string, previous: string, next: string): Promise<boolean>;
|
swap(id: string, previous: string, next: string): Promise<boolean>;
|
||||||
needsRekey(stored: string): boolean;
|
needsRekey(stored: string): boolean;
|
||||||
isPlaintext(stored: string): boolean;
|
isPlaintext(stored: string): boolean;
|
||||||
open(stored: string): string;
|
/** `id` binds voice content to its row (associated data); the other purposes ignore it. */
|
||||||
seal(plaintext: string): string;
|
open(stored: string, id: string): string;
|
||||||
|
seal(plaintext: string, id: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
||||||
@@ -87,6 +111,20 @@ function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sums the counts of columns that share one purpose (voice has three). */
|
||||||
|
function sumCounts(all: readonly RekeyCounts[]): RekeyCounts {
|
||||||
|
const sum = (key: keyof RekeyCounts): number =>
|
||||||
|
all.reduce((total, each) => total + each[key], 0);
|
||||||
|
return {
|
||||||
|
total: sum('total'),
|
||||||
|
rekeyed: sum('rekeyed'),
|
||||||
|
alreadyCurrent: sum('alreadyCurrent'),
|
||||||
|
plaintextSealed: sum('plaintextSealed'),
|
||||||
|
changedMeanwhile: sum('changedMeanwhile'),
|
||||||
|
failed: sum('failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
||||||
function toJobResult(
|
function toJobResult(
|
||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
@@ -112,7 +150,8 @@ function describeResult(result: JobResult): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reseals every stored MFA (TOTP) and webhook signing secret with the
|
* Reseals every stored MFA (TOTP) and webhook signing secret, and the
|
||||||
|
* content of open voice notes (transcript, suggestion, audio), with the
|
||||||
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
||||||
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
||||||
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
||||||
@@ -135,15 +174,30 @@ export class RekeyJob implements Job {
|
|||||||
|
|
||||||
async run(args: readonly string[]): Promise<JobResult> {
|
async run(args: readonly string[]): Promise<JobResult> {
|
||||||
const dryRun = isDryRun(args);
|
const dryRun = isDryRun(args);
|
||||||
const columns = [this.mfaColumn(), this.webhookColumn()];
|
const columns = [
|
||||||
|
this.mfaColumn(),
|
||||||
|
this.webhookColumn(),
|
||||||
|
...this.voiceColumns(),
|
||||||
|
];
|
||||||
// A key that is set but unusable fails the run before any row is read.
|
// A key that is set but unusable fails the run before any row is read.
|
||||||
for (const column of columns) {
|
for (const purpose of new Set(columns.map((column) => column.purpose))) {
|
||||||
loadKeyRing(column.purpose);
|
loadKeyRing(purpose);
|
||||||
}
|
}
|
||||||
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
const perColumn: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
perColumn.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||||
}
|
}
|
||||||
|
const counts = (['mfa', 'webhook', 'voice'] as const).map(
|
||||||
|
(purpose) =>
|
||||||
|
[
|
||||||
|
purpose,
|
||||||
|
sumCounts(
|
||||||
|
perColumn
|
||||||
|
.filter(([each]) => each === purpose)
|
||||||
|
.map(([, each]) => each),
|
||||||
|
),
|
||||||
|
] as const,
|
||||||
|
);
|
||||||
const result = toJobResult(dryRun, counts);
|
const result = toJobResult(dryRun, counts);
|
||||||
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
@@ -158,9 +212,17 @@ export class RekeyJob implements Job {
|
|||||||
column: SecretColumn,
|
column: SecretColumn,
|
||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
): Promise<RekeyCounts> {
|
): Promise<RekeyCounts> {
|
||||||
const rows = await column.read();
|
|
||||||
const outcomes: RowOutcome[] = [];
|
const outcomes: RowOutcome[] = [];
|
||||||
for (const row of rows) {
|
if (column.readIds && column.readOne) {
|
||||||
|
for (const id of await column.readIds()) {
|
||||||
|
const row = await column.readOne(id);
|
||||||
|
if (row !== null) {
|
||||||
|
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countOutcomes(outcomes);
|
||||||
|
}
|
||||||
|
for (const row of await column.read()) {
|
||||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||||
}
|
}
|
||||||
return countOutcomes(outcomes);
|
return countOutcomes(outcomes);
|
||||||
@@ -185,13 +247,13 @@ export class RekeyJob implements Job {
|
|||||||
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
||||||
try {
|
try {
|
||||||
// Opened in a dry-run too: it reports what a real run could not reseal.
|
// Opened in a dry-run too: it reports what a real run could not reseal.
|
||||||
const plaintext = column.open(row.stored);
|
const plaintext = column.open(row.stored, row.id);
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
const resealed = column.seal(plaintext);
|
const resealed = column.seal(plaintext, row.id);
|
||||||
// The swap replaces the only copy of the old value.
|
// The swap replaces the only copy of the old value.
|
||||||
if (column.open(resealed) !== plaintext) {
|
if (column.open(resealed, row.id) !== plaintext) {
|
||||||
throw new Error('resealed secret does not open to the same value');
|
throw new Error('resealed secret does not open to the same value');
|
||||||
}
|
}
|
||||||
if (await column.swap(row.id, row.stored, resealed)) {
|
if (await column.swap(row.id, row.stored, resealed)) {
|
||||||
@@ -262,4 +324,96 @@ export class RekeyJob implements Job {
|
|||||||
seal: sealWebhookSecret,
|
seal: sealWebhookSecret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcript, suggestion and audio of open voice notes. Audio is handled
|
||||||
|
* as base64 text so it shares the row logic; it is read only for open
|
||||||
|
* notes (at most a few small files).
|
||||||
|
*/
|
||||||
|
private voiceColumns(): SecretColumn[] {
|
||||||
|
const openStatus = { notIn: FINISHED_VOICE_STATUSES };
|
||||||
|
const textColumn = (
|
||||||
|
field: 'transcriptEnc' | 'suggestionEnc',
|
||||||
|
kind: 'transcript' | 'suggestion',
|
||||||
|
): SecretColumn => ({
|
||||||
|
purpose: 'voice',
|
||||||
|
entity: 'VoiceCapture',
|
||||||
|
read: async () => {
|
||||||
|
const rows = await this.prisma.voiceCapture.findMany({
|
||||||
|
where: { status: openStatus, [field]: { not: null } },
|
||||||
|
select: { id: true, [field]: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.flatMap((row) => {
|
||||||
|
const stored = row[field];
|
||||||
|
return typeof stored === 'string' ? [{ id: row.id, stored }] : [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
swap: async (id, previous, next) => {
|
||||||
|
const { count } = await this.prisma.voiceCapture.updateMany({
|
||||||
|
where: { id, status: openStatus, [field]: previous },
|
||||||
|
data: { [field]: next },
|
||||||
|
});
|
||||||
|
return count === 1;
|
||||||
|
},
|
||||||
|
needsRekey: (stored) => !isVoiceSealedWithCurrentKey(stored),
|
||||||
|
isPlaintext: () => false,
|
||||||
|
open: (stored, id) => openVoiceText(stored, voiceAad(id, kind)),
|
||||||
|
seal: (plaintext, id) => sealVoiceText(plaintext, voiceAad(id, kind)),
|
||||||
|
});
|
||||||
|
const audioColumn: SecretColumn = {
|
||||||
|
purpose: 'voice',
|
||||||
|
entity: 'VoiceCapture',
|
||||||
|
read: () => Promise.resolve([]),
|
||||||
|
readIds: async () => {
|
||||||
|
const rows = await this.prisma.voiceCaptureAudio.findMany({
|
||||||
|
where: { capture: { status: openStatus } },
|
||||||
|
select: { captureId: true },
|
||||||
|
orderBy: { captureId: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.captureId);
|
||||||
|
},
|
||||||
|
readOne: async (id) => {
|
||||||
|
const row = await this.prisma.voiceCaptureAudio.findFirst({
|
||||||
|
where: { captureId: id, capture: { status: openStatus } },
|
||||||
|
select: { captureId: true, dataEnc: true },
|
||||||
|
});
|
||||||
|
return row
|
||||||
|
? {
|
||||||
|
id: row.captureId,
|
||||||
|
stored: Buffer.from(row.dataEnc).toString('base64'),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
swap: async (id, previous, next) => {
|
||||||
|
const { count } = await this.prisma.voiceCaptureAudio.updateMany({
|
||||||
|
where: {
|
||||||
|
captureId: id,
|
||||||
|
dataEnc: Buffer.from(previous, 'base64'),
|
||||||
|
capture: { status: openStatus },
|
||||||
|
},
|
||||||
|
data: { dataEnc: Buffer.from(next, 'base64') },
|
||||||
|
});
|
||||||
|
return count === 1;
|
||||||
|
},
|
||||||
|
needsRekey: (stored) =>
|
||||||
|
!isVoiceSealedWithCurrentKey(Buffer.from(stored, 'base64')),
|
||||||
|
isPlaintext: () => false,
|
||||||
|
open: (stored, id) =>
|
||||||
|
openVoiceBytes(
|
||||||
|
Buffer.from(stored, 'base64'),
|
||||||
|
voiceAad(id, 'audio'),
|
||||||
|
).toString('base64'),
|
||||||
|
seal: (plaintext, id) =>
|
||||||
|
sealVoiceBytes(
|
||||||
|
Buffer.from(plaintext, 'base64'),
|
||||||
|
voiceAad(id, 'audio'),
|
||||||
|
).toString('base64'),
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
textColumn('transcriptEnc', 'transcript'),
|
||||||
|
textColumn('suggestionEnc', 'suggestion'),
|
||||||
|
audioColumn,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ const KEYS = {
|
|||||||
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
|
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
|
||||||
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
|
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
|
||||||
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
|
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
|
||||||
|
VOICE_ENCRYPTION_KEY: 'v'.repeat(32),
|
||||||
};
|
};
|
||||||
const PROD_ENV = {
|
const PROD_ENV = {
|
||||||
...BASE_ENV,
|
...BASE_ENV,
|
||||||
@@ -81,16 +82,17 @@ describe('assertStartupEnvironment', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('encryption keys', () => {
|
describe('encryption keys', () => {
|
||||||
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => {
|
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('worker', {
|
assertStartupEnvironment('worker', {
|
||||||
...PROD_ENV,
|
...PROD_ENV,
|
||||||
MFA_ENCRYPTION_KEY: undefined,
|
MFA_ENCRYPTION_KEY: undefined,
|
||||||
WEBHOOK_ENCRYPTION_KEY: undefined,
|
WEBHOOK_ENCRYPTION_KEY: undefined,
|
||||||
BACKUP_ENCRYPTION_KEY: undefined,
|
BACKUP_ENCRYPTION_KEY: undefined,
|
||||||
|
VOICE_ENCRYPTION_KEY: undefined,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY',
|
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY, VOICE_ENCRYPTION_KEY',
|
||||||
);
|
);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('job', {
|
assertStartupEnvironment('job', {
|
||||||
@@ -126,7 +128,7 @@ describe('assertStartupEnvironment', () => {
|
|||||||
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
|
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different',
|
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY and VOICE_ENCRYPTION_KEY must be pairwise different',
|
||||||
);
|
);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('api', {
|
assertStartupEnvironment('api', {
|
||||||
@@ -198,9 +200,11 @@ describe('assertStartupEnvironment', () => {
|
|||||||
'MFA_ENCRYPTION_KEY',
|
'MFA_ENCRYPTION_KEY',
|
||||||
'WEBHOOK_ENCRYPTION_KEY',
|
'WEBHOOK_ENCRYPTION_KEY',
|
||||||
'BACKUP_ENCRYPTION_KEY',
|
'BACKUP_ENCRYPTION_KEY',
|
||||||
|
'VOICE_ENCRYPTION_KEY',
|
||||||
'MFA_ENCRYPTION_KEY_PREVIOUS',
|
'MFA_ENCRYPTION_KEY_PREVIOUS',
|
||||||
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
|
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
|
||||||
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
|
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
|
||||||
|
'VOICE_ENCRYPTION_KEY_PREVIOUS',
|
||||||
])('production refuses a change-me placeholder in %s', (name) => {
|
])('production refuses a change-me placeholder in %s', (name) => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
|
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
|
||||||
@@ -360,4 +364,70 @@ describe('assertStartupEnvironment', () => {
|
|||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('voice', () => {
|
||||||
|
it('production requires VOICE_ENCRYPTION_KEY distinct from the others', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('api', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: undefined,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'Missing required environment variable(s): VOICE_ENCRYPTION_KEY',
|
||||||
|
);
|
||||||
|
for (const other of [
|
||||||
|
'MFA_ENCRYPTION_KEY',
|
||||||
|
'WEBHOOK_ENCRYPTION_KEY',
|
||||||
|
'BACKUP_ENCRYPTION_KEY',
|
||||||
|
] as const) {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('job', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: KEYS[other],
|
||||||
|
}),
|
||||||
|
).toThrow('must be pairwise different');
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('worker', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'VOICE_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a change-me VOICE_ENCRYPTION_KEY in production', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('api', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: 'change-me-to-a-fifth-random-value-32-chars',
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'Secret(s) still set to an example placeholder: VOICE_ENCRYPTION_KEY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unknown VOICE_MODEL in every role', () => {
|
||||||
|
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment(role, {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_MODEL: 'claude-unknown',
|
||||||
|
}),
|
||||||
|
).toThrow('VOICE_MODEL must be one of');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a bad ANTHROPIC_API_KEY never stops a pod', () => {
|
||||||
|
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment(role, {
|
||||||
|
...PROD_ENV,
|
||||||
|
ANTHROPIC_API_KEY: 'change-me-anthropic',
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
readRingSecrets,
|
readRingSecrets,
|
||||||
} from '../common/crypto/key-ring';
|
} from '../common/crypto/key-ring';
|
||||||
import { assertTelegramToken } from '../telegram/telegram-config';
|
import { assertTelegramToken } from '../telegram/telegram-config';
|
||||||
|
import { assertAnthropicKey } from '../voice/anthropic-config';
|
||||||
|
import { voiceConfig } from '../voice/voice-config';
|
||||||
import type { ApicoreRole } from './role';
|
import type { ApicoreRole } from './role';
|
||||||
|
|
||||||
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
||||||
@@ -132,8 +134,9 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
|
|||||||
/**
|
/**
|
||||||
* Startup checks shared by every entry point (API, worker, CLI job). All
|
* Startup checks shared by every entry point (API, worker, CLI job). All
|
||||||
* roles load the same modules and secrets today, so the checks hardly differ
|
* roles load the same modules and secrets today, so the checks hardly differ
|
||||||
* per role; the Telegram bot token is the first role-specific one (only the
|
* per role; the Telegram bot token and the Anthropic key are role-specific
|
||||||
* worker holds it).
|
* (only the worker holds them, both optional). The voice settings are
|
||||||
|
* validated in every role: a mistyped model id stops the pod, not a night job.
|
||||||
*/
|
*/
|
||||||
export function assertStartupEnvironment(
|
export function assertStartupEnvironment(
|
||||||
role: ApicoreRole,
|
role: ApicoreRole,
|
||||||
@@ -144,4 +147,6 @@ export function assertStartupEnvironment(
|
|||||||
assertNoPlaceholderSecrets(env);
|
assertNoPlaceholderSecrets(env);
|
||||||
assertEncryptionKeys(env);
|
assertEncryptionKeys(env);
|
||||||
assertTelegramToken(role, env);
|
assertTelegramToken(role, env);
|
||||||
|
voiceConfig(env);
|
||||||
|
assertAnthropicKey(role, env);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { anthropicApiKey, assertAnthropicKey } from './anthropic-config';
|
||||||
|
|
||||||
|
const KEY = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
|
||||||
|
function fakeLogger() {
|
||||||
|
return { warn: jest.fn(), error: jest.fn() } satisfies Pick<
|
||||||
|
Logger,
|
||||||
|
'warn' | 'error'
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('anthropicApiKey', () => {
|
||||||
|
it('returns a well-formed key, trimmed', () => {
|
||||||
|
expect(anthropicApiKey({ ANTHROPIC_API_KEY: ` ${KEY} ` })).toBe(KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['unset', {}],
|
||||||
|
['empty', { ANTHROPIC_API_KEY: ' ' }],
|
||||||
|
['a placeholder', { ANTHROPIC_API_KEY: 'change-me-anthropic-key' }],
|
||||||
|
['the wrong format', { ANTHROPIC_API_KEY: 'sk-ant-short' }],
|
||||||
|
['dummy', { ANTHROPIC_API_KEY: 'dummy' }],
|
||||||
|
])('returns null for %s', (_label, env) => {
|
||||||
|
expect(anthropicApiKey(env)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertAnthropicKey', () => {
|
||||||
|
it('assertAnthropicKey never throws and disables on a placeholder', () => {
|
||||||
|
for (const value of ['change-me-anthropic-key', 'dummy', '', 'sk-ant-x']) {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
expect(() =>
|
||||||
|
assertAnthropicKey(
|
||||||
|
'worker',
|
||||||
|
{ NODE_ENV: 'production', ANTHROPIC_API_KEY: value },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(
|
||||||
|
'worker',
|
||||||
|
{ NODE_ENV: 'production', ANTHROPIC_API_KEY: 'change-me-x' },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(anthropicApiKey({ ANTHROPIC_API_KEY: 'change-me-x' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the variable name once, never the value', () => {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(
|
||||||
|
'worker',
|
||||||
|
{ ANTHROPIC_API_KEY: 'sk-ant-short-secret' },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const output = JSON.stringify([
|
||||||
|
logger.warn.mock.calls,
|
||||||
|
logger.error.mock.calls,
|
||||||
|
]);
|
||||||
|
expect(output).toContain('ANTHROPIC_API_KEY');
|
||||||
|
expect(output).not.toContain('short-secret');
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('api and job roles skip the Anthropic check', () => {
|
||||||
|
for (const role of ['api', 'job'] as const) {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(role, { ANTHROPIC_API_KEY: 'garbage' }, logger);
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a good key silently in the worker and in all-in-one mode', () => {
|
||||||
|
for (const role of ['worker', 'all'] as const) {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(role, { ANTHROPIC_API_KEY: KEY }, logger);
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns that extraction stays off when the worker has no key', () => {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey('worker', {}, logger);
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { ApicoreRole } from '../runtime/role';
|
||||||
|
|
||||||
|
/** Key format issued by Anthropic: "sk-ant-" followed by URL-safe characters. */
|
||||||
|
export const ANTHROPIC_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]{20,}$/;
|
||||||
|
|
||||||
|
const PLACEHOLDER_PATTERN = /^change-me/i;
|
||||||
|
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
|
||||||
|
|
||||||
|
function trimmed(value: string | undefined): string | null {
|
||||||
|
const text = value?.trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API key for voice extraction, or null if it is unset, a placeholder or
|
||||||
|
* malformed - extraction then stays off (code EXTRACT_DISABLED). Only the
|
||||||
|
* worker holds the key (Secret apicore-anthropic, optional).
|
||||||
|
*/
|
||||||
|
export function anthropicApiKey(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string | null {
|
||||||
|
const key = trimmed(env.ANTHROPIC_API_KEY);
|
||||||
|
return key !== null && ANTHROPIC_KEY_PATTERN.test(key) ? key : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup check, same rules as the Telegram token: optional, a bad value
|
||||||
|
* never stops a pod. In the worker, a missing key is a warning and a
|
||||||
|
* placeholder or malformed key an error, once, naming the variable and
|
||||||
|
* never its value. API and job pods never hold the key and skip the check.
|
||||||
|
*/
|
||||||
|
export function assertAnthropicKey(
|
||||||
|
role: ApicoreRole,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
logger: Pick<Logger, 'warn' | 'error'> = new Logger('AnthropicStartup'),
|
||||||
|
): void {
|
||||||
|
if (!WORKER_ROLES.includes(role)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = trimmed(env.ANTHROPIC_API_KEY);
|
||||||
|
if (raw === null) {
|
||||||
|
logger.warn(
|
||||||
|
'ANTHROPIC_API_KEY is not set: voice extraction stays off (EXTRACT_DISABLED).',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (PLACEHOLDER_PATTERN.test(raw)) {
|
||||||
|
logger.error(
|
||||||
|
'ANTHROPIC_API_KEY is still set to an example placeholder: voice extraction stays off - set the real key or remove it.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ANTHROPIC_KEY_PATTERN.test(raw)) {
|
||||||
|
logger.error(
|
||||||
|
'ANTHROPIC_API_KEY is malformed (expected "sk-ant-..."): voice extraction stays off.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { getMetadataStorage } from 'class-validator';
|
||||||
|
import { ColonyCondition } from '../../../generated/prisma';
|
||||||
|
import { CreateInspectionDto } from '../../inspections/dto/create-inspection.dto';
|
||||||
|
import {
|
||||||
|
FIELD_SPECS,
|
||||||
|
INSPECTION_VOICE_FIELDS,
|
||||||
|
VOICE_EXCLUDED_INSPECTION_FIELDS,
|
||||||
|
checkFieldValue,
|
||||||
|
} from './inspection-field-specs';
|
||||||
|
|
||||||
|
interface DtoRule {
|
||||||
|
name: string;
|
||||||
|
constraints: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The class-validator rules of every property of the DTO. */
|
||||||
|
function dtoRules(): Map<string, DtoRule[]> {
|
||||||
|
const rules = new Map<string, DtoRule[]>();
|
||||||
|
for (const meta of getMetadataStorage().getTargetValidationMetadatas(
|
||||||
|
CreateInspectionDto,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)) {
|
||||||
|
const list = rules.get(meta.propertyName) ?? [];
|
||||||
|
list.push({
|
||||||
|
name: meta.name ?? meta.type,
|
||||||
|
constraints: meta.constraints ?? [],
|
||||||
|
});
|
||||||
|
rules.set(meta.propertyName, list);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FIELD_SPECS', () => {
|
||||||
|
it('FIELD_SPECS covers exactly the DTO fields minus the excluded list', () => {
|
||||||
|
const dtoFields = [...dtoRules().keys()];
|
||||||
|
const covered = dtoFields
|
||||||
|
.filter((field) => !(field in VOICE_EXCLUDED_INSPECTION_FIELDS))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
expect(covered).toEqual([...INSPECTION_VOICE_FIELDS].sort());
|
||||||
|
expect(Object.keys(FIELD_SPECS).sort()).toEqual(covered);
|
||||||
|
expect(INSPECTION_VOICE_FIELDS).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every excluded field a reason', () => {
|
||||||
|
expect(Object.keys(VOICE_EXCLUDED_INSPECTION_FIELDS).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'applyToColony',
|
||||||
|
'broodAmount',
|
||||||
|
'colonyId',
|
||||||
|
'date',
|
||||||
|
'hiveChange',
|
||||||
|
'temperature',
|
||||||
|
'voiceCaptureId',
|
||||||
|
'weather',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
for (const reason of Object.values(VOICE_EXCLUDED_INSPECTION_FIELDS)) {
|
||||||
|
expect(reason.length).toBeGreaterThan(10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FIELD_SPECS bounds lie within CreateInspectionDto', () => {
|
||||||
|
const rules = dtoRules();
|
||||||
|
for (const field of INSPECTION_VOICE_FIELDS) {
|
||||||
|
const spec = FIELD_SPECS[field];
|
||||||
|
const fieldRules = rules.get(field) ?? [];
|
||||||
|
if (spec.kind === 'int') {
|
||||||
|
const min = fieldRules.find((rule) => rule.name === 'min');
|
||||||
|
const max = fieldRules.find((rule) => rule.name === 'max');
|
||||||
|
if (min) {
|
||||||
|
expect(spec.min).toBeGreaterThanOrEqual(min.constraints[0] as number);
|
||||||
|
}
|
||||||
|
if (max) {
|
||||||
|
expect(spec.max).toBeLessThanOrEqual(max.constraints[0] as number);
|
||||||
|
}
|
||||||
|
expect(fieldRules.map((rule) => rule.name)).toContain('isInt');
|
||||||
|
}
|
||||||
|
if (spec.kind === 'bool') {
|
||||||
|
expect(fieldRules.map((rule) => rule.name)).toContain('isBoolean');
|
||||||
|
}
|
||||||
|
if (spec.kind === 'text') {
|
||||||
|
expect(fieldRules.map((rule) => rule.name)).toContain('isString');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The DTO has no upper bound for broodFrames; the voice path adds one.
|
||||||
|
expect(FIELD_SPECS.broodFrames).toEqual({ kind: 'int', min: 0, max: 40 });
|
||||||
|
expect(FIELD_SPECS.colonyStrength).toEqual({
|
||||||
|
kind: 'int',
|
||||||
|
min: 0,
|
||||||
|
max: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feedStatus accepts every ColonyCondition and nothing else', () => {
|
||||||
|
for (const value of Object.values(ColonyCondition)) {
|
||||||
|
expect(checkFieldValue('feedStatus', value)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const value of ['ausreichend', 'good', 'OK', '', 3, null]) {
|
||||||
|
expect(checkFieldValue('feedStatus', value)).toEqual({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkFieldValue', () => {
|
||||||
|
it('accepts integers within the bounds and rejects the rest', () => {
|
||||||
|
expect(checkFieldValue('colonyStrength', 40)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: 40,
|
||||||
|
});
|
||||||
|
expect(checkFieldValue('colonyStrength', 0)).toMatchObject({ ok: true });
|
||||||
|
for (const value of [41, -1, 7.5, '7', true, null, Number.NaN]) {
|
||||||
|
expect(checkFieldValue('colonyStrength', value)).toEqual({ ok: false });
|
||||||
|
}
|
||||||
|
expect(checkFieldValue('broodPattern', 1)).toMatchObject({ ok: true });
|
||||||
|
expect(checkFieldValue('broodPattern', 5)).toMatchObject({ ok: true });
|
||||||
|
expect(checkFieldValue('broodPattern', 0)).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('broodPattern', 6)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts booleans only as booleans', () => {
|
||||||
|
expect(checkFieldValue('queenSeen', false)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
expect(checkFieldValue('queenSeen', 'true')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('queenSeen', 1)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes text and reports shortening', () => {
|
||||||
|
expect(checkFieldValue('notes', 'Ruhig <b>und</b> sanft')).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: 'Ruhig bund/b sanft',
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
const long = checkFieldValue('healthIssues', 'x'.repeat(700));
|
||||||
|
expect(long).toMatchObject({ ok: true, truncated: true });
|
||||||
|
expect(long.ok && String(long.value)).toHaveLength(500);
|
||||||
|
expect(checkFieldValue('measures', ' <> ')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('measures', 5)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects weather and temperature and unknown fields', () => {
|
||||||
|
expect(checkFieldValue('weather', 'sonnig')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('temperature', 21)).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('colonyId', 'abc')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('constructor', 'x')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('__proto__', 'x')).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { ColonyCondition } from '../../../generated/prisma';
|
||||||
|
import { sanitizeFreeText } from './voice-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inspection fields a voice note may suggest. The limits follow the
|
||||||
|
* existing form and CreateInspectionDto (ruling 2): strength up to 40, five
|
||||||
|
* feed levels. broodFrames has no upper bound in the DTO; the voice path
|
||||||
|
* adds 40 so a misheard number cannot reach the form.
|
||||||
|
*/
|
||||||
|
export const INSPECTION_VOICE_FIELDS = [
|
||||||
|
'colonyStrength',
|
||||||
|
'broodFrames',
|
||||||
|
'broodPattern',
|
||||||
|
'openBroodPresent',
|
||||||
|
'cappedBroodPresent',
|
||||||
|
'eggsPresent',
|
||||||
|
'queenSeen',
|
||||||
|
'queenCellsPresent',
|
||||||
|
'swarmMood',
|
||||||
|
'gentleness',
|
||||||
|
'combBehavior',
|
||||||
|
'feedStatus',
|
||||||
|
'honeySuperStatus',
|
||||||
|
'healthIssues',
|
||||||
|
'varroaIssues',
|
||||||
|
'measures',
|
||||||
|
'notes',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type InspectionVoiceField = (typeof INSPECTION_VOICE_FIELDS)[number];
|
||||||
|
|
||||||
|
export type FieldSpec =
|
||||||
|
| { kind: 'int'; min: number; max: number }
|
||||||
|
| { kind: 'bool' }
|
||||||
|
| { kind: 'enum'; values: readonly string[] }
|
||||||
|
| { kind: 'text'; max: 500 };
|
||||||
|
|
||||||
|
const TEXT_MAX = 500;
|
||||||
|
const FRAME_MAX = 40;
|
||||||
|
const SCALE: FieldSpec = { kind: 'int', min: 1, max: 5 };
|
||||||
|
const BOOL: FieldSpec = { kind: 'bool' };
|
||||||
|
const TEXT: FieldSpec = { kind: 'text', max: TEXT_MAX };
|
||||||
|
|
||||||
|
export const FIELD_SPECS: Readonly<Record<InspectionVoiceField, FieldSpec>> = {
|
||||||
|
colonyStrength: { kind: 'int', min: 0, max: FRAME_MAX },
|
||||||
|
broodFrames: { kind: 'int', min: 0, max: FRAME_MAX },
|
||||||
|
broodPattern: SCALE,
|
||||||
|
openBroodPresent: BOOL,
|
||||||
|
cappedBroodPresent: BOOL,
|
||||||
|
eggsPresent: BOOL,
|
||||||
|
queenSeen: BOOL,
|
||||||
|
queenCellsPresent: BOOL,
|
||||||
|
swarmMood: SCALE,
|
||||||
|
gentleness: SCALE,
|
||||||
|
combBehavior: SCALE,
|
||||||
|
feedStatus: { kind: 'enum', values: Object.values(ColonyCondition) },
|
||||||
|
honeySuperStatus: TEXT,
|
||||||
|
healthIssues: TEXT,
|
||||||
|
varroaIssues: TEXT,
|
||||||
|
measures: TEXT,
|
||||||
|
notes: TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DTO properties a voice note never fills, each with the reason. */
|
||||||
|
export const VOICE_EXCLUDED_INSPECTION_FIELDS: Readonly<
|
||||||
|
Record<string, string>
|
||||||
|
> = {
|
||||||
|
colonyId: 'The colony comes from the voice note itself, never from speech.',
|
||||||
|
date: 'The date comes from the voice note (inspectionDate), not from speech.',
|
||||||
|
weather: 'Weather is fetched from the weather service, not spoken.',
|
||||||
|
temperature: 'Temperature is fetched from the weather service, not spoken.',
|
||||||
|
broodAmount: 'Legacy free text; broodFrames and broodPattern replace it.',
|
||||||
|
applyToColony: 'Taking values over to the colony card is a separate click.',
|
||||||
|
hiveChange: 'Hive operations are never created from speech.',
|
||||||
|
voiceCaptureId: 'Links the voice note; it is not an observation.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckedFieldValue =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
field: InspectionVoiceField;
|
||||||
|
value: number | boolean | string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
| { ok: false };
|
||||||
|
|
||||||
|
const REJECTED: CheckedFieldValue = { ok: false };
|
||||||
|
|
||||||
|
function accepted(
|
||||||
|
field: InspectionVoiceField,
|
||||||
|
value: number | boolean | string,
|
||||||
|
truncated = false,
|
||||||
|
): CheckedFieldValue {
|
||||||
|
return { ok: true, field, value, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVoiceField(field: string): field is InspectionVoiceField {
|
||||||
|
return Object.hasOwn(FIELD_SPECS, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks one suggested value against its limits; text is cleaned and shortened. */
|
||||||
|
export function checkFieldValue(
|
||||||
|
field: string,
|
||||||
|
value: unknown,
|
||||||
|
): CheckedFieldValue {
|
||||||
|
if (!isVoiceField(field)) {
|
||||||
|
return REJECTED;
|
||||||
|
}
|
||||||
|
const spec = FIELD_SPECS[field];
|
||||||
|
switch (spec.kind) {
|
||||||
|
case 'int':
|
||||||
|
return typeof value === 'number' &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= spec.min &&
|
||||||
|
value <= spec.max
|
||||||
|
? accepted(field, value)
|
||||||
|
: REJECTED;
|
||||||
|
case 'bool':
|
||||||
|
return typeof value === 'boolean' ? accepted(field, value) : REJECTED;
|
||||||
|
case 'enum':
|
||||||
|
return typeof value === 'string' && spec.values.includes(value)
|
||||||
|
? accepted(field, value)
|
||||||
|
: REJECTED;
|
||||||
|
case 'text': {
|
||||||
|
const text =
|
||||||
|
typeof value === 'string' ? sanitizeFreeText(value, spec.max) : null;
|
||||||
|
return text ? accepted(field, text.text, text.truncated) : REJECTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* The four 1-5 scales of the inspection form: what the STORED ends mean
|
||||||
|
* (the form shows swarm mood mirrored, but stores 1 = ruhig) and the words
|
||||||
|
* a beekeeper says for them. The same data is in
|
||||||
|
* test/fixtures/voice/inspection-scales.json for the frontend guard; a test
|
||||||
|
* keeps both equal (the TS module cannot import the JSON: it lies outside
|
||||||
|
* src, and the build's root directory must stay src).
|
||||||
|
*/
|
||||||
|
export interface ScaleSpec {
|
||||||
|
field: 'broodPattern' | 'swarmMood' | 'gentleness' | 'combBehavior';
|
||||||
|
/** Meaning of the stored value 1. */
|
||||||
|
storedOne: string;
|
||||||
|
/** Meaning of the stored value 5. */
|
||||||
|
storedFive: string;
|
||||||
|
/** Spoken word -> stored value. */
|
||||||
|
anchors: Record<string, 1 | 2 | 3 | 4 | 5>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INSPECTION_SCALES: readonly ScaleSpec[] = [
|
||||||
|
{
|
||||||
|
field: 'broodPattern',
|
||||||
|
storedOne: 'lückenhaft',
|
||||||
|
storedFive: 'geschlossen',
|
||||||
|
anchors: { lückenhaft: 1, mittel: 3, geschlossen: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'swarmMood',
|
||||||
|
storedOne: 'ruhig',
|
||||||
|
storedFive: 'stark',
|
||||||
|
anchors: { ruhig: 1, mittel: 3, stark: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'gentleness',
|
||||||
|
storedOne: 'stechlustig',
|
||||||
|
storedFive: 'sehr sanft',
|
||||||
|
anchors: { stechlustig: 1, normal: 3, 'sehr sanft': 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'combBehavior',
|
||||||
|
storedOne: 'läuft weg',
|
||||||
|
storedFive: 'sitzt ruhig',
|
||||||
|
anchors: { 'läuft weg': 1, mittel: 3, 'sitzt ruhig': 5 },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { sealVoiceText, voiceAad } from './voice-crypto';
|
||||||
|
import {
|
||||||
|
MAX_TRANSCRIPT_CHARS,
|
||||||
|
type TranscriptPayload,
|
||||||
|
openTranscript,
|
||||||
|
sealTranscript,
|
||||||
|
} from './transcript';
|
||||||
|
|
||||||
|
describe('transcript', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'j'.repeat(40);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = 'a'.repeat(40);
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transcript payload round-trips with segments', () => {
|
||||||
|
const payload: TranscriptPayload = {
|
||||||
|
v: 1,
|
||||||
|
text: 'Königin gesehen. Acht Waben.',
|
||||||
|
durationSec: 12,
|
||||||
|
segments: [
|
||||||
|
{ start: 0, end: 3.2, text: 'Königin gesehen.' },
|
||||||
|
{ start: 3.2, end: 6, text: 'Acht Waben.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sealed = sealTranscript('c1', payload);
|
||||||
|
|
||||||
|
expect(sealed).toMatch(/^k[0-9a-f]{8}:/);
|
||||||
|
expect(sealed).not.toContain('Königin');
|
||||||
|
expect(openTranscript('c1', sealed)).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a text-only payload', () => {
|
||||||
|
const payload: TranscriptPayload = { v: 1, text: 'Nur Text' };
|
||||||
|
expect(openTranscript('c1', sealTranscript('c1', payload))).toEqual(
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is bound to its capture', () => {
|
||||||
|
const sealed = sealTranscript('c1', { v: 1, text: 'x' });
|
||||||
|
expect(() => openTranscript('c2', sealed)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a payload with another version', () => {
|
||||||
|
const sealed = sealVoiceText(
|
||||||
|
JSON.stringify({ v: 2, text: 'geheim' }),
|
||||||
|
voiceAad('c1', 'transcript'),
|
||||||
|
);
|
||||||
|
expect(() => openTranscript('c1', sealed)).toThrow(
|
||||||
|
'unsupported transcript payload',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['not json at all geheim', 'unsupported transcript payload'],
|
||||||
|
[JSON.stringify({ v: 1, text: 5 }), 'unsupported transcript payload'],
|
||||||
|
[
|
||||||
|
JSON.stringify({ v: 1, text: 'a', segments: [{ start: 'x' }] }),
|
||||||
|
'unsupported transcript payload',
|
||||||
|
],
|
||||||
|
[JSON.stringify(null), 'unsupported transcript payload'],
|
||||||
|
])('rejects a malformed payload without echoing it', (body, message) => {
|
||||||
|
const sealed = sealVoiceText(body, voiceAad('c1', 'transcript'));
|
||||||
|
let thrown = '';
|
||||||
|
try {
|
||||||
|
openTranscript('c1', sealed);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = (error as Error).message;
|
||||||
|
}
|
||||||
|
expect(thrown).toBe(message);
|
||||||
|
expect(thrown).not.toContain('geheim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-finite numbers and oversized text', () => {
|
||||||
|
const open = (body: string): unknown =>
|
||||||
|
openTranscript('c1', sealVoiceText(body, voiceAad('c1', 'transcript')));
|
||||||
|
const bad = 'unsupported transcript payload';
|
||||||
|
expect(() => open('{"v":1,"text":"a","durationSec":1e999}')).toThrow(bad);
|
||||||
|
expect(() =>
|
||||||
|
open(
|
||||||
|
'{"v":1,"text":"a","segments":[{"start":1e999,"end":2,"text":"b"}]}',
|
||||||
|
),
|
||||||
|
).toThrow(bad);
|
||||||
|
expect(() =>
|
||||||
|
open(
|
||||||
|
JSON.stringify({ v: 1, text: 'x'.repeat(MAX_TRANSCRIPT_CHARS + 1) }),
|
||||||
|
),
|
||||||
|
).toThrow(bad);
|
||||||
|
expect(
|
||||||
|
open(JSON.stringify({ v: 1, text: 'x'.repeat(MAX_TRANSCRIPT_CHARS) })),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only the known keys', () => {
|
||||||
|
const sealed = sealVoiceText(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
text: 'a',
|
||||||
|
extra: 'x',
|
||||||
|
segments: [{ start: 0, end: 1, text: 'b', more: 1 }],
|
||||||
|
}),
|
||||||
|
voiceAad('c1', 'transcript'),
|
||||||
|
);
|
||||||
|
expect(openTranscript('c1', sealed)).toEqual({
|
||||||
|
v: 1,
|
||||||
|
text: 'a',
|
||||||
|
segments: [{ start: 0, end: 1, text: 'b' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { openVoiceText, sealVoiceText, voiceAad } from './voice-crypto';
|
||||||
|
|
||||||
|
export interface TranscriptSegment {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `transcriptEnc` holds once opened. Segments let part 4 join chunks. */
|
||||||
|
export interface TranscriptPayload {
|
||||||
|
v: 1;
|
||||||
|
text: string;
|
||||||
|
durationSec?: number;
|
||||||
|
segments?: TranscriptSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound for the text of one transcript. Whisper gets at most 240 s of
|
||||||
|
* speech (a few thousand characters); this leaves room and stops a forged
|
||||||
|
* or damaged payload from growing without limit.
|
||||||
|
*/
|
||||||
|
export const MAX_TRANSCRIPT_CHARS = 50000;
|
||||||
|
const MAX_SEGMENTS = 5000;
|
||||||
|
|
||||||
|
const UNSUPPORTED = 'unsupported transcript payload';
|
||||||
|
|
||||||
|
function isText(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.length <= MAX_TRANSCRIPT_CHARS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the known keys survive. */
|
||||||
|
function toSegment(value: unknown): TranscriptSegment | null {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { start, end, text } = value as Record<string, unknown>;
|
||||||
|
return Number.isFinite(start) && Number.isFinite(end) && isText(text)
|
||||||
|
? { start: start as number, end: end as number, text }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(value: unknown): TranscriptPayload | null {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { v, text, durationSec, segments } = value as Record<string, unknown>;
|
||||||
|
if (v !== 1 || !isText(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload: TranscriptPayload = { v: 1, text };
|
||||||
|
if (durationSec !== undefined) {
|
||||||
|
if (!Number.isFinite(durationSec)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
payload.durationSec = durationSec as number;
|
||||||
|
}
|
||||||
|
if (segments !== undefined) {
|
||||||
|
if (!Array.isArray(segments) || segments.length > MAX_SEGMENTS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const checked = segments.map(toSegment);
|
||||||
|
if (checked.some((each) => each === null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
payload.segments = checked as TranscriptSegment[];
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealTranscript(
|
||||||
|
captureId: string,
|
||||||
|
payload: TranscriptPayload,
|
||||||
|
): string {
|
||||||
|
return sealVoiceText(
|
||||||
|
JSON.stringify(payload),
|
||||||
|
voiceAad(captureId, 'transcript'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens and checks a transcript. A broken payload throws a fixed message:
|
||||||
|
* JSON.parse errors quote the input, so they are never passed on.
|
||||||
|
*/
|
||||||
|
export function openTranscript(
|
||||||
|
captureId: string,
|
||||||
|
stored: string,
|
||||||
|
): TranscriptPayload {
|
||||||
|
const json = openVoiceText(stored, voiceAad(captureId, 'transcript'));
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
throw new Error(UNSUPPORTED);
|
||||||
|
}
|
||||||
|
const payload = toPayload(parsed);
|
||||||
|
if (payload === null) {
|
||||||
|
throw new Error(UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { createHash, hkdfSync } from 'node:crypto';
|
||||||
|
import { loadKeyRing } from '../../common/crypto/key-ring';
|
||||||
|
import {
|
||||||
|
isVoiceSealedWithCurrentKey,
|
||||||
|
openVoiceBytes,
|
||||||
|
openVoiceText,
|
||||||
|
sealVoiceBytes,
|
||||||
|
sealVoiceText,
|
||||||
|
voiceAad,
|
||||||
|
} from './voice-crypto';
|
||||||
|
|
||||||
|
const JWT_SECRET = 'j'.repeat(40);
|
||||||
|
const KEY_A = 'a'.repeat(40);
|
||||||
|
const KEY_B = 'b'.repeat(40);
|
||||||
|
const AAD = 'capture:c1:transcript';
|
||||||
|
|
||||||
|
describe('voice-crypto', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = JWT_SECRET;
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = KEY_A;
|
||||||
|
delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audio and transcript round-trip through the key ring', () => {
|
||||||
|
const text = 'Königin gesehen, 8 Waben besetzt.';
|
||||||
|
const audio = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 1, 2, 3, 255, 0]);
|
||||||
|
|
||||||
|
const sealedText = sealVoiceText(text, AAD);
|
||||||
|
const sealedAudio = sealVoiceBytes(audio, 'capture:c1:audio');
|
||||||
|
|
||||||
|
expect(sealedText).toMatch(/^k[0-9a-f]{8}:/);
|
||||||
|
expect(sealedText).not.toContain('Königin');
|
||||||
|
expect(openVoiceText(sealedText, AAD)).toBe(text);
|
||||||
|
expect(openVoiceBytes(sealedAudio, 'capture:c1:audio').equals(audio)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(sealedAudio.length).toBe(1 + 8 + 12 + 16 + audio.length);
|
||||||
|
expect(sealedAudio[0]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seals the same text differently every time', () => {
|
||||||
|
expect(sealVoiceText('x', AAD)).not.toBe(sealVoiceText('x', AAD));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail with the wrong key', () => {
|
||||||
|
const sealedText = sealVoiceText('geheim', AAD);
|
||||||
|
const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = KEY_B;
|
||||||
|
|
||||||
|
expect(() => openVoiceText(sealedText, AAD)).toThrow('unknown key id');
|
||||||
|
expect(() => openVoiceBytes(sealedAudio, AAD)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens values sealed with the previous key', () => {
|
||||||
|
const sealedText = sealVoiceText('geheim', AAD);
|
||||||
|
const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = KEY_B;
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = KEY_A;
|
||||||
|
|
||||||
|
expect(openVoiceText(sealedText, AAD)).toBe('geheim');
|
||||||
|
expect(openVoiceBytes(sealedAudio, AAD).toString()).toBe('geheim');
|
||||||
|
expect(isVoiceSealedWithCurrentKey(sealedText)).toBe(false);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(sealedAudio)).toBe(false);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(sealVoiceText('neu', AAD))).toBe(true);
|
||||||
|
expect(
|
||||||
|
isVoiceSealedWithCurrentKey(sealVoiceBytes(Buffer.from('neu'), AAD)),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail when the aad names another capture', () => {
|
||||||
|
const sealedText = sealVoiceText('geheim', voiceAad('c1', 'transcript'));
|
||||||
|
const sealedAudio = sealVoiceBytes(
|
||||||
|
Buffer.from('geheim'),
|
||||||
|
voiceAad('c1', 'audio'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
openVoiceText(sealedText, voiceAad('c2', 'transcript')),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
|
expect(() =>
|
||||||
|
openVoiceText(sealedText, voiceAad('c1', 'suggestion')),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
|
expect(() => openVoiceBytes(sealedAudio, voiceAad('c2', 'audio'))).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail on changed bytes, a short value and an unknown format', () => {
|
||||||
|
const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
const changed = Buffer.from(sealedAudio);
|
||||||
|
changed[changed.length - 1] ^= 0xff;
|
||||||
|
|
||||||
|
expect(() => openVoiceBytes(changed, AAD)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
expect(() => openVoiceBytes(sealedAudio.subarray(0, 20), AAD)).toThrow(
|
||||||
|
'malformed ciphertext',
|
||||||
|
);
|
||||||
|
const otherVersion = Buffer.from(sealedAudio);
|
||||||
|
otherVersion[0] = 2;
|
||||||
|
expect(() => openVoiceBytes(otherVersion, AAD)).toThrow(
|
||||||
|
'unsupported ciphertext version',
|
||||||
|
);
|
||||||
|
expect(() => openVoiceText('plain text', AAD)).toThrow('missing key id');
|
||||||
|
expect(() => openVoiceText('k00000000:a.b', AAD)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use the voice purpose key, not the JWT secret', () => {
|
||||||
|
const key = Buffer.from(
|
||||||
|
hkdfSync('sha256', KEY_A, 'apicore-key-ring', 'apicore-voice-v1', 32),
|
||||||
|
);
|
||||||
|
const keyId = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
|
||||||
|
|
||||||
|
expect(loadKeyRing('voice').current.id).toBe(keyId);
|
||||||
|
expect(sealVoiceText('x', AAD).startsWith(`${keyId}:`)).toBe(true);
|
||||||
|
expect(
|
||||||
|
sealVoiceBytes(Buffer.from('x'), AAD).subarray(1, 9).toString(),
|
||||||
|
).toBe(keyId.slice(1));
|
||||||
|
// Rotating the JWT secret changes nothing for stored voice content.
|
||||||
|
const sealed = sealVoiceText('geheim', AAD);
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
expect(openVoiceText(sealed, AAD)).toBe('geheim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the aad per capture and kind', () => {
|
||||||
|
expect(voiceAad('abc', 'transcript')).toBe('capture:abc:transcript');
|
||||||
|
expect(voiceAad('abc', 'suggestion')).toBe('capture:abc:suggestion');
|
||||||
|
expect(voiceAad('abc', 'audio')).toBe('capture:abc:audio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a key id that is not eight hex characters', () => {
|
||||||
|
const sealed = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
const bad = [
|
||||||
|
Buffer.from('ZZZZZZZZ'),
|
||||||
|
Buffer.from([0xe1, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68]),
|
||||||
|
];
|
||||||
|
for (const id of bad) {
|
||||||
|
const changed = Buffer.from(sealed);
|
||||||
|
id.copy(changed, 1);
|
||||||
|
expect(() => openVoiceBytes(changed, AAD)).toThrow(
|
||||||
|
'malformed ciphertext',
|
||||||
|
);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(changed)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
KEY_ID_PATTERN,
|
||||||
|
findRingKey,
|
||||||
|
gcmOpen,
|
||||||
|
gcmSeal,
|
||||||
|
loadKeyRing,
|
||||||
|
} from '../../common/crypto/key-ring';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice content (audio, transcript, suggestions) is sealed with the key ring
|
||||||
|
* of the purpose `voice` (VOICE_ENCRYPTION_KEY). Every value is bound to its
|
||||||
|
* row through the GCM associated data, so a ciphertext moved to another
|
||||||
|
* capture, or to another kind of content, no longer opens.
|
||||||
|
*
|
||||||
|
* Text: `k<8 hex>:<iv>.<tag>.<data>` (base64 parts), like the other purposes.
|
||||||
|
* Bytes: `[1 byte version][8 byte key id (ASCII hex)][12 iv][16 tag][data]`.
|
||||||
|
* Errors carry no input.
|
||||||
|
*/
|
||||||
|
export type VoiceContentKind = 'transcript' | 'suggestion' | 'audio';
|
||||||
|
|
||||||
|
const BYTES_VERSION = 1;
|
||||||
|
const KEY_ID_BYTES = 8;
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
const TAG_BYTES = 16;
|
||||||
|
const KEY_ID_HEX = /^[0-9a-f]{8}$/;
|
||||||
|
const BYTES_HEADER = 1 + KEY_ID_BYTES + IV_BYTES + TAG_BYTES;
|
||||||
|
|
||||||
|
/** The associated data of one kind of content of one capture. */
|
||||||
|
export function voiceAad(captureId: string, kind: VoiceContentKind): string {
|
||||||
|
return `capture:${captureId}:${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aadBuffer(aad: string): Buffer {
|
||||||
|
return Buffer.from(aad, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealVoiceText(plaintext: string, aad: string): string {
|
||||||
|
const { current } = loadKeyRing('voice');
|
||||||
|
const sealed = gcmSeal(
|
||||||
|
current.key,
|
||||||
|
Buffer.from(plaintext, 'utf8'),
|
||||||
|
aadBuffer(aad),
|
||||||
|
);
|
||||||
|
const body = [sealed.iv, sealed.tag, sealed.ciphertext]
|
||||||
|
.map((part) => part.toString('base64'))
|
||||||
|
.join('.');
|
||||||
|
return `${current.id}:${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openVoiceText(stored: string, aad: string): string {
|
||||||
|
if (!KEY_ID_PATTERN.test(stored)) {
|
||||||
|
throw new Error('missing key id');
|
||||||
|
}
|
||||||
|
const separator = stored.indexOf(':');
|
||||||
|
const ringKey = findRingKey(loadKeyRing('voice'), stored.slice(0, separator));
|
||||||
|
if (!ringKey) {
|
||||||
|
throw new Error('unknown key id');
|
||||||
|
}
|
||||||
|
const parts = stored.slice(separator + 1).split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('malformed ciphertext');
|
||||||
|
}
|
||||||
|
const [iv, tag, ciphertext] = parts.map((part) =>
|
||||||
|
Buffer.from(part, 'base64'),
|
||||||
|
);
|
||||||
|
return gcmOpen(ringKey.key, { iv, tag, ciphertext }, aadBuffer(aad)).toString(
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealVoiceBytes(plaintext: Buffer, aad: string): Buffer {
|
||||||
|
const { current } = loadKeyRing('voice');
|
||||||
|
const sealed = gcmSeal(current.key, plaintext, aadBuffer(aad));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([BYTES_VERSION]),
|
||||||
|
Buffer.from(current.id.slice(1), 'ascii'),
|
||||||
|
sealed.iv,
|
||||||
|
sealed.tag,
|
||||||
|
sealed.ciphertext,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 8 hex characters after the version byte, or null for anything else. */
|
||||||
|
function storedKeyId(stored: Buffer): string | null {
|
||||||
|
const id = stored.subarray(1, 1 + KEY_ID_BYTES).toString('latin1');
|
||||||
|
return KEY_ID_HEX.test(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openVoiceBytes(stored: Buffer, aad: string): Buffer {
|
||||||
|
if (stored.length < BYTES_HEADER) {
|
||||||
|
throw new Error('malformed ciphertext');
|
||||||
|
}
|
||||||
|
if (stored[0] !== BYTES_VERSION) {
|
||||||
|
throw new Error('unsupported ciphertext version');
|
||||||
|
}
|
||||||
|
const keyId = storedKeyId(stored);
|
||||||
|
if (keyId === null) {
|
||||||
|
throw new Error('malformed ciphertext');
|
||||||
|
}
|
||||||
|
const ringKey = findRingKey(loadKeyRing('voice'), `k${keyId}`);
|
||||||
|
if (!ringKey) {
|
||||||
|
throw new Error('unknown key id');
|
||||||
|
}
|
||||||
|
const ivStart = 1 + KEY_ID_BYTES;
|
||||||
|
const tagStart = ivStart + IV_BYTES;
|
||||||
|
return gcmOpen(
|
||||||
|
ringKey.key,
|
||||||
|
{
|
||||||
|
iv: stored.subarray(ivStart, tagStart),
|
||||||
|
tag: stored.subarray(tagStart, BYTES_HEADER),
|
||||||
|
ciphertext: stored.subarray(BYTES_HEADER),
|
||||||
|
},
|
||||||
|
aadBuffer(aad),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** False for anything the rekey job should reseal (previous key, other formats). */
|
||||||
|
export function isVoiceSealedWithCurrentKey(stored: string | Buffer): boolean {
|
||||||
|
const { current } = loadKeyRing('voice');
|
||||||
|
if (typeof stored === 'string') {
|
||||||
|
return stored.startsWith(`${current.id}:`);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
stored.length >= BYTES_HEADER &&
|
||||||
|
stored[0] === BYTES_VERSION &&
|
||||||
|
storedKeyId(stored) === current.id.slice(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// One source of truth: the list lives in ../voice-error-codes.ts (created with
|
||||||
|
// the claims); the core exports it under the name the plan gives it.
|
||||||
|
export { VOICE_ERROR_CODES } from '../voice-error-codes';
|
||||||
|
export type { VoiceErrorCode } from '../voice-error-codes';
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
MAX_QUOTE_LENGTH,
|
||||||
|
normalizeForQuote,
|
||||||
|
normalizeInputText,
|
||||||
|
quoteInText,
|
||||||
|
sanitizeFreeText,
|
||||||
|
} from './voice-text';
|
||||||
|
|
||||||
|
describe('normalizeInputText', () => {
|
||||||
|
it('applies NFKC, keeps newlines, drops other control characters and trims', () => {
|
||||||
|
expect(normalizeInputText(' ① Wabe\u0000\u0007\r\nZweite Zeile\t ')).toBe(
|
||||||
|
'1 Wabe\nZweite Zeile',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeForQuote', () => {
|
||||||
|
it('lowercases, turns non-letters into spaces and collapses them', () => {
|
||||||
|
expect(normalizeForQuote(' Königin, GESEHEN!! 8 Waben… ')).toBe(
|
||||||
|
'königin gesehen 8 waben',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds compatibility characters', () => {
|
||||||
|
expect(normalizeForQuote('ABC 12')).toBe('abc 12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quoteInText', () => {
|
||||||
|
const transcript = 'Also, die Königin habe ich gesehen. Acht Waben besetzt!';
|
||||||
|
|
||||||
|
it('matches the quote across punctuation and case', () => {
|
||||||
|
expect(quoteInText('KÖNIGIN habe ich gesehen', transcript)).toBe(true);
|
||||||
|
expect(quoteInText('acht waben besetzt', transcript)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a quote with a changed number', () => {
|
||||||
|
expect(quoteInText('neun Waben besetzt', transcript)).toBe(false);
|
||||||
|
// "12" must not match inside "112".
|
||||||
|
expect(quoteInText('12 Waben', '112 Waben besetzt')).toBe(false);
|
||||||
|
expect(quoteInText('12 Waben', 'Es sind 12 Waben besetzt')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, punctuation-only and overlong quotes', () => {
|
||||||
|
expect(quoteInText('', transcript)).toBe(false);
|
||||||
|
expect(quoteInText('...', transcript)).toBe(false);
|
||||||
|
const long = 'a '.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
expect(quoteInText(long, long)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeFreeText', () => {
|
||||||
|
it('sanitizeFreeText strips control characters and angle brackets and shortens to 500', () => {
|
||||||
|
expect(sanitizeFreeText('a\u0000b<script>c</script>', 500)).toEqual({
|
||||||
|
text: 'abscriptc/script',
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
const result = sanitizeFreeText('x'.repeat(600), 500);
|
||||||
|
expect(result?.text).toHaveLength(500);
|
||||||
|
expect(result?.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns line breaks into single spaces', () => {
|
||||||
|
expect(sanitizeFreeText('Zeile eins\n\nZeile\tzwei', 500)?.text).toBe(
|
||||||
|
'Zeile eins Zeile zwei',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing is left', () => {
|
||||||
|
expect(sanitizeFreeText(' \u0000<> \n', 500)).toBeNull();
|
||||||
|
expect(sanitizeFreeText('', 500)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cut a character in half', () => {
|
||||||
|
const result = sanitizeFreeText('\u{1F41D}'.repeat(10), 4);
|
||||||
|
expect(result?.text).toBe('\u{1F41D}'.repeat(4));
|
||||||
|
expect(result?.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decimal numbers in quotes', () => {
|
||||||
|
it('keeps a decimal number as one token', () => {
|
||||||
|
expect(quoteInText('5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(false);
|
||||||
|
expect(quoteInText('3,5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(true);
|
||||||
|
expect(quoteInText('3.5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(true);
|
||||||
|
expect(quoteInText('3,5 Waben', 'ungefaehr 3.5 Waben besetzt')).toBe(true);
|
||||||
|
expect(normalizeForQuote('Es sind 8. Waben, 5')).toBe('es sind 8 waben 5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hidden characters and limits', () => {
|
||||||
|
it('maps tabs and lone carriage returns to spaces in normalizeInputText', () => {
|
||||||
|
expect(normalizeInputText('a\tb\rc\r\nd')).toBe('a b c\nd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips format characters and line separators', () => {
|
||||||
|
const hidden = 'a\u200Bb\u202Ec\u2028d\u2029e\uFEFFf';
|
||||||
|
expect(normalizeInputText(hidden)).toBe('abc d ef');
|
||||||
|
expect(sanitizeFreeText(hidden, 500)?.text).toBe('abc d ef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the quote length in code points', () => {
|
||||||
|
const word = 'a'.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
expect(quoteInText(word, word)).toBe(true);
|
||||||
|
expect(quoteInText(word + 'a', word + 'a')).toBe(false);
|
||||||
|
const emoji = '\u{1F41D}'.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
// 120 code points are 240 UTF-16 units: still within the limit.
|
||||||
|
expect(quoteInText(emoji, emoji)).toBe(false); // emoji are not letters or digits
|
||||||
|
const letters = '\u{1D400}'.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
expect(quoteInText(letters, letters)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a cut leaves nothing', () => {
|
||||||
|
expect(sanitizeFreeText('abc', 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** A quote longer than this is not a quote of one remark (spec 6.5). */
|
||||||
|
export const MAX_QUOTE_LENGTH = 120;
|
||||||
|
|
||||||
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
||||||
|
// Format characters (bidi marks, zero-width, BOM) change how text looks, not what it says.
|
||||||
|
const FORMAT_CHARACTERS = /\p{Cf}/gu;
|
||||||
|
const LINE_BREAKS = /[\r\n\t\u2028\u2029]+/g;
|
||||||
|
// A decimal separator between digits is kept as part of the number.
|
||||||
|
const DECIMAL_SEPARATOR = /(\d)[.,](?=\d)/g;
|
||||||
|
const DECIMAL_MARK = '\uE000';
|
||||||
|
const ANGLE_BRACKETS = /[<>]/g;
|
||||||
|
const NOT_LETTER_OR_DIGIT_OR_MARK = /[^\p{L}\p{N}\uE000]+/gu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NFKC; tabs, lone carriage returns and the Unicode line separators become
|
||||||
|
* spaces; other control and format characters are removed; trimmed. Line
|
||||||
|
* feeds stay.
|
||||||
|
*/
|
||||||
|
export function normalizeInputText(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/\r(?!\n)|[\t\u2028\u2029]/g, ' ')
|
||||||
|
.replace(/[^\P{Cc}\n]/gu, '')
|
||||||
|
.replace(FORMAT_CHARACTERS, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form quotes are compared in: NFKC, lower case, only letters and
|
||||||
|
* digits, single spaces. "3,5" and "3.5" stay one token ("3.5"), so a quote
|
||||||
|
* "5 Waben" is not found in "3,5 Waben".
|
||||||
|
*/
|
||||||
|
export function normalizeForQuote(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(DECIMAL_SEPARATOR, `$1${DECIMAL_MARK}`)
|
||||||
|
.replace(NOT_LETTER_OR_DIGIT_OR_MARK, ' ')
|
||||||
|
.replaceAll(DECIMAL_MARK, '.')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the quote stands in the text, whole words only ("12" is not in
|
||||||
|
* "112"). This is the barrier against values the model made up.
|
||||||
|
*/
|
||||||
|
export function quoteInText(quote: string, text: string): boolean {
|
||||||
|
if (Array.from(quote).length > MAX_QUOTE_LENGTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const wanted = normalizeForQuote(quote);
|
||||||
|
if (wanted === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ` ${normalizeForQuote(text)} `.includes(` ${wanted} `);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free text from the model: line breaks become spaces, other control
|
||||||
|
* characters and angle brackets disappear, at most `max` characters
|
||||||
|
* (code points). Null if nothing is left.
|
||||||
|
*/
|
||||||
|
export function sanitizeFreeText(
|
||||||
|
text: string,
|
||||||
|
max: number,
|
||||||
|
): { text: string; truncated: boolean } | null {
|
||||||
|
const cleaned = text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(LINE_BREAKS, ' ')
|
||||||
|
.replace(CONTROL_CHARACTERS, '')
|
||||||
|
.replace(FORMAT_CHARACTERS, '')
|
||||||
|
.replace(ANGLE_BRACKETS, '')
|
||||||
|
.replace(/ {2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (cleaned === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const characters = Array.from(cleaned);
|
||||||
|
if (characters.length <= max) {
|
||||||
|
return { text: cleaned, truncated: false };
|
||||||
|
}
|
||||||
|
const cut = characters.slice(0, max).join('').trimEnd();
|
||||||
|
return cut === '' ? null : { text: cut, truncated: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { INSPECTION_SCALES } from './inspection-scales';
|
||||||
|
import { VOICE_ERROR_CODES } from './voice-errors';
|
||||||
|
import { VOICE_VOCABULARY, whisperInitialPrompt } from './voice-vocabulary';
|
||||||
|
|
||||||
|
const FIXTURES = join(__dirname, '..', '..', '..', 'test', 'fixtures', 'voice');
|
||||||
|
|
||||||
|
function fixture(name: string): unknown {
|
||||||
|
return JSON.parse(readFileSync(join(FIXTURES, name), 'utf8')) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('voice vocabulary', () => {
|
||||||
|
it('VOICE_VOCABULARY equals the shared fixture', () => {
|
||||||
|
expect([...VOICE_VOCABULARY]).toEqual(fixture('vocabulary.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicates or blanks', () => {
|
||||||
|
expect(new Set(VOICE_VOCABULARY).size).toBe(VOICE_VOCABULARY.length);
|
||||||
|
for (const word of VOICE_VOCABULARY) {
|
||||||
|
expect(word.trim()).toBe(word);
|
||||||
|
expect(word.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whisperInitialPrompt stays below 800 characters', () => {
|
||||||
|
const prompt = whisperInitialPrompt();
|
||||||
|
expect(prompt.length).toBeLessThan(800);
|
||||||
|
for (const word of VOICE_VOCABULARY) {
|
||||||
|
expect(prompt).toContain(word);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inspection scales', () => {
|
||||||
|
it('INSPECTION_SCALES equals the shared fixture', () => {
|
||||||
|
expect(JSON.parse(JSON.stringify(INSPECTION_SCALES))).toEqual(
|
||||||
|
fixture('inspection-scales.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the four 1-5 scales of the form, with the stored ends', () => {
|
||||||
|
expect(INSPECTION_SCALES.map((scale) => scale.field)).toEqual([
|
||||||
|
'broodPattern',
|
||||||
|
'swarmMood',
|
||||||
|
'gentleness',
|
||||||
|
'combBehavior',
|
||||||
|
]);
|
||||||
|
const swarm = INSPECTION_SCALES.find(
|
||||||
|
(scale) => scale.field === 'swarmMood',
|
||||||
|
);
|
||||||
|
// Stored 1 is the calm end for swarm mood, unlike the other scales.
|
||||||
|
expect(swarm).toMatchObject({ storedOne: 'ruhig', storedFive: 'stark' });
|
||||||
|
for (const scale of INSPECTION_SCALES) {
|
||||||
|
expect(scale.anchors[scale.storedOne]).toBe(1);
|
||||||
|
expect(scale.anchors[scale.storedFive]).toBe(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('voice error codes', () => {
|
||||||
|
it('lists the fourteen fixed codes of the plan', () => {
|
||||||
|
expect([...VOICE_ERROR_CODES]).toEqual([
|
||||||
|
'UNSUPPORTED_AUDIO',
|
||||||
|
'TOO_LARGE',
|
||||||
|
'TOO_MANY_OPEN',
|
||||||
|
'NO_SPEECH',
|
||||||
|
'TOO_LONG',
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
'AUDIO_EXPIRED',
|
||||||
|
'EXTRACT_DISABLED',
|
||||||
|
'EXTRACT_RETRY',
|
||||||
|
'EXTRACT_FAILED',
|
||||||
|
'DAILY_LIMIT',
|
||||||
|
'EXTRACT_INVALID',
|
||||||
|
'REFUSED',
|
||||||
|
'VOICE_CAPTURE_GONE',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Beekeeping words that speech recognition tends to mishear. One source for
|
||||||
|
* Whisper (initial prompt), the Claude prompt and the browser phrases. The
|
||||||
|
* frontend reads the same list from test/fixtures/voice/vocabulary.json; a
|
||||||
|
* test keeps both equal.
|
||||||
|
*/
|
||||||
|
export const VOICE_VOCABULARY: readonly string[] = [
|
||||||
|
'Königin',
|
||||||
|
'Stifte',
|
||||||
|
'Weiselzellen',
|
||||||
|
'Brutwaben',
|
||||||
|
'Futterkranz',
|
||||||
|
'Honigraum',
|
||||||
|
'Varroa',
|
||||||
|
'Schwarmstimmung',
|
||||||
|
'sanftmütig',
|
||||||
|
'Wabensitz',
|
||||||
|
'Drohnenbrut',
|
||||||
|
'Brutnest',
|
||||||
|
'Brutfläche',
|
||||||
|
'Spielnäpfchen',
|
||||||
|
'Schwarmzellen',
|
||||||
|
'Absperrgitter',
|
||||||
|
'Futterteig',
|
||||||
|
'Ableger',
|
||||||
|
'Jungvolk',
|
||||||
|
'Rähmchen',
|
||||||
|
'Mittelwand',
|
||||||
|
'Flugloch',
|
||||||
|
'Beute',
|
||||||
|
'Zarge',
|
||||||
|
'Ameisensäure',
|
||||||
|
'Oxalsäure',
|
||||||
|
'Windel',
|
||||||
|
'Faulbrut',
|
||||||
|
'Kalkbrut',
|
||||||
|
'Nosema',
|
||||||
|
'Wachsmotten',
|
||||||
|
'Pollen',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Whisper reads about 224 tokens of prompt; 800 characters stay safely below. */
|
||||||
|
const PROMPT_PREFIX = 'Durchsicht am Bienenvolk: ';
|
||||||
|
|
||||||
|
export function whisperInitialPrompt(): string {
|
||||||
|
return `${PROMPT_PREFIX}${VOICE_VOCABULARY.join(', ')}.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { DEFAULT_VOICE_MODEL, VOICE_MODELS, voiceConfig } from './voice-config';
|
||||||
|
|
||||||
|
describe('voiceConfig', () => {
|
||||||
|
it('uses the defaults for an empty environment', () => {
|
||||||
|
expect(voiceConfig({})).toEqual({
|
||||||
|
model: 'claude-sonnet-5-5',
|
||||||
|
dailyExtractions: 60,
|
||||||
|
recordingEnabled: false,
|
||||||
|
whisperModel: 'small',
|
||||||
|
});
|
||||||
|
expect(DEFAULT_VOICE_MODEL).toBe('claude-sonnet-5-5');
|
||||||
|
expect(VOICE_MODELS).toEqual([
|
||||||
|
'claude-sonnet-5-5',
|
||||||
|
'claude-haiku-5-5',
|
||||||
|
'claude-opus-5-5',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads every setting, trimmed', () => {
|
||||||
|
expect(
|
||||||
|
voiceConfig({
|
||||||
|
VOICE_MODEL: ' claude-haiku-5-5 ',
|
||||||
|
VOICE_DAILY_EXTRACTIONS: '120',
|
||||||
|
VOICE_RECORDING_ENABLED: 'true',
|
||||||
|
WHISPER_MODEL: 'medium',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
model: 'claude-haiku-5-5',
|
||||||
|
dailyExtractions: 120,
|
||||||
|
recordingEnabled: true,
|
||||||
|
whisperModel: 'medium',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats blank values as unset', () => {
|
||||||
|
expect(
|
||||||
|
voiceConfig({ VOICE_MODEL: ' ', VOICE_DAILY_EXTRACTIONS: '' }),
|
||||||
|
).toMatchObject({ model: DEFAULT_VOICE_MODEL, dailyExtractions: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('voiceConfig rejects an unknown model id', () => {
|
||||||
|
expect(() => voiceConfig({ VOICE_MODEL: 'gpt-5' })).toThrow(
|
||||||
|
/VOICE_MODEL must be one of claude-sonnet-5-5, claude-haiku-5-5, claude-opus-5-5/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['0', '10001', '-1', '1.5', 'many'])(
|
||||||
|
'rejects %s as VOICE_DAILY_EXTRACTIONS',
|
||||||
|
(value) => {
|
||||||
|
expect(() => voiceConfig({ VOICE_DAILY_EXTRACTIONS: value })).toThrow(
|
||||||
|
'VOICE_DAILY_EXTRACTIONS must be a whole number from 1 to 10000',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects unknown switch and Whisper model values', () => {
|
||||||
|
expect(() => voiceConfig({ VOICE_RECORDING_ENABLED: 'yes' })).toThrow(
|
||||||
|
'VOICE_RECORDING_ENABLED must be "true" or "false"',
|
||||||
|
);
|
||||||
|
expect(() => voiceConfig({ WHISPER_MODEL: 'large' })).toThrow(
|
||||||
|
'WHISPER_MODEL must be one of small, medium',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads VOICE_RECORDING_ENABLED case-insensitively', () => {
|
||||||
|
expect(
|
||||||
|
voiceConfig({ VOICE_RECORDING_ENABLED: 'TRUE' }).recordingEnabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
voiceConfig({ VOICE_RECORDING_ENABLED: ' False ' }).recordingEnabled,
|
||||||
|
).toBe(false);
|
||||||
|
expect(() => voiceConfig({ VOICE_RECORDING_ENABLED: '1' })).toThrow(
|
||||||
|
'must be "true" or "false"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Voice settings from the environment. Model ids live here and nowhere else
|
||||||
|
* (spec 7.1): a change of model is a change of this list and of VOICE_MODEL.
|
||||||
|
*/
|
||||||
|
export const VOICE_MODELS = [
|
||||||
|
'claude-sonnet-5-5',
|
||||||
|
'claude-haiku-5-5',
|
||||||
|
'claude-opus-5-5',
|
||||||
|
] as const;
|
||||||
|
export type VoiceModel = (typeof VOICE_MODELS)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_VOICE_MODEL: VoiceModel = 'claude-sonnet-5-5';
|
||||||
|
|
||||||
|
export const WHISPER_MODELS = ['small', 'medium'] as const;
|
||||||
|
export type WhisperModel = (typeof WHISPER_MODELS)[number];
|
||||||
|
|
||||||
|
const DEFAULT_DAILY_EXTRACTIONS = 60;
|
||||||
|
const MIN_DAILY_EXTRACTIONS = 1;
|
||||||
|
const MAX_DAILY_EXTRACTIONS = 10000;
|
||||||
|
|
||||||
|
export interface VoiceConfig {
|
||||||
|
model: VoiceModel;
|
||||||
|
/** Operation-wide limit of Claude calls per UTC day. */
|
||||||
|
dailyExtractions: number;
|
||||||
|
/** Switch that shows or hides audio recording in the app (ConfigMap). */
|
||||||
|
recordingEnabled: boolean;
|
||||||
|
whisperModel: WhisperModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed(value: string | undefined): string | null {
|
||||||
|
const text = value?.trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends string>(
|
||||||
|
name: string,
|
||||||
|
raw: string | undefined,
|
||||||
|
allowed: readonly T[],
|
||||||
|
fallback: T,
|
||||||
|
): T {
|
||||||
|
const value = trimmed(raw);
|
||||||
|
if (value === null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const match = allowed.find((each) => each === value);
|
||||||
|
if (match === undefined) {
|
||||||
|
throw new Error(`${name} must be one of ${allowed.join(', ')}`);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyExtractions(raw: string | undefined): number {
|
||||||
|
const value = trimmed(raw);
|
||||||
|
if (value === null) {
|
||||||
|
return DEFAULT_DAILY_EXTRACTIONS;
|
||||||
|
}
|
||||||
|
const number = /^\d+$/.test(value) ? Number(value) : NaN;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(number) ||
|
||||||
|
number < MIN_DAILY_EXTRACTIONS ||
|
||||||
|
number > MAX_DAILY_EXTRACTIONS
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`VOICE_DAILY_EXTRACTIONS must be a whole number from ${MIN_DAILY_EXTRACTIONS} to ${MAX_DAILY_EXTRACTIONS}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingEnabled(raw: string | undefined): boolean {
|
||||||
|
const value = trimmed(raw)?.toLowerCase() ?? null;
|
||||||
|
if (value === null || value === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error('VOICE_RECORDING_ENABLED must be "true" or "false"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalid values fail with the variable name (never a secret): startup refuses them. */
|
||||||
|
export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
|
||||||
|
return {
|
||||||
|
model: oneOf(
|
||||||
|
'VOICE_MODEL',
|
||||||
|
env.VOICE_MODEL,
|
||||||
|
VOICE_MODELS,
|
||||||
|
DEFAULT_VOICE_MODEL,
|
||||||
|
),
|
||||||
|
dailyExtractions: dailyExtractions(env.VOICE_DAILY_EXTRACTIONS),
|
||||||
|
recordingEnabled: recordingEnabled(env.VOICE_RECORDING_ENABLED),
|
||||||
|
whisperModel: oneOf(
|
||||||
|
'WHISPER_MODEL',
|
||||||
|
env.WHISPER_MODEL,
|
||||||
|
WHISPER_MODELS,
|
||||||
|
'small',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "broodPattern",
|
||||||
|
"storedOne": "lückenhaft",
|
||||||
|
"storedFive": "geschlossen",
|
||||||
|
"anchors": { "lückenhaft": 1, "mittel": 3, "geschlossen": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "swarmMood",
|
||||||
|
"storedOne": "ruhig",
|
||||||
|
"storedFive": "stark",
|
||||||
|
"anchors": { "ruhig": 1, "mittel": 3, "stark": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "gentleness",
|
||||||
|
"storedOne": "stechlustig",
|
||||||
|
"storedFive": "sehr sanft",
|
||||||
|
"anchors": { "stechlustig": 1, "normal": 3, "sehr sanft": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "combBehavior",
|
||||||
|
"storedOne": "läuft weg",
|
||||||
|
"storedFive": "sitzt ruhig",
|
||||||
|
"anchors": { "läuft weg": 1, "mittel": 3, "sitzt ruhig": 5 }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -314,6 +314,8 @@ describe('rekey job (e2e)', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
webhookSubscription: prisma.webhookSubscription,
|
webhookSubscription: prisma.webhookSubscription,
|
||||||
|
voiceCapture: prisma.voiceCapture,
|
||||||
|
voiceCaptureAudio: prisma.voiceCaptureAudio,
|
||||||
} as unknown as PrismaService;
|
} as unknown as PrismaService;
|
||||||
|
|
||||||
const result = await new RekeyJob(racing).run([]);
|
const result = await new RekeyJob(racing).run([]);
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ stringData:
|
|||||||
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
|
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
||||||
# One encryption key per purpose (TOTP secrets, webhook signing secrets,
|
# One encryption key per purpose (TOTP secrets, webhook signing secrets,
|
||||||
# backup files). Required in production: at least 32 characters each, all
|
# backup files, voice notes). Required in production: at least 32 characters
|
||||||
# three different from each other and from both JWT secrets.
|
# each, all four different from each other and from both JWT secrets.
|
||||||
# BACKUP_ENCRYPTION_KEY (with MFA_/WEBHOOK_ENCRYPTION_KEY for MFA and webhook
|
# BACKUP_ENCRYPTION_KEY (with MFA_/WEBHOOK_ENCRYPTION_KEY for MFA and webhook
|
||||||
# secrets) is needed to restore backups on another deployment.
|
# secrets) is needed to restore backups on another deployment.
|
||||||
# Rotation: old value to <NAME>_PREVIOUS, new <NAME>, run the rekey job,
|
# Rotation: old value to <NAME>_PREVIOUS, new <NAME>, run the rekey job,
|
||||||
@@ -27,9 +27,13 @@ stringData:
|
|||||||
MFA_ENCRYPTION_KEY: "change-me-to-a-third-random-value-at-least-32-characters"
|
MFA_ENCRYPTION_KEY: "change-me-to-a-third-random-value-at-least-32-characters"
|
||||||
WEBHOOK_ENCRYPTION_KEY: "change-me-to-a-fourth-random-value-at-least-32-characters"
|
WEBHOOK_ENCRYPTION_KEY: "change-me-to-a-fourth-random-value-at-least-32-characters"
|
||||||
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
|
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
|
||||||
|
# VOICE_ENCRYPTION_KEY seals audio, transcripts and suggestions of voice notes.
|
||||||
|
# It must exist before the T7 backend image rolls out (every role checks it).
|
||||||
|
VOICE_ENCRYPTION_KEY: "change-me-to-a-fifth-random-value-at-least-32-characters"
|
||||||
# MFA_ENCRYPTION_KEY_PREVIOUS: ""
|
# MFA_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS: ""
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS: ""
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
|
# VOICE_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
||||||
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
|
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
|
||||||
@@ -55,3 +59,18 @@ stringData:
|
|||||||
# type: Opaque
|
# type: Opaque
|
||||||
# stringData:
|
# stringData:
|
||||||
# TELEGRAM_BOT_TOKEN: "<token-from-botfather>"
|
# TELEGRAM_BOT_TOKEN: "<token-from-botfather>"
|
||||||
|
|
||||||
|
# Anthropic-API-Schluessel fuer die Auswertung der Sprachnotizen (optional) -
|
||||||
|
# NICHT in diesem Secret, sondern im eigenen Secret apicore-anthropic, das nur
|
||||||
|
# der Worker einbindet (einzelne secretKeyRef-Variable ANTHROPIC_API_KEY mit
|
||||||
|
# optional: true, kein envFrom, kein Volume; Muster apicore-telegram). API- und
|
||||||
|
# Job-Pods lesen ihn nie. Der Inhaber legt das Secret selbst an; ohne
|
||||||
|
# Schluessel bleibt die Auswertung aus (EXTRACT_DISABLED), alles andere laeuft.
|
||||||
|
#
|
||||||
|
# apiVersion: v1
|
||||||
|
# kind: Secret
|
||||||
|
# metadata:
|
||||||
|
# name: apicore-anthropic
|
||||||
|
# type: Opaque
|
||||||
|
# stringData:
|
||||||
|
# ANTHROPIC_API_KEY: "<key-from-the-anthropic-console>"
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
# bash deploy/k8s/scripts/ensure-secrets.sh --dry-run # nur anzeigen
|
# bash deploy/k8s/scripts/ensure-secrets.sh --dry-run # nur anzeigen
|
||||||
#
|
#
|
||||||
# Werte:
|
# Werte:
|
||||||
# - MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY: zufaellig (48 Byte, base64url). Die
|
# - MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY: zufaellig (48 Byte, base64url). Die
|
||||||
# App verlangt mindestens 32 Zeichen; "dummy" wuerde den Start abbrechen.
|
# App verlangt mindestens 32 Zeichen; "dummy" wuerde den Start abbrechen.
|
||||||
# Diese Schluessel nie wieder aendern ohne *_PREVIOUS (sonst sind MFA-
|
# Diese Schluessel nie wieder aendern ohne *_PREVIOUS (sonst sind MFA-
|
||||||
# Geheimnisse, Webhook-Secrets und Backups nicht mehr lesbar). Sichern!
|
# Geheimnisse, Webhook-Secrets, Backups und offene Sprachnotizen nicht mehr
|
||||||
|
# lesbar). Sichern!
|
||||||
|
# VOICE_ENCRYPTION_KEY muss vor dem Rollout des T7-Backend-Images existieren:
|
||||||
|
# die App verlangt ihn in Produktion in jeder Rolle und startet sonst nicht.
|
||||||
# - OPEN_METEO_API_KEY: leer = freie Open-Meteo-API (ein Fantasiewert wuerde
|
# - OPEN_METEO_API_KEY: leer = freie Open-Meteo-API (ein Fantasiewert wuerde
|
||||||
# die kostenpflichtige API mit falschem Schluessel ansprechen).
|
# die kostenpflichtige API mit falschem Schluessel ansprechen).
|
||||||
# - TELEGRAM_BOT_TOKEN (Secret apicore-telegram, nur Worker): "dummy". Die App
|
# - TELEGRAM_BOT_TOKEN (Secret apicore-telegram, nur Worker): "dummy". Die App
|
||||||
@@ -83,9 +86,14 @@ ensure_keys apicore-backend-secret \
|
|||||||
MFA_ENCRYPTION_KEY random \
|
MFA_ENCRYPTION_KEY random \
|
||||||
WEBHOOK_ENCRYPTION_KEY random \
|
WEBHOOK_ENCRYPTION_KEY random \
|
||||||
BACKUP_ENCRYPTION_KEY random \
|
BACKUP_ENCRYPTION_KEY random \
|
||||||
|
VOICE_ENCRYPTION_KEY random \
|
||||||
OPEN_METEO_API_KEY empty
|
OPEN_METEO_API_KEY empty
|
||||||
|
|
||||||
ensure_keys apicore-telegram \
|
ensure_keys apicore-telegram \
|
||||||
TELEGRAM_BOT_TOKEN dummy
|
TELEGRAM_BOT_TOKEN dummy
|
||||||
|
|
||||||
|
# apicore-anthropic (ANTHROPIC_API_KEY, nur Worker, optional) legt der Inhaber
|
||||||
|
# selbst an; dieses Skript erzeugt es nicht. Ohne Schluessel bleibt die
|
||||||
|
# Auswertung der Sprachnotizen aus (EXTRACT_DISABLED), alles andere laeuft.
|
||||||
|
|
||||||
echo "Fertig. Laufende Pods lesen neue Werte erst nach einem Neustart."
|
echo "Fertig. Laufende Pods lesen neue Werte erst nach einem Neustart."
|
||||||
Reference in new issue
Block a user