Files
tessera-ctl/.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-PLAN.md
T
schalli 6829c44464 docs(quick-261002-k67): Modul Nextcloud-Status
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:04:01 +02:00

61 KiB
Raw Blame History

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-k67 01 execute 1
261002-k67 Neues Modul Nextcloud-Status: Clouds als Ampel-Kacheln mit Versionsbewertung, stündlicher Prüfung und Dashboard-Kachel 2026-10-02
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
apps/api/src/nextcloud-status/nextcloud-rating.ts
apps/api/src/nextcloud-status/nextcloud-rating.spec.ts
apps/api/src/nextcloud-status/nextcloud-status-fetch.ts
apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts
apps/api/src/nextcloud-status/nextcloud-release.service.ts
apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts
apps/api/src/nextcloud-status/nextcloud-status.service.ts
apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
apps/api/src/nextcloud-status/nextcloud-status.controller.ts
apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
apps/api/src/nextcloud-status/nextcloud-status.seed.ts
apps/api/src/nextcloud-status/nextcloud-status.module.ts
apps/api/src/app.module.ts
docs/mandantentrennung-zugriffsklassifikation.md
apps/web/src/lib/nextcloud-status-api.ts
apps/web/src/components/nextcloud-status/rating-display.ts
apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx
apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
apps/web/src/app/(portal)/modules/module-layouts.test.tsx
apps/web/src/lib/module-loader.ts
apps/web/src/lib/module-identity.ts
apps/web/src/lib/stores/nav-store.ts
apps/web/src/components/modules/module-tile.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/web/src/messages/umlaut-dictionary.ts
apps/api/src/nextcloud-status/nextcloud-logo-rules.ts
apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts
apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
apps/api/src/module-registry/module-manage-handlers.spec.ts
apps/api/src/prisma/rls-access-inventory.spec.ts
apps/web/src/components/nextcloud-status/sort-clouds.ts
apps/web/src/components/nextcloud-status/sort-clouds.test.ts
apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
packages/shared/src/index.ts
apps/api/src/dashboard/widget-module-map.ts
apps/api/src/dashboard/widget-module-map.spec.ts
apps/web/src/components/dashboard/widget-registry.tsx
apps/web/src/components/dashboard/widget-registry.test.tsx
apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
apps/web/src/components/dashboard/widgets/widget-icon.tsx
apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx
apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx
apps/web/src/app/(portal)/page.tsx
apps/web/src/app/(portal)/page.test.tsx
CHANGELOG.md
docs/anleitung-anwender.md
docs/anleitung-administration.md
true
QUICK-261002-k67
tokens raw_tokens tasks confidence
170000 170000 3 low
truths artifacts key_links
A user with grant level Benutzen (USE) on nextcloud-status sees one tile per cloud: logo (or initials), Kundenname, URL link opening in a new tab, installed version, traffic-light color with a short plain-language reason, and the time of the last check; the page also names the newest overall Nextcloud version
The traffic light follows the locked rules: GREEN = latest patch of its cycle and EOL more than 90 days away; YELLOW = patch update available in its cycle or EOL within 90 days; RED = EOL passed (or major older than every listed cycle), unreachable/invalid answer, maintenance mode, or needsDbUpgrade; without reference data the tile is grey with 'Bewertung nicht möglich' (red status conditions still red)
Only administrators and users with Verwalten (MANAGE) can add/edit/delete clouds, upload/remove logos, and trigger 'Jetzt prüfen' or a per-tile check — API returns 403 for USE-level users and the controls are hidden for them
Every cloud is checked automatically once per hour (also on a fresh database after the first start), each check fetches only <url>/status.php with a 10 s timeout, max 3 redirects and a size cap, and only parsed fields reach the browser
Version reference data from endoflife.date is cached for 12 h; an outage keeps the last good data and never breaks the page
The user can sort tiles by Kundenname, Status (red first), Version, or Support-Ende, and the choice survives a reload for the same user
Users with module access can place a 'Nextcloud-Status' dashboard tile showing green/yellow/red counters and the red/yellow clouds with reason; clicking opens the module; users without access never see the tile in the catalog or on the dashboard
path provides contains
apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql NextcloudInstance table with tenant_isolation_policy and system_read_policy NextcloudInstance
path provides exports
apps/api/src/nextcloud-status/nextcloud-rating.ts pure rating function with injected date
rateNextcloud
path provides exports
apps/api/src/nextcloud-status/nextcloud-status-fetch.ts normalizeCloudUrl, parseNextcloudStatus, fetchNextcloudStatus (injectable fetch)
normalizeCloudUrl
parseNextcloudStatus
fetchNextcloudStatus
path provides
apps/api/src/nextcloud-status/nextcloud-release.service.ts endoflife.date cache (12 h, keep last good, backoff) + parseEndOfLife
path provides
apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts hourly check job registered in onApplicationBootstrap
path provides
apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx tile grid, sorting, manager controls
path provides
apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx dashboard overview tile
from to via pattern
apps/api/src/nextcloud-status/nextcloud-status.service.ts rateNextcloud + NextcloudReleaseService.getReference listForTenant maps every row to a rating at read time rateNextcloud(
from to via pattern
apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts NextcloudStatusService.loadAllInstancesForScheduler (forSystem) -> checkInstance (forTenant) onApplicationBootstrap registers the cron job nextcloud-status-poll onApplicationBootstrap
from to via pattern
packages/shared/src/index.ts WIDGET_MODULE_SLUGS DashboardService fail-closed filter + web catalog visibleWidgetTypes 'nextcloud-status': 'nextcloud-status' 'nextcloud-status': 'nextcloud-status'
from to via pattern
apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx useCanManageModule('nextcloud-status') manager-only controls useCanManageModule('nextcloud-status')
New Tessera module "Nextcloud-Status" (slug `nextcloud-status`, category `infrastructure`, next to Proxmox). Managers register customer Nextcloud instances (URL + Kundenname + optional logo); Tessera checks each instance's public `status.php` every hour and on demand, compares the installed version with the endoflife.date reference data and shows a traffic-light tile per cloud plus a dashboard overview tile.

Locked decisions from the request (cited below as L-xx):

  • L-01 Slug nextcloud-status, category infrastructure; follow the Proxmox module end to end (seed, NestJS module, ModuleGuard, Prisma model with RLS + hand-written migration, web route with ModuleAccessGate, sidebar/marketplace/icon registrations, module-layouts test, dashboard widget registration).
  • L-02 Managers add a cloud with URL and Kundenname; optional logo either uploaded (PostgreSQL bytea, magic-byte type check, 1 MiB limit, served via an authenticated route) or an https URL the browser loads directly — the API never fetches the logo URL.
  • L-03 Status: server-side GET <url>/status.php, ~10 s timeout, no credentials, http and https, at most a few redirects, capped response size; last result stored on the record (versionstring, maintenance, reachable, error text, checkedAt). URL policy like Proxmox (manager-entered, internal hosts allowed) with the SSRF consideration documented in a code comment; only parsed JSON fields, never raw bodies, reach the client.
  • L-04 Reference data from https://endoflife.date/api/nextcloud.json, fetched server-side, cached ~12 h, outage-tolerant (keep last good); never fetched → tiles show the version without rating (grey "Bewertung nicht möglich").
  • L-05 Traffic light (locked): GREEN = latest patch of its major cycle AND cycle still supported; YELLOW = update available within its cycle OR cycle EOL within the next 90 days; RED = cycle EOL passed (or major not in the list and older than all listed), unreachable, maintenance on, or needsDbUpgrade. Also show the newest overall Nextcloud version. Rating is a pure function with thorough Vitest tests and an injected date.
  • L-06 Tile: logo, Kundenname, URL (new tab), installed version, status color + short German reason ("Aktuell", "Update auf 34.0.4 verfügbar", "Support endet am 30.06.2027", "Support abgelaufen seit …", "Nicht erreichbar", "Wartungsmodus"), last check time.
  • L-07 Sorting selectable: Kundenname (A–Z), Status (rot zuerst), Version, Support-Ende; remembered per user in localStorage (try/catch).
  • L-08 Polling hourly via scheduler (Proxmox/Tender pattern incl. the onApplicationBootstrap lesson), plus "Jetzt prüfen" (whole list) and per-tile refresh; background checks per tenant in system context like the other background jobs.
  • L-09 Rights (locked): USE sees tiles; MANAGE (@ModuleManage) or admin adds/edits/deletes clouds, uploads logos, triggers checks. Web uses useCanManageModule.
  • L-10 Dashboard widget (locked): counters (grün/gelb/rot) and the red/yellow clouds (name + reason); click opens the module; registered and sized like the Proxmox widget.
  • L-11 UI texts German (formal "Sie") and English; UI texts never name the tenant concept; dark/light via existing tokens.
  • L-12 Tests: api Vitest for rating, status.php parser (valid, maintenance, garbage, timeout), eol cache, service CRUD, controller guard metadata (static routes before :id); web tests for page (tiles, sorting, manager-only controls) and widget; full api + web suites, tsc, biome on touched files.
  • L-13 CHANGELOG (Unveröffentlicht, user-facing German) + user/admin docs in docs/.
  • L-14 Local migration via container IP, rebuild docker compose up -d --build api web; do NOT push; browser check is the orchestrator's job.

Claude's discretion (decided here, apply as written):

  • D-A Logo bytes live in bytea columns on the instance row as L-02 says (note: dashboard images moved to the file area in 260922-hk4; for a handful of logos ≤ 1 MiB the DB is fine and needs no file cleanup). Every list/scheduler query uses an explicit select without the bytes. Accepted types PNG/JPEG/GIF/WebP via the existing detectImageMime — no SVG (script risk). Upload and logo URL are mutually exclusive: uploading clears logoUrl; saving a non-empty logoUrl clears the upload.
  • D-B The rating is computed in the API at read time (GET instances), so page and widget show the same result; the API returns reason codes plus parameters, the web translates them (German + English).
  • D-C One global hourly cron job nextcloud-status-poll (0 * * * *), registered unconditionally in onApplicationBootstrap without reading the database at registration time — a fresh database cannot end up without the job (Tender lesson). Each tick reads (id, tenantId) of all instances in system context (the single forSystem call), then checks each instance tenant-bound with concurrency 4 and an overlap guard.
  • D-D Reference cache in memory (no table): TTL 12 h; stale data is returned immediately and refreshed in the background; only an empty cache is awaited; after a failure no new attempt for 15 min; concurrent refreshes share one request; bootstrap warms it up without blocking.
  • D-E A major newer than every listed cycle (fresh release not yet on endoflife.date) rates GREEN "Aktuell"; a major inside the listed range but missing from it rates grey.
  • D-F An answer that is not a valid Nextcloud status JSON (or installed not true) is RED with its own reason "Keine gültige Nextcloud-Antwort" (variant of "unreachable").
  • D-G Status sort order red, yellow, grey, green (then name); version sort oldest first, unknown last; Support-Ende earliest first, unknown last; ties by name.
  • D-H TLS certificates of the clouds are verified (no opt-out); a certificate error shows as "Nicht erreichbar" with the error code as detail.

Output: migration + model, API module (pure functions, release cache, service, controller, scheduler, seed), module page with tiles/sorting/manager form, dashboard widget, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @./CLAUDE.md

Discovered facts the executor can rely on (verified during planning):

  • Proxmox touchpoints (grep proxmox across apps/ and packages/) are the template: apps/api/src/proxmox/{proxmox.module.ts, proxmox.seed.ts, proxmox.controller.ts, proxmox.service.ts, proxmox-scheduler.service.ts}, apps/api/src/app.module.ts, migration 20260923140000_proxmox_server, web apps/web/src/app/(portal)/modules/proxmox/{layout.tsx,page.tsx}, apps/web/src/lib/{proxmox-api.ts,module-loader.ts,module-identity.ts,stores/nav-store.ts}, apps/web/src/components/modules/module-tile.tsx (ICONS map keyed by ModuleIconId), widget files under apps/web/src/components/dashboard/.
  • PrismaService is injected without importing a Prisma module (global); forTenant/forSystem come from apps/api/src/prisma/prisma-tenant.extension.ts. ModuleRegistryModule must be imported for ModuleRegistryService (seed) and ModuleGuard. ScheduleModule is already global in app.module.ts.
  • @UseModule(slug) (class) and @ModuleManage(slug) (handler) live in apps/api/src/module-registry/module.guard.ts (quick 261002-icv). Never put a role decorator on a manage handler — the global RolesGuard would block managers. GET /modules/active already carries canManage; apps/web/src/lib/use-module-capability.ts exports useCanManageModule.
  • Cron: ProxmoxSchedulerService resolves CronJob via require('cron').CronJob (pnpm strict isolation) and registers with SchedulerRegistry.addCronJob — reuse that workaround verbatim.
  • HTTP: apps/api/src/favorites/icon-discovery.service.ts uses fetch as undiciFetch from undici (dependency 7.28.0) with redirect: 'manual', AbortController timeouts and a capped body reader (readTextCapped) — same approach here, but WITHOUT the private-IP filter (L-03: internal hosts allowed).
  • Magic bytes: detectImageMime(buffer) in apps/api/src/dashboard/dashboard-image-rules.ts (PNG/JPEG/GIF/WebP). Serving pattern for uploaded images: apps/api/src/favorites/favorites.controller.ts getIcon (Content-Type from detected mime, Cache-Control: private, max-age=86400, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox) and FileInterceptor(field, { limits: { fileSize, files: 1 } }). The web loads authenticated images through the same-origin proxy /api-proxy/<api path> with a ?v=<version> cache buster (favorites-widget.tsx).
  • RLS gates: rls-coverage.spec.ts needs the policies in the migration; rls-access-inventory.spec.ts compares every (file, model) Prisma access against the Fundstellentabelle in docs/mandantentrennung-zugriffsklassifikation.md (plus Bereichszeile, Summenzeile, Paarzählung — follow the quick-261002-fm5 and proxmox rows) and allows forSystem( only at the call sites listed in FORSYSTEM_ALLOWED_CALL_SITES. A file with tenant-bound AND one system read on the same model has Stand system-gebunden (precedent proxmox.service.ts/proxmoxServer). Never use include: or relation select: in this module.
  • Status colors: tokens bg-status-ok|warn|down|idle and text-status-*-fg, pill form bg-status-ok/12 text-status-ok-fg (see apps/web/src/components/proxmox/status-styles.ts); class strings must be literal (Tailwind scanning).
  • Module routes in the web: /modules/nextcloud-status (own layout with ModuleAccessGate) AND the sidebar route /modules/infrastructure/nextcloud-status (generic [category]/[moduleSlug] page → module-loader.ts).
  • i18n: widget catalog names live under widgets.<key>.name/description in de.json/en.json; module texts get a new top-level namespace nextcloudStatus. apps/web/src/messages/umlaut-guard.spec.ts rejects ae/oe/ue/ss tokens in de.json that are not in UMLAUT_ALLOWLIST (e.g. "aktuell", "Neueste" may need allowlisting — run the test).
  • Widget tests enumerate all widget types (currently eleven): widget-registry.test.tsx, widget-catalog-modal.test.tsx, apps/api/src/dashboard/widget-module-map.spec.ts; (portal)/page.test.tsx mocks each widget module.
  • endoflife.date sample (2026-10-02): [{"cycle":"35","releaseDate":"2026-09-16","eol":"2027-09-30","latest":"35.0.1",...},{"cycle":"34","eol":"2027-06-30","latest":"34.0.4"},{"cycle":"33","eol":"2027-02-28","latest":"33.0.9"},{"cycle":"32","eol":"2026-09-30","latest":"32.0.15"},...]; eol may also be a boolean. Nextcloud status.php returns installed, maintenance, needsDbUpgrade, version ("31.0.5.1"), versionstring ("31.0.5"), edition, productname, extendedSupport.
  • Migration convention: hand-written SQL with German header comment (model: 20260923140000_proxmox_server); latest existing migration is 20261002140000_module_grant_level. Local DB: no host port — IP via docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1, then DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy.
  • Commits: German subject, conventional prefix, end with Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>. Never push. PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor.

@apps/api/src/proxmox/proxmox.controller.ts @apps/api/src/proxmox/proxmox-scheduler.service.ts @apps/api/src/proxmox/proxmox.seed.ts @apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql @apps/web/src/app/(portal)/modules/proxmox/page.tsx @apps/web/src/components/dashboard/widgets/proxmox-widget.tsx

Task 1: Tracer — a registered cloud is checked, rated and shown as a tile (DB → status.php → endoflife reference → rating → GET instances → module page) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/nextcloud-status/nextcloud-rating.ts, apps/api/src/nextcloud-status/nextcloud-rating.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts, apps/api/src/nextcloud-status/nextcloud-release.service.ts, apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-status.seed.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/rating-display.ts, apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/lib/stores/nav-store.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts - rateNextcloud (reference = 35/2027-09-30/35.0.1, 34/2027-06-30/34.0.4, 33/2027-02-28/33.0.9, 32/2026-09-30/32.0.15, 31/2026-02-28/31.0.14; now = 2026-10-02 unless stated): 35.0.1 → green/current; 35.0.2 (newer than listed latest) → green/current; 34.0.3 → yellow/update-available, updateTo 34.0.4; 32.0.15 → red/eol-passed with eolDate 2026-09-30; 32.0.15 at now 2026-08-01 → yellow/eol-soon; 33.0.9 at 2026-12-15 → yellow/eol-soon eolDate 2027-02-28; boundary: EOL exactly 90 days ahead → yellow, 91 days → green; EOL day itself → yellow, the day after → red; 33.0.5 at 2026-12-15 → yellow, reason eol-soon AND updateTo 33.0.9; major 20 (older than all listed) → red/eol-passed without date; major 36 (newer than all) → green/current; cycle with eol false → supported, green when latest; eol true → red/eol-passed without date; reachable false → red/unreachable even with reference null; errorKind not-nextcloud → red/invalid-response; maintenance true → red/maintenance; needsDbUpgrade true → red/needs-db-upgrade; red priority unreachable > invalid-response > maintenance > needs-db-upgrade > eol-passed; reference null + reachable → unknown/no-reference; reachable but no parseable version → unknown/version-unknown; never checked → unknown/not-checked; newestVersion(reference) = latest of the highest cycle ('35.0.1'). - normalizeCloudUrl: ' https://cloud.kunde.de/ ' → 'https://cloud.kunde.de'; '.../status.php' and '.../index.php' suffix stripped; subpath 'https://host/nextcloud/' → 'https://host/nextcloud'; query/hash dropped; 'http://intern:8080' kept; ftp:, javascript:, URL with user:pass@, empty, longer than 2048 → null. - parseNextcloudStatus: valid JSON → fields; maintenance true kept; needsDbUpgrade missing → false; versionstring missing → first three parts of version; HTML, empty, array, installed false, non-object → null; overlong edition/productname cut to 64 chars. - fetchNextcloudStatus (injected fetch): 200 valid → reachable true + fields; 200 maintenance → reachable true, maintenance true; 200 garbage → reachable false, errorKind not-nextcloud; 503 → errorKind http-status, errorDetail 'HTTP 503'; never-resolving fetch → errorKind timeout after the injected timeout (fake timers or a 20 ms timeout); rejected fetch with cause.code ENOTFOUND → errorKind network, detail 'ENOTFOUND'; cert error code (e.g. CERT_HAS_EXPIRED) → errorKind tls; 301 with relative Location → followed once and succeeds; redirect to ftp: → errorKind redirect; 4 consecutive redirects → errorKind redirect; body over 64 KiB → errorKind too-large; request carries no Authorization/Cookie header and targets exactly '/status.php'. - NextcloudReleaseService (injected fetch + clock): first getReference fetches and parses; second call within 12 h does not fetch; after 12 h returns the stale data immediately and refreshes in the background; failure with existing data keeps it; failure without data → null; no new attempt within 15 min after a failure; garbage/empty array keeps last good; parallel calls share one request; parseEndOfLife skips entries without numeric cycle or valid latest. - NextcloudStatusService (mocked prisma via forTenant, mocked fetch + release): createInstance normalizes the URL (invalid → BadRequestException with German message), creates the row and runs the first check; checkInstance writes reachable/maintenance/needsDbUpgrade/versionString/edition/errorKind/errorDetail/lastCheckedAt; checkInstance for an unknown id → NotFoundException; listForTenant selects no logo bytes, returns { instances (with rating), reference: { newestVersion, fetchedAt } }. - Controller metadata: class has MODULE_SLUG_KEY 'nextcloud-status' + ModuleGuard; `list` has no MODULE_MANAGE_KEY; `create` and `checkOne` have MODULE_MANAGE_KEY true and no ROLES_KEY. - Web page test: with a mocked list (one green, one yellow with updateTo, one red unreachable, one grey) it renders four tiles with Kundenname, version, the translated reasons ('Aktuell', 'Update auf 34.0.4 verfügbar', 'Nicht erreichbar', 'Bewertung nicht möglich'), the URL as link with target _blank and rel noopener noreferrer, and the line with the newest Nextcloud version; an empty list shows the empty state. **Schema + migration (L-01, L-02, L-03, D-A).** In `apps/api/prisma/schema.prisma` add `model NextcloudInstance` after `ProxmoxServerStatus` with a German comment (quick-261002-k67; status lives on the row, L-03; logo bytes per D-A; no relation to Tenant, pattern ProxmoxServer): `id String @id @default(uuid())`, `tenantId String`, `customerName String`, `baseUrl String`, `logoUrl String?`, `logoData Bytes?`, `logoMime String?`, `logoVersion Int @default(0)`, `lastCheckedAt DateTime?`, `reachable Boolean?` (null = never checked), `maintenance Boolean?`, `needsDbUpgrade Boolean?`, `versionString String?`, `edition String?`, `productName String?`, `errorKind String?`, `errorDetail String?`, `createdAt`, `updatedAt @updatedAt`, `@@index([tenantId])`. Hand-write `apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql` in the style of 20260923140000_proxmox_server: German header (purpose; tenant_isolation_policy WITHOUT user dimension because clouds are shared data of the organisation; `system_read_policy ... FOR SELECT USING (is_system_context())` because the hourly job of Task 2 reads id/tenantId of all rows; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), CREATE TABLE with `"logoData" BYTEA`, index, ENABLE + FORCE ROW LEVEL SECURITY, both policies. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally (L-14) with the container-IP command from the context and confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0.

Pure functions (L-03, L-05, D-E, D-F). nextcloud-rating.ts (no Nest, no Prisma): types NextcloudCycle { cycle: number; eol: string | boolean; latest: string }, NextcloudReference { cycles: NextcloudCycle[]; fetchedAt: string }, RatingLevel = 'green'|'yellow'|'red'|'unknown', RatingReason = 'current'|'update-available'|'eol-soon'|'eol-passed'|'unreachable'|'invalid-response'|'maintenance'|'needs-db-upgrade'|'no-reference'|'version-unknown'|'not-checked', NextcloudRating { level; reason; updateTo: string|null; eolDate: string|null; cycle: number|null }. Export rateNextcloud(status, reference, now: Date) and newestVersion(reference). Compare dates as UTC calendar days ('YYYY-MM-DD' of now), EOL-soon window EOL_WARNING_DAYS = 90 inclusive, version compare numerically on three parts parsed with an anchored regex from versionString (fallback: first three parts of version). Implement exactly the cases in <behavior>; German JSDoc explaining each rule and citing L-05. nextcloud-status-fetch.ts: normalizeCloudUrl(raw), parseNextcloudStatus(text) (JSON.parse in try, whitelist fields, version strings limited to digits/dots and 32 chars), and fetchNextcloudStatus(baseUrl, opts?: { fetchImpl?, timeoutMs? }) → { reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail }. Use undiciFetch by default, redirect: 'manual', at most MAX_REDIRECTS = 3 hops (each Location resolved against the current URL, only http/https), one AbortController for the whole request with STATUS_TIMEOUT_MS = 10_000, headers only Accept: application/json and a User-Agent: Tessera-Nextcloud-Status, body read through a capped reader with MAX_STATUS_BYTES = 64 * 1024, discard bodies of redirect/error responses. errorKind values: timeout, network, tls, http-status, not-nextcloud, too-large, redirect; errorDetail is only our own short code ('HTTP 503', the cause.code such as ENOTFOUND/ECONNREFUSED/CERT_HAS_EXPIRED, max 120 chars) — never a response body or exception message. Put a German SSRF comment block above the function (L-03): manager-entered URLs incl. internal hosts are allowed on purpose like Proxmox (T-DHH-02); limits applied (only /status.php, GET, no credentials, 3 redirects, 10 s, 64 KiB, only whitelisted fields leave the server); a person with Verwalten could thereby learn whether an internal address answers — accepted.

Reference cache (L-04, D-D). nextcloud-release.service.ts: @Injectable() NextcloudReleaseService without constructor parameters; test seams are two instance fields fetchImpl (default undiciFetch) and now (default () => Date.now()) that specs overwrite on the instance. Export pure parseEndOfLife(json): NextcloudCycle[] (cycle must be /^\d+$/, latest a dotted version, eol a 'YYYY-MM-DD' string or boolean; invalid entries skipped). getReference(): Promise<NextcloudReference | null> and refresh(): Promise<void> per D-D: source URL constant ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json', CACHE_TTL_MS = 12 h, RETRY_BACKOFF_MS = 15 min, 10 s timeout, 512 KiB cap, empty parse result counts as failure, failures logged once per attempt with Logger.warn.

Service + DTO + controller + seed + module (L-01, L-09). dto/nextcloud-instance.dto.ts: CreateNextcloudInstanceDto { customerName (IsString, IsNotEmpty, MaxLength 120); baseUrl (IsString, IsNotEmpty, MaxLength 2048); logoUrl? (IsOptional, ValidateIf non-empty, IsUrl https only with require_protocol, MaxLength 2048) } and UpdateNextcloudInstanceDto with all three optional (same validators; empty logoUrl = remove). nextcloud-status.service.ts: inject PrismaService and NextcloudReleaseService; a module-level PUBLIC_SELECT constant without logoData; every method uses its own const tenantPrisma = forTenant(this.prisma, tenantId); listForTenant(tenantId) (findMany ordered by customerName, maps to a view { id, customerName, baseUrl, logoUrl, hasUploadedLogo, logoVersion, status: { checkedAt, reachable, maintenance, needsDbUpgrade, versionString, edition, errorKind, errorDetail }, rating } using rateNextcloud(..., reference, new Date()), returns { instances, reference: { newestVersion, fetchedAt } }); createInstance(tenantId, dto) (normalizeCloudUrl → BadRequestException 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; create; then checkInstance); checkInstance(tenantId, id) (findFirst by id+tenantId → NotFoundException; fetchNextcloudStatus; update status columns + lastCheckedAt; return the view). nextcloud-status.controller.ts: @Controller('modules/nextcloud-status'), class @UseModule('nextcloud-status'), requireTenantId as in ProxmoxController; @Get('instances') list; @Post('instances') @ModuleManage('nextcloud-status') create; @Post('instances/:id/check') @ModuleManage('nextcloud-status') checkOne. Header comment: rights per L-09, route order rule (static routes before :id, see Task 2). nextcloud-status.seed.ts like proxmox.seed.ts: slug 'nextcloud-status', name 'Nextcloud-Status', version '1.0.0', category 'infrastructure', description de 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick' / en 'Keep track of versions and availability of your Nextcloud clouds', isSystem true. nextcloud-status.module.ts like ProxmoxModule (imports ModuleRegistryModule, providers NextcloudStatusService + NextcloudReleaseService, OnModuleInit seed with log line 'Nextcloud-Status module seeded in registry'). Register NextcloudStatusModule in app.module.ts right after ProxmoxModule. Specs per <behavior> (controller spec pattern: Reflect.getMetadata on prototype methods, like module-manage-handlers.spec.ts).

RLS inventory doc. Run pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory; add the Bereichszeile nextcloud-status, update the Summenzeile and Paarzählung, and add the Fundstellentabelle row(s) for apps/api/src/nextcloud-status/nextcloud-status.service.ts / nextcloudInstance (Stand gebunden for now; Task 2 turns it into system-gebunden) in docs/mandantentrennung-zugriffsklassifikation.md, counted with the Gate-Schleife exactly as the fm5 rows describe; both specs green.

Web tracer (L-06, L-11). apps/web/src/lib/nextcloud-status-api.ts (pattern proxmox-api.ts, credentials: 'include'): exported types mirroring the API view and listInstances(); plus logoSrc(instance) returning /api-proxy/modules/nextcloud-status/instances/<id>/logo?v=<logoVersion> when hasUploadedLogo, else logoUrl, else null. apps/web/src/components/nextcloud-status/rating-display.ts: literal class map RATING_STYLE per level (green→status-ok, yellow→status-warn, red→status-down, unknown→status-idle; fill + pill + text), ratingReasonText(t, rating, locale) mapping reason → key under nextcloudStatus.reason.* with params (version, date formatted with Intl.DateTimeFormat for the locale in UTC, e.g. 30.06.2027), shared later by the widget. layout.tsx = ModuleAccessGate moduleSlug "nextcloud-status" (copy proxmox/layout.tsx). page.tsx ('use client'): PageHeader with title, a muted line "Neueste Nextcloud-Version: {version}" (or "Versionsdaten derzeit nicht verfügbar"), loading skeleton, empty state, responsive tile grid (grid gap-4 sm:grid-cols-2 xl:grid-cols-3) of CloudTile. CloudTile.tsx: colored left strip + status pill with reason, logo (img with referrerPolicy="no-referrer", alt = Kundenname, fallback initials on null or onError), Kundenname, URL link (target="_blank" rel="noopener noreferrer"), installed version (or "—"), last check as relative time ("vor 12 Min.", "noch nie"), errorDetail in small muted text only when red/unreachable. Use existing tokens (bg-card, text-muted-foreground, dark: variants as in proxmox ServerCard) — no new colors. Registrations: module-loader.ts entry 'nextcloud-status' (dynamic import of the page, ssr false); module-identity.ts new ModuleIconId 'cloud' mapped from 'nextcloud-status'; module-tile.tsx ICONS 'cloud' (lucide cloud path M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z); nav-store.ts MODULE_TITLE_KEYS 'nextcloud-status' → 'nextcloudStatus.title'; module-layouts.test.tsx add ['nextcloud-status', NextcloudStatusLayout] to the it.each. Messages: new top-level nextcloudStatus namespace in de.json (formal Sie, real umlauts) and en.json with identical keys: title, newestVersion, referenceUnavailable, empty, lastCheck/never, version labels, and reason.{current: 'Aktuell', updateAvailable: 'Update auf {version} verfügbar', eolSoon: 'Support endet am {date}', eolPassed: 'Support abgelaufen seit {date}', eolPassedNoDate: 'Support abgelaufen', unreachable: 'Nicht erreichbar', invalidResponse: 'Keine gültige Nextcloud-Antwort', maintenance: 'Wartungsmodus', needsDbUpgrade: 'Datenbank-Aktualisierung ausstehend', noReference: 'Bewertung nicht möglich', versionUnknown: 'Version unbekannt', notChecked: 'Noch nicht geprüft'} plus English equivalents ('Up to date', 'Update to {version} available', 'Support ends on {date}', …). UI texts never name the tenant concept (L-11). Run the umlaut guard and add legitimately correct tokens to UMLAUT_ALLOWLIST only if it fails. Page test per <behavior> (mock @/lib/nextcloud-status-api and next-intl like the proxmox page tests).

Biome-lint the touched files (pnpm exec biome lint <files> from repo root, biome check --write on new files only), commit feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht (attribution line). Do not push. pnpm --filter @tessera/api exec vitest run src/nextcloud-status rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status module-layouts umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit NextcloudInstance migration applied locally without drift; rating, parser, fetch, release cache, service and controller specs green; GET /modules/nextcloud-status/instances returns rated instances plus newest version; the module page renders the tiles with translated reasons; module registered in loader, identity, tile icon, nav titles and layouts test; RLS gates green; commit on main, not pushed.

Task 2: Managers maintain clouds (edit, delete, logo), trigger checks, hourly job runs; everyone can sort the tiles apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/sort-clouds.ts, apps/web/src/components/nextcloud-status/sort-clouds.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts - checkLogoUpload (nextcloud-logo-rules): PNG/JPEG/GIF/WebP bytes ≤ 1 MiB → detected mime; SVG text, PDF, empty, renamed HTML → rejected; 1 MiB + 1 byte → rejected (second net behind multer). - Service: updateInstance changes name/URL (new URL normalized and re-checked; unchanged URL not re-checked); non-empty logoUrl clears logoData/logoMime and bumps logoVersion; empty logoUrl sets null; uploadLogo stores bytes + detected mime, clears logoUrl, bumps logoVersion; removeLogo clears bytes/mime and bumps logoVersion; getLogo returns { data, mime } or NotFoundException when none; deleteInstance removes the row; every write/read of a foreign id → NotFoundException (where id + tenantId); checkAllForTenant checks all instances of the tenant with at most 4 in parallel and returns the refreshed list; loadAllInstancesForScheduler selects only id and tenantId through forSystem. - Scheduler: onApplicationBootstrap registers exactly one cron job 'nextcloud-status-poll' with '0 * * * *' without any database read, starts it, and fires one non-blocking release refresh; a thrown error during bootstrap is logged and does not propagate; tick groups instances by tenant and calls checkInstance(tenantId, id) for each; one failing instance does not stop the others; a tick started while the previous one runs is skipped. - Controller metadata + order: list and logo (GET) have no MODULE_MANAGE_KEY; create, update, remove, checkAll, checkOne, uploadLogo, removeLogo have MODULE_MANAGE_KEY true and no ROLES_KEY; the static handler for POST 'instances/check' is declared before every handler whose path contains ':id' (index check on Object.getOwnPropertyNames of the prototype). - sortClouds: 'name' → A–Z with German collation (Ä next to A, case-insensitive); 'status' → red, yellow, unknown, green, ties by name; 'version' → oldest first, missing version last; 'eol' → earliest eolDate first, missing last; input array not mutated. readSortPreference/writeSortPreference: key 'tessera:nextcloud-status:sort:', unknown stored value → 'name', throwing localStorage → default and no throw. - Page: USE user (useCanManageModule false) sees tiles and the sort selector but no "Cloud hinzufügen", "Jetzt prüfen", per-tile refresh/edit buttons; manager (true) sees all of them; choosing 'Status' reorders tiles red first and writes the preference; a stored preference is applied on load; "Jetzt prüfen" calls checkAll and replaces the list. - CloudForm: required Kundenname and URL; logo choice "Keins / Bild hochladen / Bildadresse"; https-only hint on logo URL; submit calls create (or update) and, when a file is chosen, uploadLogo afterwards; delete asks for confirmation before calling remove; API error message is shown in the form. **API write paths (L-02, L-09, D-A).** `nextcloud-logo-rules.ts`: `NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024`, `checkLogoUpload(buffer)` → mime or null, built on `detectImageMime` from `../dashboard/dashboard-image-rules` (German comment: no SVG because inline script; magic bytes instead of client mimetype, pattern T-PI9-01). Extend `nextcloud-status.service.ts` with `updateInstance`, `deleteInstance`, `uploadLogo(tenantId, id, file)` (BadRequestException 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.'), `getLogo`, `removeLogo`, `checkAllForTenant` (small promise pool of 4, each instance isolated with try/catch + Logger.warn), `listInstanceIdsForTenant`, and `loadAllInstancesForScheduler()` — the single `const systemPrisma = forSystem(this.prisma);` access, `select: { id: true, tenantId: true }`, German comment naming FORSYSTEM_ALLOWED_CALL_SITES and the system_read_policy of migration 20261002150000. All per-row writes stay `forTenant` with `where: { id, tenantId }`.

Controller routes (L-08, L-09, L-12). Final handler order in nextcloud-status.controller.ts: @Get('instances') list; @Post('instances') create; @Post('instances/check') checkAll (static, BEFORE any :id route); @Put('instances/:id') update; @Delete('instances/:id') remove; @Post('instances/:id/check') checkOne; @Get('instances/:id/logo') logo (USE level; sets Content-Type from the stored mime, Cache-Control: private, max-age=86400, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, sends the bytes — pattern favorites getIcon); @Post('instances/:id/logo') with FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }) uploadLogo; @Delete('instances/:id/logo') removeLogo. Every write/check handler gets @ModuleManage('nextcloud-status') and no role decorator. Extend the controller spec with the metadata and declaration-order assertions, and add a NextcloudStatusController block to apps/api/src/module-registry/module-manage-handlers.spec.ts (manage handlers listed via it.each, list/logo stay USE level).

Scheduler (L-08, D-C). nextcloud-status-scheduler.service.ts implementing OnApplicationBootstrap (German header: why not OnModuleInit — Tender bootstrap lesson; why a single global job instead of per-tenant jobs — fixed hourly interval, no per-row setting, no DB read at registration). Reuse the require('cron').CronJob workaround and the SchedulerRegistry calls from ProxmoxSchedulerService; job name nextcloud-status-poll, cron 0 * * * *; running flag as overlap guard; tick = loadAllInstancesForScheduler() → group by tenantId → all instances of the tick run through one pool of at most 4 concurrent checkInstance(tenantId, id) calls (each bound to its own tenantId), every error logged with tenant and id. Bootstrap also calls void this.release.refresh() (caught). Register the scheduler in nextcloud-status.module.ts providers. Spec per <behavior> with mocked SchedulerRegistry (pattern proxmox-scheduler.service.spec.ts).

RLS gates. Add ['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1] to FORSYSTEM_ALLOWED_CALL_SITES in rls-access-inventory.spec.ts, extending its history comment (quick-261002-k67: hourly job reads id/tenantId of all instances, writes per row tenant-bound; new totals). Update docs/mandantentrennung-zugriffsklassifikation.md: Bereichszeile counts (bound/system), Summenzeile, Fundstellentabelle row for nextcloud-status.service.ts/nextcloudInstance now system-gebunden with the explanation (precedent proxmox row); recount with the Gate-Schleife, never copy numbers.

Web (L-02, L-07, L-09, D-G). nextcloud-status-api.ts: add createInstance, updateInstance, deleteInstance, checkAll, checkOne, uploadLogo (FormData field 'logo'), removeLogo; non-ok responses throw an Error carrying the API message (pattern proxmox-api.ts). sort-clouds.ts: SortKey = 'name'|'status'|'version'|'eol', sortClouds(items, key) per D-G (pure, returns a new array, localeCompare(…, 'de', { sensitivity: 'base' })), readSortPreference(userId) / writeSortPreference(userId, key) with key tessera:nextcloud-status:sort:<userId>, everything in try/catch, unknown values fall back to 'name'. Page: sort <select> with the four options (label "Sortieren nach"), applied via useMemo; user id from useAuthStore; const canManage = useCanManageModule('nextcloud-status') === true gates "Cloud hinzufügen", "Jetzt prüfen" (spinner while running, then replace list with the response) and, on each tile, refresh (checkOne, replaces that tile) and edit (opens CloudForm). CloudForm.tsx: dialog/panel (follow the proxmox ServerForm look) for add/edit with Kundenname, URL (placeholder https://cloud.example.com), logo choice radio "Kein Logo / Bild hochladen / Bildadresse (https)", file input accept="image/png,image/jpeg,image/gif,image/webp", client-side size hint 1 MB, preview of the current logo, "Logo entfernen", delete button with confirmation dialog ("Möchten Sie die Cloud „{name}“ wirklich entfernen?"), saving state "Wird geprüft …" (create awaits the first check), API errors shown inline. All new texts in nextcloudStatus.* de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per <behavior>: sort-clouds.test.ts, CloudForm.test.tsx, and new manager/USE/sorting cases in nextcloud-status-page.test.tsx (mock @/lib/use-module-capability).

Biome-lint touched files, commit feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung (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 -z "$(grep -nE '^\s*@Roles(' apps/api/src/nextcloud-status/nextcloud-status.controller.ts)" All write, logo and check routes exist behind ModuleManage (metadata + route-order specs green); the hourly job is registered in onApplicationBootstrap without a DB read and checks every instance tenant-bound; forSystem allowlist and RLS doc updated; the page offers sorting for everyone (remembered per user) and add/edit/delete/logo/check controls only to managers; commit on main, not pushed.

Task 3: Dashboard tile "Nextcloud-Status", docs and changelog, full suites, local rebuild packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md - WIDGET_TYPES ends with 'nextcloud-status' (twelve types, existing order unchanged); WIDGET_MODULE_SLUGS maps proxmox → proxmox and 'nextcloud-status' → 'nextcloud-status'; getModuleSlugForWidgetType('nextcloud-status') = 'nextcloud-status'; all other types stay platform tiles. - Catalog: without module access neither Proxmox nor Nextcloud-Status appears; with access to 'nextcloud-status' only that module tile is added. - Widget: list with 2 green, 1 yellow (updateTo 34.0.4), 1 red (maintenance) → counters 2/1/1, list shows the red cloud first, then the yellow one, each with name + translated reason; all green → a calm "Alle Clouds sind aktuell" line and no list; empty list → hint "Noch keine Cloud eingetragen"; view mode rows link to /modules/nextcloud-status; edit mode rows are not links; the widget never calls a check endpoint (only listInstances); unknown-rated clouds counted separately only when > 0. **Widget registration (L-10).** `packages/shared/src/index.ts`: append `'nextcloud-status'` to WIDGET_TYPES and add `'nextcloud-status': 'nextcloud-status'` to WIDGET_MODULE_SLUGS (extend the comment: second module tile, quick-261002-k67; slug equals the seed and the class-level UseModule). Update the comment in `apps/api/src/dashboard/widget-module-map.ts` (no longer exactly one entry) and `widget-module-map.spec.ts` (module tiles = proxmox and nextcloud-status). `widget-registry.tsx`: WIDGET_CONSTRAINTS `'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }` with a comment, an inline `NextcloudStatusIcon` (cloud path from Task 1), and the registry entry (nameKey 'nextcloudStatus.name', descriptionKey 'nextcloudStatus.description', moduleSlug from WIDGET_MODULE_SLUGS, PlaceholderWidget). `widget-icon.tsx`: 'nextcloud-status' cloud glyph. `widget-wrapper.tsx`: add 'nextcloud-status' to FRAME_HEADER_TYPES (frame header = icon + name, hide-title toggle via TITLE_TYPES) and update its comment. `(portal)/page.tsx`: import and `registerWidget('nextcloud-status', NextcloudStatusWidget)`; add the matching vi.mock in `page.test.tsx`. Update the enumerations in `widget-registry.test.tsx` (twelve types, constraints table, moduleSlug assertion for both module tiles, visibleWidgetTypes cases) and `widget-catalog-modal.test.tsx` (counts and module-access cases).

Widget component (L-10, L-11). nextcloud-status-widget.tsx ('use client', WidgetProps): reads only listInstances() from @/lib/nextcloud-status-api (German comment: never triggers checks — pattern T-I8V-02), refresh every 5 min with visibility pause and immediate reload on return (pattern proxmox-widget.tsx); three counter chips using RATING_STYLE from rating-display.ts (grün/gelb/rot labels plus "ohne Bewertung" only when > 0); below, red then yellow clouds sorted by name (sortClouds(items, 'status') from Task 2) with status dot, Kundenname and ratingReasonText; container-query compact mode like the proxmox widget (narrow: only counters); in view mode each row and the counter area are Next Links to /modules/nextcloud-status, in edit mode plain rows without tabstop (links are in the drag-cancel selector, see proxmox comment); loading, error ("Status konnte nicht geladen werden") and empty states. Messages: widgets.nextcloudStatus.{name: 'Nextcloud-Status', description: 'Ampelübersicht Ihrer Nextcloud-Clouds'} and nextcloudStatus.widget.* texts in de + en. Widget test per <behavior>.

Docs + changelog (L-13). CHANGELOG.md under "## Unveröffentlicht" → "### Neu": one user-facing German bullet (module in Infrastruktur, Aktivierung im Marktplatz + Freigabe, tiles with Ampel and what the colors mean, newest version shown, hourly check plus "Jetzt prüfen", sorting remembered, logo upload or address, who may maintain clouds = Verwalten, dashboard tile). docs/anleitung-anwender.md: new section "### Nextcloud-Status" after "### Proxmox" (what the tile shows, the exact traffic-light rules in plain words incl. 3-month window, grey state, sorting, the dashboard tile, what Verwalten users can do). docs/anleitung-administration.md: new subsection after "### Proxmox-Server anbinden…" — "### Nextcloud-Status: Clouds eintragen" (who may maintain, Tessera reads only the public status.php, no login data needed, the API container needs outbound access to the clouds and to endoflife.date, internal addresses allowed, certificate must be valid, logo rules 1 MB PNG/JPEG/GIF/WebP or https address loaded by the browser), plus its entry in the table of contents if the section list there names subsections. No tenant wording in user-facing docs/changelog.

Final gates (L-12, L-14). Run full pnpm --filter @tessera/api test and pnpm --filter @tessera/web test, both tsc, biome lint on all touched files of the three tasks; fix any enumeration test that still expects eleven widget types. Rebuild docker compose up -d --build api web, wait for healthy, check docker compose logs api for 'Nextcloud-Status module seeded in registry', the mapped routes /modules/nextcloud-status/instances, the scheduler log line, and no migration errors; confirm with psql in the db container that the Module row nextcloud-status has category infrastructure. Commit feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog (attribution line). Do not push; leave the browser check to the orchestrator and list in the SUMMARY what to click (add a public cloud such as a real customer URL, manager vs USE view, sorting, widget). 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)}' && grep -q "Nextcloud" CHANGELOG.md && grep -q "### Nextcloud-Status" docs/anleitung-anwender.md && 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 module seeded in registry" Dashboard tile registered (shared types, module map, registry, icon, wrapper, page) and visible only with module access; widget shows counters and red/yellow clouds and links to the module; CHANGELOG, user and admin docs updated; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.

<threat_model>

Trust Boundaries

Boundary Description
browser → API (/modules/nextcloud-status/*) untrusted caller; tenant/user/role only from the validated JWT
API → customer cloud (<url>/status.php) outbound request to a manager-entered address, untrusted response
API → endoflife.date outbound request to a public service, untrusted response
browser → logo URL host the viewer's browser loads an external image
manager upload → DB → other users' browsers uploaded bytes are served back to every module user

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-k67-01 Information Disclosure (SSRF) fetchNextcloudStatus medium accept Internal targets allowed by design like Proxmox (L-03); limits: only GET <url>/status.php, no credentials, http/https, 3 redirects, 10 s, 64 KiB, whitelisted parsed fields only, errorDetail only own codes; write access requires Verwalten; documented in the code comment and admin docs
T-k67-02 Tampering / Spoofing logo upload + GET logo high mitigate multer fileSize 1 MiB + service re-check; type from magic bytes (PNG/JPEG/GIF/WebP, no SVG); served with detected mime, nosniff, default-src 'none'; sandbox, private cache; logo-rules spec
T-k67-03 Elevation of Privilege controller write/check/logo routes high mitigate @ModuleManage('nextcloud-status') on every write/check handler, no role decorator; controller spec + module-manage-handlers.spec metadata; verify gate greps for role decorators
T-k67-04 Information Disclosure cross-tenant rows high mitigate forTenant on every request path, where: { id, tenantId } → 404, tenant_isolation_policy; system read limited to select { id, tenantId } in one allowlisted call site (rls-access-inventory)
T-k67-05 Information Disclosure external logo URL low mitigate https only (DTO), referrerPolicy="no-referrer" on the img, API never fetches it (L-02)
T-k67-06 Denial of Service hourly job / reference fetch medium mitigate concurrency 4, overlap guard, per-instance try/catch, 10 s timeouts, size caps; reference cache 12 h with 15 min failure backoff and shared in-flight request
T-k67-07 Information Disclosure list response medium mitigate PUBLIC_SELECT without logo bytes; raw bodies never stored or returned; service spec asserts the select
T-k67-08 Elevation of Privilege route shadowing (instances/check vs :id) medium mitigate static route declared before :id routes; declaration-order assertion in the controller spec
T-k67-09 Tampering stored URL low mitigate normalizeCloudUrl rejects non-http(s), embedded credentials, overlong input; link rendered with rel="noopener noreferrer"
T-k67-SC Tampering npm/pip/cargo installs low accept No new packages (undici, class-validator, multer, cron already present); nothing to verify
</threat_model>
- Task verify commands all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, module-layouts, widget enumerations). - `prisma migrate status` up to date locally; drift check exit 0. - `docker compose ps` shows api and web running after the rebuild; api log shows the seed line and the routes. - Source coverage audit:
Source item Covered by
GOAL: Nextcloud-Status module with traffic-light tiles Tasks 1-3
L-01 slug/category, Proxmox touchpoints end to end Task 1 (API, migration, web route, registrations, layouts test), Task 3 (widget registration)
L-02 URL + Kundenname, logo upload (bytea, magic bytes, 1 MiB, auth route) or https URL Task 1 (model, create), Task 2 (logo routes, form)
L-03 status.php fetch limits, stored result, SSRF comment, no raw bodies Task 1
L-04 endoflife.date cache 12 h, outage-tolerant, grey without data Task 1
L-05 traffic-light rules + newest version, pure function with date injection Task 1
L-06 tile content and reason texts Task 1
L-07 four sort options remembered per user Task 2
L-08 hourly job (onApplicationBootstrap), "Jetzt prüfen", per-tile refresh, system context Task 1 (checkOne), Task 2 (checkAll, scheduler)
L-09 rights USE vs MANAGE/admin, useCanManageModule Task 1 (create/checkOne), Task 2 (all write routes, UI gating)
L-10 dashboard widget counters + red/yellow list Task 3
L-11 German Sie + English, no tenant wording, tokens Tasks 1-3 + node key check
L-12 tests, full suites, tsc, biome Tasks 1-3
L-13 CHANGELOG + docs Task 3
L-14 local migration, rebuild, no push Task 1 (migrate), Task 3 (rebuild)

<success_criteria>

  • NextcloudInstance exists with RLS policies; migration applied locally without drift.
  • Rating, parser, fetch, release cache, service, controller, scheduler, logo rules, sort, page, form and widget tests pass; full api + web suites, both tsc runs and biome on touched files are green.
  • USE users see rated tiles and can sort; managers and admins can additionally add/edit/delete clouds, manage logos and trigger checks; the API enforces this with ModuleManage.
  • The hourly job is registered on every start, independent of existing rows.
  • The dashboard tile appears in the catalog only with module access and links to the module.
  • CHANGELOG and both guides describe the module; three commits on main, nothing pushed. </success_criteria>
Create `.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md` when done (not committed by the executor).