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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:41:47 +02:00
1 parent 2a4dc868d9
commit 9795f98006
27 files changed
+2212 -31

No files matched your search

@@ -6,6 +6,7 @@ const KEYS = {
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
VOICE_ENCRYPTION_KEY: 'v'.repeat(32),
};
const PROD_ENV = {
...BASE_ENV,
@@ -81,16 +82,17 @@ describe('assertStartupEnvironment', () => {
});
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(() =>
assertStartupEnvironment('worker', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: undefined,
WEBHOOK_ENCRYPTION_KEY: undefined,
BACKUP_ENCRYPTION_KEY: undefined,
VOICE_ENCRYPTION_KEY: undefined,
}),
).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(() =>
assertStartupEnvironment('job', {
@@ -126,7 +128,7 @@ describe('assertStartupEnvironment', () => {
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
}),
).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(() =>
assertStartupEnvironment('api', {
@@ -198,9 +200,11 @@ describe('assertStartupEnvironment', () => {
'MFA_ENCRYPTION_KEY',
'WEBHOOK_ENCRYPTION_KEY',
'BACKUP_ENCRYPTION_KEY',
'VOICE_ENCRYPTION_KEY',
'MFA_ENCRYPTION_KEY_PREVIOUS',
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
'VOICE_ENCRYPTION_KEY_PREVIOUS',
])('production refuses a change-me placeholder in %s', (name) => {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
@@ -360,4 +364,70 @@ describe('assertStartupEnvironment', () => {
).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,
} from '../common/crypto/key-ring';
import { assertTelegramToken } from '../telegram/telegram-config';
import { assertAnthropicKey } from '../voice/anthropic-config';
import { voiceConfig } from '../voice/voice-config';
import type { ApicoreRole } from './role';
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
* 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
* worker holds it).
* per role; the Telegram bot token and the Anthropic key are role-specific
* (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(
role: ApicoreRole,
@@ -144,4 +147,6 @@ export function assertStartupEnvironment(
assertNoPlaceholderSecrets(env);
assertEncryptionKeys(env);
assertTelegramToken(role, env);
voiceConfig(env);
assertAnthropicKey(role, env);
}