48 KiB
phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | quick_id | description | date | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261002-kxc | 01 | execute | 1 | 261002-kxc | Nextcloud-Status: persönliche Benachrichtigung (Glocke je Kachel) bei Störung und Wiederherstellung, per E-Mail und in Tessera | 2026-10-02 |
|
true |
|
|
|
Locked decisions from the request (cited below as L-xx):
- L-01 Every user who can see the module (grant USE or MANAGE, or admin) gets a bell toggle "Benachrichtigen" on each tile, personal per user per cloud. New Prisma table user x instance, tenant RLS like the other tables, cascade on instance/user delete. Toggle endpoints need only module access (USE), not manage. Tile shows the bell state; the list endpoint returns
subscribedper instance for the current user. - L-02 Trigger: transition INTO red (unreachable, maintenance, needsDbUpgrade, EOL passed, invalid response) notifies subscribers once; staying red = no repeat; red back to yellow/green = one "wieder in Ordnung" notification. Last notified state stored on the instance so restarts/multiple polls never duplicate; claim-before-send with an
updateManyguard likereminder-mail.scheduler.ts. - L-03 Flapping guard: "unreachable" only counts as red after 2 consecutive failed checks (counter on the instance); the tile keeps showing the last good state plus a hint until the second failure; other red reasons (maintenance, EOL) count immediately; after a first failure the cloud is re-checked after about 5 minutes.
- L-04 Channels: e-mail via the existing MailService/SMTP config exactly like reminder mails (skip silently + log if SMTP missing, user has no e-mail or is inactive; max 3 attempts) plus an in-app notification while Tessera is open, reusing the reminder mechanism (
reminder-notifier.tsx,reminder-notify.ts; desktop app = Windows notification) with a small "recent alerts" endpoint. - L-05 Mail text German, plain and short: subject "Nextcloud : nicht erreichbar" / "Nextcloud : wieder in Ordnung", body with reason, URL, time and link to the module. Reminder mails are German-only (
MailService.sendReminderEmail), so these mails are German-only too. - L-06 Only users who still have module access at send time (grant re-checked) and only active users are notified.
- L-07 Tests: transition logic as pure function (green->red, red->red, red->green, unreachable once vs twice, maintenance immediate), claim/no-duplicate, subscription endpoints + guard metadata, web bell toggle + notifier; full api + web suites, tsc, biome on touched files.
- L-08 CHANGELOG (Unveröffentlicht, German, user-facing) + docs update.
- L-09 Local migration via db container IP +
prisma migrate deploy; rebuilddocker compose up -d --build api web. - L-10 No word for tenant in user texts. Do not push. SUMMARY documents how to force a red transition locally and whether local SMTP is configured (it is: one
SmtpConfigrow with a host exists in the local db).
Claude's discretion, decided here (cited as D-Kx):
- D-K1 The two-strike guard applies to every failed fetch (
reachable === false, all error kinds incl.not-nextcloud): each comes from one HTTP call and can be transient (a proxy error page during a restart is an "invalid answer"). Maintenance and DB upgrade come from a successful answer, EOL from the date — both immediate. - D-K2 First failure writes ONLY
consecutiveFailures = 1andfirstFailureAt; all status fields andlastCheckedAtstay untouched, so the rating (computed from stored fields) keeps the last good state. A never-checked cloud therefore stays grey "Noch nicht geprüft" plus the hint. The second failure writes the failure fields as today. - D-K3 Quick retry = a second cron job
nextcloud-status-retryevery minute that checks only clouds with exactly one failure whosefirstFailureAtis at least 5 minutes old. It reuses the ONE existingforSystemcall site (loadAllInstancesForSchedulergets an optional filter) — no new system read, no new policy. State lives on the row, so it survives restarts. - D-K4 Transition state on the instance:
alertState('ok' | 'red', default 'ok'),alertReason,alertChangedAt. Grey ("unknown") changes nothing. Existing rows start as 'ok'. - D-K5 Mail delivery runs in the background after a won claim (never blocks "Jetzt prüfen"); per recipient up to 3 attempts, 60 s apart, in-process. A restart between attempts drops the remaining ones — accepted: a late status mail after a restart has little value, and tile plus in-app notification show the state anyway. Skips (no SMTP / no address / inactive / no access) are logged and never retried.
- D-K6 Subject per red reason: unreachable uses the locked wording "nicht erreichbar"; the other red reasons get their own short wording ("keine gültige Antwort", "im Wartungsmodus", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen") so a subject is never factually wrong; recovery uses the locked "wieder in Ordnung".
- D-K7 In-app:
GET modules/nextcloud-status/alertsreturns, for the caller's subscribed clouds, the latest transition of the last 24 h that happened after the caller subscribed. The client dedupes per (cloud, changedAt) with the existing reminder helpers and only polls when the user has module access. - D-K8 Changing a cloud's address resets the status fields and the failure counter but KEEPS
alertState— fixing a broken address therefore sends "wieder in Ordnung" to subscribers. - D-K9 Subscription RLS with user dimension like "Reminder" (
current_user_id() IS NULL OR "userId" = current_user_id()), nosystem_read_policy(never read in system context). - D-K10 List responses (
GET instances,POST instances/check) carrysubscribed; single-cloud responses do not — the page keeps the tile's bell state when it swaps in a single checked tile. Switching a bell on for the first time asks the browser for notification permission once (requestBrowserPermissionOnce).
Purpose: subscribers learn about an outage within minutes instead of noticing it on the next visit, without false alarms from a single hiccup.
Output: one migration, alert rules/mail/service in apps/api/src/nextcloud-status/, bell + hint on the tile, global notifier, docs and changelog.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Facts gathered during planning (no need to re-discover):
rateNextcloud(status, reference, now)computes the rating from the STORED fields;NextcloudStatusService.toViewcalls it at read time.checkInstance(tenantId, id)is the single write path for every check (hourly tick, "Jetzt prüfen", per-tile check, create, URL change).fetchNextcloudStatusreturnsreachable: falsewitherrorKindin timeout | network | tls | http-status | not-nextcloud | too-large | redirect;errorDetailis a short code likeHTTP 502orECONNREFUSED.ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)(exported byModuleRegistryModule) is the single source for access incl. admin short-circuit;ModuleRegistryService.findBySlug('nextcloud-status')gives the module id.MailModuleexportsMailService,SettingsModuleexportsSettingsService.getSmtpConfig(tenantId)(null = not set up).MailServicekeepsappUrlfromTESSERA_APP_URL; reminder mails are German-only, time zone Europe/Berlin.- Controllers get the user via
@CurrentUser() user: AuthUser(seereminders.controller.ts), tenant viarequireTenantId(req). forTenant(prisma, tenantId, userId?)sets the user context; every call must use the assignment formconst tenantPrisma = forTenant(...)(checked byrls-access-inventory.spec.ts),forSystemonlyconst systemPrisma = forSystem(...).FORSYSTEM_ALLOWED_CALL_SITESallows exactly 1 call innextcloud-status.service.ts— keep it at 1. Selects stay scalar (no relation keys) so no new relation pairs appear.docs/mandantentrennung-zugriffsklassifikation.mdkeeps a Bereichszeilenextcloud-status(currently 0/14/1), a Summenzeile (61/264/8), pair counts (93) and the Fundstellentabelle; k67 shows how each task updated them with the Gate-Schleife. Every new (file, model) pair needs a row.- Web:
reminder-notify.tsexportsclaimNotification(key, nowMs),withNotifyLock,showReminderNotification({title, body, tag})(Tauri plugin in the desktop app, Web Notification in the browser),requestBrowserPermissionOnce,CATCH_UP_WINDOW_MS.ReminderNotifieris mounted inapps/web/src/components/layout/app-shell.tsx. Access check pattern:GET /modules/active(seeuse-module-capability.ts). - Local SMTP is configured (one
SmtpConfigrow with host). Containerstessera-ctl-db-1,tessera-ctl-api-1,tessera-ctl-web-1run. Latest migration:20261002150000_nextcloud_status.
Pure rules, TDD first (L-02, L-03, D-K1, D-K2). nextcloud-alert-rules.ts, no Nest, no Prisma, date passed in: FAILURES_FOR_RED = 2, RETRY_DELAY_MS = 5 * 60 * 1000, type AlertState = 'ok' | 'red', decideAlert(prev: AlertState, level: RatingLevel): 'down' | 'up' | null (grey changes nothing), and planStatusWrite(prevFailures: number, result: NextcloudCheckResult, now: Date): { outcome: 'ok' | 'pending' | 'confirmed'; data: Record<string, unknown> } per D-K2 (failure = result.reachable === false, any errorKind, D-K1). German header comment explaining the two-strike rule and why a first failure leaves the stored state alone. Write the spec with every case from <behavior> (incl. the combined cases that run rateNextcloud on the resulting stored fields) BEFORE the implementation, see it fail, then implement.
Mail builder (L-05, D-K6). nextcloud-alert-mail.ts, pure: buildNextcloudAlertMail(input, appUrl): { subject: string; text: string } with input { kind: 'down' | 'up'; customerName; baseUrl; rating: NextcloudRating; errorKind; errorDetail; at: Date }. Subject Nextcloud <Kundenname>: <wording> (down wording per reason per D-K6, up "wieder in Ordnung"), CR/LF collapsed to a space, max 150 characters (same as sendReminderEmail). Plain-text body, short, Sie-form: "Guten Tag,", one sentence (down: the cloud "" has a problem since
Alert service (L-01, L-02, L-04, L-06, D-K4, D-K5). nextcloud-alert.service.ts (@Injectable, deps PrismaService, MailService, SettingsService, ModuleAccessService, ModuleRegistryService). Methods: subscribe(tenantId, userId, instanceId) (instance must exist with { id, tenantId } else NotFoundException 'Cloud nicht gefunden'; upsert on the unique pair with forTenant(prisma, tenantId, userId); returns { subscribed: true }), unsubscribe(...) (deleteMany where tenantId, userId, instanceId; returns { subscribed: false }), subscribedInstanceIds(tenantId, userId): Promise<Set<string>>, evaluateAfterCheck(tenantId, row, rating, now) where row carries id, customerName, baseUrl, errorKind, errorDetail, alertState: compute decideAlert; null -> return { kind: null, delivery: null }; otherwise claim with nextcloudInstance.updateMany({ where: { id, tenantId, alertState: prev }, data: { alertState: next, alertReason: down ? rating.reason : null, alertChangedAt: now } }) — only count === 1 continues (header comment: why the claim stands before sending, same reasoning as ReminderMailScheduler). Then start notifySubscribers WITHOUT awaiting it inside the check path and return { kind, delivery } (the promise, .catch logs) so tests can await it. notifySubscribers: subscriptions of the instance (tenant-bound, no user filter), users where { tenantId, id in, isActive: true } with scalar select email + role, module id via findBySlug('nextcloud-status'), per user getModuleAccessLevels(tenantId, user.id, user.role) must contain the module (L-06), SMTP via getSmtpConfig(tenantId); every skip logs one German line with the reason (Benutzer deaktiviert / keine E-Mail-Adresse / kein Modulzugriff / kein E-Mail-Versand eingerichtet) and is never retried; eligible recipients get sendNextcloudAlertEmail with up to 3 attempts, ALERT_MAIL_RETRY_MS = 60_000 apart via an overridable sleep member (D-K5, comment states that a restart drops pending attempts and why that is accepted). Spec: claim won/lost, red->red no claim, all skip reasons, 1-3 attempts, access revoked, inactive user.
Wire into the existing service and controller. NextcloudStatusService gets NextcloudAlertService injected. checkInstance: load { id, baseUrl, consecutiveFailures }, fetch, planStatusWrite, update with that data selecting PUBLIC_SELECT plus alertState, build the view, then await this.alerts.evaluateAfterCheck(...) (awaits only the claim) and return the view. listForTenant(tenantId, userId) and checkAllForTenant(tenantId, userId) add subscribed: boolean per instance from subscribedInstanceIds (D-K10); NextcloudInstanceView gets an optional subscribed. Update the existing service spec (constructor, two-failure path, subscribed flag). Controller: list and checkAll pass user.id via @CurrentUser(); new POST instances/:id/subscription and DELETE instances/:id/subscription with only the class-level @UseModule — no manage decorator, no role decorator (L-01). Module imports MailModule and SettingsModule and provides NextcloudAlertService. Extend module-manage-handlers.spec.ts so subscribe/unsubscribe are asserted to stay on Benutzen level next to list/logo; controller spec covers the two routes (user id from @CurrentUser, never from body).
RLS bookkeeping. Run pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory; add the Fundstellentabelle rows for the new (file, model) pairs (e.g. nextcloud-alert.service.ts with nextcloudInstance, nextcloudAlertSubscription, user), update the Bereichszeile nextcloud-status, the Summenzeile and the Paarzählung in docs/mandantentrennung-zugriffsklassifikation.md, counted with the Gate-Schleife exactly like the k67 entries (measured, not copied). FORSYSTEM_ALLOWED_CALL_SITES stays unchanged.
Web bell (L-01, D-K10). nextcloud-status-api.ts: optional subscribed?: boolean on NextcloudInstance (missing = false, keeps existing fixtures valid), subscribe(id) (POST ${BASE}/${id}/subscription) and unsubscribe(id) (DELETE) with readErrorMessage. CloudTile: new props subscribed, onToggleSubscription, toggling; a bell icon button shown to EVERY user (outside the canManage block, left of the manager buttons), aria-pressed, aria-label "Benachrichtigen", title from bell.titleOn / bell.titleOff, outlined bell when off, filled bell in accent color when on, disabled while toggling. page.tsx: optimistic toggle, call subscribe/unsubscribe, on error roll back and show bell.error as a small line above the grid; on switching on call requestBrowserPermissionOnce() from @/lib/reminder-notify; handleCheckOne keeps the previous subscribed when swapping in the checked tile. Texts in nextcloudStatus.bell (label, titleOn, titleOff, error) in de.json (real umlauts, Sie-form) and en.json with identical keys. Page test: bell visible for a USE-only user, toggle on/off calls the right function and flips aria-pressed, rollback on error, single check keeps the state.
Biome-lint touched files (pnpm exec biome lint <files> from repo root, biome check --write only on new files). Commit feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung (attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/mail src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" pnpm --filter @tessera/api exec prisma migrate status
Migration applied locally and drift-free; pure rules, mail builder, alert service, status service, controller, mail service and manage-handler specs green; a subscribed user's cloud failing twice produces exactly one claimed transition and one mail per eligible recipient; the bell works for USE-level users in the web test; RLS inventory specs green with updated doc; commit on main, not pushed.
Address change (D-K8). In updateInstance, when the normalized address changed, write the reset of the status fields, consecutiveFailures: 0 and firstFailureAt: null together with the new address (alertState untouched), then call checkInstance as today. Service spec: reset written, alertState not in the data; a cloud with alertState 'red' whose new address answers green triggers evaluateAfterCheck with an 'up' decision (alert service spec: 'up' mail subject "wieder in Ordnung" and "Aktueller Stand" line).
Hint on the tile (L-03, D-K2). Add consecutiveFailures to PUBLIC_SELECT/PublicRow and status.pendingRetry: boolean (consecutiveFailures === 1) to the view; the rating input stays unchanged. Web: optional pendingRetry?: boolean in NextcloudInstanceStatus; CloudTile shows, when true, a small line with a warning-colored dot and the text card.pendingRetry ("Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt") between the pill row and the last-check line, data-testid="pending-retry"; pill, version and last check stay as stored. en.json gets the same key. Page test: hint shown with pendingRetry, not shown without, pill keeps the stored green level.
Re-run the RLS specs and adjust the doc only if the Gate-Schleife counts changed. Biome-lint touched files, commit feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel (attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test "$(grep -v '^\s*//' apps/api/src/nextcloud-status/nextcloud-status.service.ts | grep -c 'forSystem(this.prisma)')" = "1"
Retry job registered and tested; first failure leaves the stored state and shows the hint, second failure (manual, retry or hourly) turns the cloud red; address change resets the check state but keeps the notification state; still exactly one system read in the service; specs and both tsc runs green; commit on main, not pushed.
Global notifier (L-04). nextcloud-status-api.ts: NextcloudAlert type and listAlerts() throwing an error object that carries the HTTP status (pattern ReminderRequestError). New apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx, modeled on ReminderNotifier (renders nothing, translation via refs): on mount and on focus/visibility resume it checks GET /modules/active for slug nextcloud-status (with credentials: 'include', cache: 'no-store'); only with access it loads alerts immediately and every 60 s; for each alert under withNotifyLock it calls claimNotification('nextcloud-alert|' + instanceId + '|' + changedAt, Date.now()) and on first claim showReminderNotification({ title, body: baseUrl, tag }) — reuse of these helpers is deliberate (same Tauri path = Windows notification in the desktop app, same per-client memory; say so in the header comment). Titles from nextcloudStatus.notify in de.json/en.json: up and down.unreachable, down.invalidResponse, down.maintenance, down.needsDbUpgrade, down.eolPassed, each with {name}, German wording identical to the mail subjects (D-K6). 401/403 sets a paused flag until the next focus. Mount <NextcloudAlertNotifier /> in app-shell.tsx right after <ReminderNotifier /> with a short German comment. Notifier test modeled on reminder-notifier.test.tsx: no access -> no alerts call; access -> one notification per alert, none on the next poll, correct titles; 403 pauses.
Docs + changelog (L-08, L-10). CHANGELOG under "## Unveröffentlicht" / "### Neu": one user-facing German bullet — bell on each Nextcloud-Status tile, personal; mail and on-screen notification (desktop app: Windows notification) when the cloud fails and when it is back in order; one message per change; a single failed check does not alert, Tessera re-checks after about five minutes; mails need the e-mail setup. docs/anleitung-anwender.md section Nextcloud-Status: new paragraph "Benachrichtigen:" (who sees the bell, what triggers a message, two failed checks for "nicht erreichbar", immediate for maintenance/DB upgrade/support expired, "wieder in Ordnung", hint text on the tile, browser asks once for permission, mail only with e-mail setup and an address in the profile). docs/anleitung-administration.md section "Nextcloud-Status: Clouds eintragen": bullet on notifications (SMTP from section 6 required, recipients re-checked at send time, at most three attempts, changing the address sends "wieder in Ordnung" if it was red) and extend the "Rhythmus" bullet with the 5-minute re-check. No word for tenant anywhere in these texts.
Final gates (L-07, L-09). Full pnpm --filter @tessera/api test and pnpm --filter @tessera/web test (if an AppShell-rendering test breaks, mock the new notifier the way ReminderNotifier is mocked), both tsc, biome lint on all touched files of the three tasks. Rebuild docker compose up -d --build api web, wait for api healthy, check docker compose logs api for "Nextcloud-Status module seeded in registry", "Nextcloud-Status retry job registered" and the mapped routes /modules/nextcloud-status/instances/:id/subscription and /modules/nextcloud-status/alerts, no migration errors. Commit feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog (attribution line). Do not push.
SUMMARY for the orchestrator's browser check (L-10): list the click path to force both transitions locally — (1) "Cloud hinzufügen" with an unreachable address such as https://127.0.0.1:9 (tile grey "Noch nicht geprüft" + hint), (2) switch the bell on (browser asks once for permission), (3) "Jetzt prüfen" or the tile's check button once more -> red, mail + on-screen notification "nicht erreichbar", (4) edit the address to a reachable public Nextcloud -> "wieder in Ordnung". State that local SMTP is configured (SmtpConfig row present) and name which local account address would receive the mail (look it up, do not change it); note that a USE-only user also sees the bell.
pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}if(!a["nextcloudStatus.notify.up"]||!a["nextcloudStatus.bell.label"]||!a["nextcloudStatus.card.pendingRetry"]){console.error("missing keys");process.exit(1)}' && grep -q "Benachrichtig" CHANGELOG.md && grep -q "Benachrichtigen:" docs/anleitung-anwender.md && grep -q "NextcloudAlertNotifier" apps/web/src/components/layout/app-shell.tsx && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status retry job registered"
Subscribers get an on-screen notification (desktop: Windows notification) once per transition while Tessera is open; users without access never poll; CHANGELOG and both guides describe the feature without a word for tenant; full api + web suites, tsc and biome green; api and web rebuilt and running with the retry job and new routes; SUMMARY contains the local test path and SMTP status; commit on main, not pushed.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| browser -> API (subscription, alerts) | user-controlled instance id; user identity must come from the JWT |
| API -> SMTP / recipient mailbox | Kundenname (manager-entered) flows into subject and body |
| background check -> notification fan-out | concurrent checks, several API instances, restarts |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-kxc-01 | Spoofing / Elevation | POST/DELETE instances/:id/subscription | high | mitigate | userId only from @CurrentUser(), tenantId only from requireTenantId(req); instance must match { id, tenantId } (404 otherwise); forTenant(prisma, tenantId, userId) + RLS user dimension; controller spec asserts no body field is used |
| T-kxc-02 | Information disclosure | GET alerts | medium | mitigate | query limited to the caller's own subscriptions (userId from JWT, user-bound client), scalar selects (name, address, state, reason, time — all already visible on the module page), class-level @UseModule so only users with access get any answer |
| T-kxc-03 | Information disclosure | mail fan-out | high | mitigate | at send time re-check isActive, e-mail present and getModuleAccessLevels contains the module (L-06); spec covers revoked access and deactivated user |
| T-kxc-04 | Repudiation / Tampering (duplicate sends) | evaluateAfterCheck | medium | mitigate | claim-before-send updateMany where alertState = previous; only count === 1 notifies; state on the row survives restarts; spec covers the lost claim |
| T-kxc-05 | Tampering (header injection) | buildNextcloudAlertMail subject | medium | mitigate | CR/LF collapsed, 150-char cap (same as reminder subject); spec with a Kundenname containing a line break |
| T-kxc-06 | Denial of service (mail flood) | flapping cloud | medium | mitigate | two-strike rule, one mail per transition, grey changes nothing, retry job only touches clouds with exactly one failure, concurrency 4, overlap guards |
| T-kxc-07 | Denial of service (blocked request) | "Jetzt prüfen" with slow SMTP | low | mitigate | delivery runs in the background after the claim; check responses never await SMTP |
| T-kxc-08 | Elevation | new handlers | medium | mitigate | no manage and no role decorator on subscription/alerts handlers — asserted in module-manage-handlers.spec.ts; module guard still enforces access |
| T-kxc-SC | Tampering | npm/pip/cargo installs | low | accept | no package installs in this plan; only existing dependencies are used |
| </threat_model> |
<success_criteria>
- A subscribed user receives exactly one mail and one on-screen notification when a cloud fails twice in a row or turns red for maintenance, DB upgrade or expired support, and exactly one "wieder in Ordnung" when it recovers.
- No duplicate notification across concurrent checks, restarts or repeated red checks.
- Single failures do not alert; real outages are reported within about five minutes.
- No user-facing text names a tenant; nothing pushed.
Source coverage audit
| Source | Item | Covered by |
|---|---|---|
| GOAL | Per-user notification on red and recovery | Tasks 1-3 |
| CONTEXT | L-01 bell, table, RLS, cascade, USE-level toggles, subscribed in list |
Task 1 |
| CONTEXT | L-02 transition once, no repeat, recovery, state on row, claim | Task 1 (+ recovery via address change Task 2) |
| CONTEXT | L-03 two-strike guard, tile keeps last good + hint, immediate other reasons, ~5 min retry | Task 1 (rules), Task 2 (retry, hint) |
| CONTEXT | L-04 mail like reminders + in-app/desktop notification | Task 1 (mail), Task 3 (in-app) |
| CONTEXT | L-05 German mail texts, locked subjects, link | Task 1 |
| CONTEXT | L-06 re-check access and active state at send time | Task 1 |
| CONTEXT | L-07 tests, full suites, tsc, biome | Tasks 1-3 |
| CONTEXT | L-08 CHANGELOG + docs | Task 3 |
| CONTEXT | L-09 local migration + rebuild | Task 1 (migration), Task 3 (rebuild) |
| CONTEXT | L-10 no tenant wording, no push, SUMMARY test path + SMTP status | Tasks 1-3, SUMMARY in Task 3 |
| </success_criteria> |