feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

@@ -45,6 +45,9 @@ data:
# WEATHER_USER_AGENT_CONTACT: ""
# Telegram-Erinnerungen (optional). Name des Bots ohne @ (5-32 Zeichen:
# Buchstaben, Ziffern, Unterstrich) fuer den Deep Link; API und Worker lesen
# ihn aus dieser ConfigMap. Das Token steht NICHT hier, siehe Kommentar in
# secret.example.yaml (Secret apicore-telegram, nur Worker).
# TELEGRAM_BOT_USERNAME: "<bot-username>"
# ihn aus dieser ConfigMap. Leer = Telegram ist in der App nicht
# eingerichtet (Platzhalter; Runbook: Bot bei BotFather anlegen, ohne
# Gruppen (/setjoingroups) und ohne Webhook, je Umgebung ein eigener Bot,
# dann hier den Namen eintragen). Das Token steht NICHT hier, siehe Kommentar
# in secret.example.yaml (Secret apicore-telegram, nur Worker).
TELEGRAM_BOT_USERNAME: ""
+2 -1
View File
@@ -43,7 +43,8 @@ stringData:
# Telegram-Bot-Token (optional) - NICHT in diesem Secret, sondern im eigenen
# Secret apicore-telegram, das nur der Worker einbindet (Secret mit
# TELEGRAM_BOT_TOKEN, envFrom/secretKeyRef mit optional: true). Der API-Pod
# TELEGRAM_BOT_TOKEN; einzelne secretKeyRef-Variable (optional: true), kein
# envFrom, kein Volume). Der API-Pod
# darf es nicht einbinden. Der Inhaber legt den Bot bei BotFather und das
# Secret selbst an; ohne Token laeuft Telegram nicht, alles andere schon.
#
@@ -79,6 +79,12 @@ spec:
value: "3002"
- name: HOME
value: /tmp
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: apicore-telegram
key: TELEGRAM_BOT_TOKEN
optional: true
envFrom:
- configMapRef:
name: apicore-backend-config
@@ -22,6 +22,7 @@ data:
WEATHER_HISTORY_SOURCE: "open-meteo"
OPEN_METEO_FORECAST_URL: "https://api.open-meteo.com/v1/forecast"
OPEN_METEO_ARCHIVE_URL: "https://archive-api.open-meteo.com/v1/archive"
# Telegram: Name des Bots (ohne @) fuer den Deep Link, sobald der Bot bei
# BotFather angelegt ist. Das Token liegt im Secret apicore-telegram (nur Worker).
# TELEGRAM_BOT_USERNAME: "<bot-username>"
# Telegram: Name des Bots (ohne @) fuer den Deep Link. Leer, solange der Bot
# bei BotFather noch nicht angelegt ist (Runbook); dann den Namen eintragen.
# Das Token liegt im Secret apicore-telegram (nur Worker).
TELEGRAM_BOT_USERNAME: ""
+84
View File
@@ -315,6 +315,90 @@ test('resources match the spec table; HPA api 1–2 at 70 % CPU', () => {
assert.equal(api.doc.spec.replicas, undefined, 'the HPA owns the api replica count');
});
// The Telegram bot token belongs to the worker alone (Spec T4c): one optional
// secretKeyRef on its main container, no envFrom, no volume, no other pod.
const TELEGRAM_SECRET = 'apicore-telegram';
const TELEGRAM_TOKEN_KEY = 'TELEGRAM_BOT_TOKEN';
/** Every place a pod template can pull `secretName` in: env, envFrom, volumes. */
function secretReferences(pod, secretName) {
const refs = [];
const containers = [
...pod.containers.map((c) => ({ c, init: false })),
...pod.initContainers.map((c) => ({ c, init: true })),
];
for (const { c, init } of containers) {
for (const e of c.env ?? []) {
const ref = e.valueFrom?.secretKeyRef;
if (ref?.name === secretName) refs.push({ kind: 'env', container: c.name, init, name: e.name, ref });
}
for (const from of c.envFrom ?? []) {
if (from.secretRef?.name === secretName) refs.push({ kind: 'envFrom', container: c.name, init });
}
}
for (const v of pod.spec.volumes ?? []) {
const projected = (v.projected?.sources ?? []).some((src) => src.secret?.name === secretName);
if (v.secret?.secretName === secretName || projected) refs.push({ kind: 'volume', volume: v.name });
}
return refs;
}
for (const [label, model] of [['target', target], ...stages]) {
test(`${label}: only the worker references the apicore-telegram secret, and only optionally`, () => {
const workers = model.byApp('apicore-worker');
if (label === 'target' || workers.length > 0) {
assert.equal(workers.length, 1, `${label}: expected exactly one apicore-worker pod`);
}
for (const pod of model.pods) {
const refs = secretReferences(pod, TELEGRAM_SECRET);
if (pod.labels.app !== 'apicore-worker') {
assert.deepEqual(refs, [], `${pod.owner} must not use secret ${TELEGRAM_SECRET}`);
continue;
}
assert.equal(refs.length, 1, `${pod.owner}: expected exactly one reference to ${TELEGRAM_SECRET}`);
const [only] = refs;
assert.equal(only.kind, 'env', `${pod.owner}: the secret must come in as a single env variable`);
assert.equal(only.init, false, `${pod.owner}: init containers must not see the bot token`);
assert.equal(only.container, mainContainer(pod).name, `${pod.owner}: only the main container takes the token`);
assert.equal(only.name, TELEGRAM_TOKEN_KEY);
assert.deepEqual(only.ref, { name: TELEGRAM_SECRET, key: TELEGRAM_TOKEN_KEY, optional: true });
const tokenEnvs = [...pod.containers, ...pod.initContainers].flatMap((c) =>
(c.env ?? []).filter((e) => e.name === TELEGRAM_TOKEN_KEY),
);
assert.equal(tokenEnvs.length, 1, `${pod.owner}: expected exactly one ${TELEGRAM_TOKEN_KEY} entry`);
assert.equal(tokenEnvs[0].value, undefined, `${pod.owner}: ${TELEGRAM_TOKEN_KEY} must not be a literal`);
}
// No other pod may carry the token by value or under that variable name.
for (const pod of model.pods.filter((p) => p.labels.app !== 'apicore-worker')) {
for (const c of [...pod.containers, ...pod.initContainers]) {
assert.ok(
!(c.env ?? []).some((e) => e.name === TELEGRAM_TOKEN_KEY),
`${pod.owner}/${c.name} defines ${TELEGRAM_TOKEN_KEY}`,
);
}
}
});
test(`${label}: the bot name sits in the shared ConfigMap, the token never does`, () => {
const configMaps = model.docs.filter((d) => d.kind === 'ConfigMap' && d.metadata.name === 'apicore-backend-config');
assert.equal(configMaps.length, 1, 'expected one apicore-backend-config');
const data = configMaps[0].data ?? {};
assert.ok('TELEGRAM_BOT_USERNAME' in data, 'TELEGRAM_BOT_USERNAME missing in apicore-backend-config');
assert.ok(!(TELEGRAM_TOKEN_KEY in data), `${TELEGRAM_TOKEN_KEY} must not be in a ConfigMap`);
for (const doc of model.docs.filter((d) => d.kind === 'ConfigMap')) {
assert.ok(!(TELEGRAM_TOKEN_KEY in (doc.data ?? {})), `${doc.metadata.name} holds ${TELEGRAM_TOKEN_KEY}`);
}
// Both roles read the name from the one ConfigMap through envFrom.
// (The worker only exists from the stage that rolls out backend-roles.)
for (const pod of [...model.byApp('apicore-backend'), ...model.byApp('apicore-worker')]) {
assert.ok(
(mainContainer(pod).envFrom ?? []).some((f) => f.configMapRef?.name === 'apicore-backend-config'),
`${pod.owner} does not read apicore-backend-config`,
);
}
});
}
test('api and worker run prisma migrate deploy as initContainer; only api seeds', () => {
const api = onePod(target, 'apicore-backend');
const worker = onePod(target, 'apicore-worker');