Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
61 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-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 |
|
true |
|
|
|
Locked decisions from the request (cited below as L-xx):
- L-01 Slug
nextcloud-status, categoryinfrastructure; 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 usesuseCanManageModule. - 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
selectwithout the bytes. Accepted types PNG/JPEG/GIF/WebP via the existingdetectImageMime— no SVG (script risk). Upload and logo URL are mutually exclusive: uploading clearslogoUrl; saving a non-emptylogoUrlclears 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 inonApplicationBootstrapwithout 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 singleforSystemcall), 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
installednot 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>
Discovered facts the executor can rely on (verified during planning):
- Proxmox touchpoints (grep
proxmoxacross 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, migration20260923140000_proxmox_server, webapps/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 byModuleIconId), widget files underapps/web/src/components/dashboard/. PrismaServiceis injected without importing a Prisma module (global);forTenant/forSystemcome fromapps/api/src/prisma/prisma-tenant.extension.ts.ModuleRegistryModulemust be imported forModuleRegistryService(seed) andModuleGuard.ScheduleModuleis already global in app.module.ts.@UseModule(slug)(class) and@ModuleManage(slug)(handler) live inapps/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/activealready carriescanManage;apps/web/src/lib/use-module-capability.tsexportsuseCanManageModule.- Cron:
ProxmoxSchedulerServiceresolvesCronJobviarequire('cron').CronJob(pnpm strict isolation) and registers withSchedulerRegistry.addCronJob— reuse that workaround verbatim. - HTTP:
apps/api/src/favorites/icon-discovery.service.tsusesfetch as undiciFetchfromundici(dependency 7.28.0) withredirect: '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)inapps/api/src/dashboard/dashboard-image-rules.ts(PNG/JPEG/GIF/WebP). Serving pattern for uploaded images:apps/api/src/favorites/favorites.controller.tsgetIcon(Content-Type from detected mime,Cache-Control: private, max-age=86400,X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandbox) andFileInterceptor(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.tsneeds the policies in the migration;rls-access-inventory.spec.tscompares every (file, model) Prisma access against the Fundstellentabelle indocs/mandantentrennung-zugriffsklassifikation.md(plus Bereichszeile, Summenzeile, Paarzählung — follow the quick-261002-fm5 and proxmox rows) and allowsforSystem(only at the call sites listed inFORSYSTEM_ALLOWED_CALL_SITES. A file with tenant-bound AND one system read on the same model has Standsystem-gebunden(precedentproxmox.service.ts/proxmoxServer). Never useinclude:or relationselect:in this module. - Status colors: tokens
bg-status-ok|warn|down|idleandtext-status-*-fg, pill formbg-status-ok/12 text-status-ok-fg(seeapps/web/src/components/proxmox/status-styles.ts); class strings must be literal (Tailwind scanning). - Module routes in the web:
/modules/nextcloud-status(own layout withModuleAccessGate) AND the sidebar route/modules/infrastructure/nextcloud-status(generic[category]/[moduleSlug]page →module-loader.ts). - i18n: widget catalog names live under
widgets.<key>.name/descriptionin de.json/en.json; module texts get a new top-level namespacenextcloudStatus.apps/web/src/messages/umlaut-guard.spec.tsrejects ae/oe/ue/ss tokens in de.json that are not inUMLAUT_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.tsxmocks 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"},...];eolmay also be a boolean. Nextcloudstatus.phpreturnsinstalled, 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 is20261002140000_module_grant_level. Local DB: no host port — IP viadocker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1, thenDATABASE_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.
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.
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> |
| 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>
NextcloudInstanceexists 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>