150 KiB
phase, plan, type, wave, depends_on, quick_id, description, date, revision, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | quick_id | description | date | revision | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261008-mzu | 01 | execute | 1 | 261008-mzu | Neues Modul Nextcloud-Dateien (Etappe 1): eine Nextcloud je Organisation, eigenes Konto je Benutzer (App-Passwort oder Login Flow v2 fuer Zwei-Faktor), Dateien durchblaettern, hoch- und herunterladen, anlegen, umbenennen, verschieben, loeschen | 2026-10-08 | 2 |
|
true |
|
|
|
Six tasks, executed strictly in order by one executor each (revision 2 after plan check). Every task is self-contained: it names its files, rebuilds what it needs, and proves its slice with specs plus an e2e script against the local test Nextcloud.
Locked decisions from the request (cited below as L-xx — NON-NEGOTIABLE):
- L-01 Exactly ONE Nextcloud per organisation; an administrator or a user with "Verwalten" sets its address in the module settings. The address may be internal. SSRF containment per research: only this base URL, relative segment-encoded paths, no redirects, never call URLs from Nextcloud answers, an address change marks all connections expired.
- L-02 Every Tessera user connects their own account: user name + password in the Tessera form →
getapppassword→ only the app password is stored, AES-encrypted via CryptoService; the real password is discarded immediately. - L-03 Two-factor login is used at the company and MUST work: after a 401 offer Login Flow v2; the link is opened by a real user click (no script-opened window after an async call); Tessera polls
{Basis}/index.php/login/v2/pollserver-side and neverpoll.endpointfrom the answer. The desktop app openstarget=_blanklinks through its document-level helper (memory: Web-Helfer lauscht auf document). - L-04 Brute force: own failed-attempt limit per user in Tessera; never repeat on 429; understandable message; admin documentation of the Nextcloud whitelist for the Tessera server IP.
- L-05 Abmelden: revoke the app password at Nextcloud + delete it locally.
- L-06 Etappe-1 functions: folder browsing (breadcrumb), list/grid switch (remembered per user), previews through a Tessera proxy, upload via drag & drop and file chooser incl. large files (browser chunks 8 MiB → API → Nextcloud Chunked Upload v2; respect the 10 MiB Next proxy limit), download (streaming, folders as ZIP), create folder, rename, move (target folder picker), delete (trash, with confirmation), multi-selection, storage display (quota; -3 = unlimited), overwrite protection (Overwrite: F / If-None-Match) with an understandable conflict message.
- L-07 Rights: using the module = "Benutzen" (everyone only their own account); setting the Nextcloud address = administrator or "Verwalten".
- L-08 No dashboard widget in Etappe 1 (
WIDGET_MODULE_SLUGSunchanged). - L-09 Design "Mosaik-Ablage" (user: "streng dich beim Design an, kein 0815", but within Mosaik — existing tokens, font, PageHeader; no new font, no new primary colour): type tiles per file family (rounded square in a muted family colour, short code like "PDF"/"XLS", families Text/Dokument, Tabelle, Präsentation, PDF, Bild, Audio/Video, Archiv, Code, Sonstiges; colours from tokens or derived harmonically in OKLCH, light AND dark checked); images show real previews; folders = tile in a muted accent-yellow. ONE bold element: drag & drop + transfer — dragging files over the window tints the folder area with a subtle diagonal accent stripe pattern and shows the target folder name large ("In „Projekte“ ablegen"); folder rows/tiles are drop targets themselves; running transfers appear in a bottom-docked, collapsible "Übertragungsleiste" with an accent progress bar per file, cancel button and plain-language error state. Everything else calm and disciplined. Dense list like a real file view (no card per file): type tile, name, size right-aligned tabular-nums, modified relative ("vor 3 Std.") with absolute tooltip, subtle row hover, context actions via row menu (⋯) and right click. Grid: preview surfaces with the name below. Selection bar replaces the toolbar ("3 ausgewählt" + actions); motion only in response to user action; prefers-reduced-motion respected. Keyboard: Enter open, Backspace/Alt+↑ up, Entf delete, F2 rename, Strg+A all; visible focus. Connect screen not a stock form: name/logo of the Nextcloud (theming via status.php/capabilities, else host), short everyday explanation (password is not stored), form; for 2FA a clear switch to "Im Browser anmelden" with waiting state and Abbrechen. Empty folders/errors give instructions ("Dateien hierher ziehen oder hochladen"). No ALL-CAPS labels (file-type short codes are the requested exception), no arrow buttons, no middle-dot meta strings, no decorative numbering. Texts formal Sie, German, no tenant/licence words. Mobile: list, actions via row menu, transfer bar at the bottom.
- L-10 Browser check in dark AND light mode with screenshots against a real local Nextcloud; real E2E of both login paths (Login Flow v2 incl. "Zugriff gewähren" via Playwright), upload > 10 MiB (chunks), download, rename/move/delete. Test container name
tessera-nc-test. - L-11 Project rules: static NestJS routes before
:idroutes; the API TS lib lacksObject.hasOwn; local DB only via the db container IP; rebuild withdocker compose up -d --build api web; never read .env files; de/en keys identical; umlaut guard (placeholder names like{query}trip it — use e.g.{term}); local admin admin/admin123; CHANGELOG under "## Unveröffentlicht" → "### Neu"; extend the Anwender- and Administrationsanleitung.
Claude's discretion (decided here, apply as written):
- D-A Identity: slug
nextcloud-files, registry name "Dateien" (clear, short, as suggested; the Nextcloud name appears on the connect screen and in the account bar), version '1.0.0', categoryinfrastructure(next to Nextcloud-Status; admins can move it), description de "Dateien Ihrer Nextcloud ansehen, hochladen, herunterladen und ordnen" / en "View, upload, download and organise the files in your Nextcloud", isSystem true. NewModuleIconIdfolder(lucide folder pathM20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z) so it differs from Nextcloud-Status' cloud. Messages namespacenextcloudFiles, title "Dateien" / "Files". - D-B Data model, migration
20261008180000_nextcloud_files(both tables in Task 1): enumsNextcloudFilesAccountStatus { ACTIVE EXPIRED },NextcloudFilesConnectMethod { PASSWORD LOGIN_FLOW };NextcloudFilesConfig(id uuid,tenantId String @unique,baseUrl String, createdAt, updatedAt,@@index([tenantId])) withtenant_isolation_policyon tenant only;NextcloudFilesAccount(id uuid, tenantId,userId→UseronDelete: Cascadewith back-relationnextcloudFilesAccountson User,baseUrl String= the address the app password was issued for,ncUserId String,ncDisplayName String?,encryptedAppPassword String,status NextcloudFilesAccountStatus @default(ACTIVE),connectedVia NextcloudFilesConnectMethod, createdAt, updatedAt,@@unique([tenantId, userId]),@@index([tenantId])) with the Reminder-form policy (tenant ANDcurrent_user_id() IS NULL OR "userId" = current_user_id()), NOsystem_read_policy(no background reader). An account counts as expired whenstatus = EXPIREDORaccount.baseUrl !== config.baseUrl. - D-C Transport (
nextcloud-http.ts):undici.request(not fetch: Node streams as body, no redirect following, body as Readable), injected asNextcloudTransport(Nest tokenNEXTCLOUD_TRANSPORT, default = undici wrapper) so every spec runs on a fake. Every URL =${baseUrl}${fixedPrefix}${encodeSegments(segments)}— fixed prefixes only/status.php,/ocs/v2.php/,/index.php/login/v2,/index.php/login/v2/poll,/index.php/core/preview,/remote.php/dav/files/{uid}/,/remote.php/dav/uploads/{uid}/,/index.php/apps/theming/image/logo,/core/img/logo/logo.svg. Segments validated byvalidateSegmentand encoded one by one withencodeURIComponent;{uid}encoded the same way. Any 3xx = failure kindredirect. No cookie header ever sent,set-cookienever forwarded. User-AgentTessera (Nextcloud-Dateien)on every call (Nextcloud shows it as the device name). OCS calls addOCS-APIRequest: trueandAccept: application/json. Timeouts: headers 15 s / body 15 s for OCS, PROPFIND and small calls; chunk PUT headers 120 s, body idle 30 s; assembly MOVE headers 30 min; downloads headers 30 s, body idle 60 s. Text reads capped: OCS 1 MiB, PROPFIND 32 MiB, status/capabilities 256 KiB. TLS certificates are always verified (no switch to turn verification off; internal CA viaNODE_EXTRA_CA_CERTS, documented). The sharedisPublicHttpUrlguard is intentionally NOT used (internal clouds are allowed, L-01) — say so in the header comment. - D-D Error contract — the API never answers 401 or 403 for a Nextcloud-side failure (the web treats those as a Tessera session/permission problem); every error body is
{ code, message }with a German message, the web maps codes to i18n texts and falls back tomessage:notConfigured409 ·notConnected409 ·connectionExpired409 (stored app password got 401 or its credential is already dead → account marked EXPIRED) ·accountBroken500 (decrypt failed; loud, logged without value) ·credentialsOrTwoFactor422 ·useBrowserLogin422 (Nextcloud 403 on getapppassword) ·tooManyAttempts429 withretryAfterSeconds(Tessera's own limiter) ·nextcloudLocked503 withretryAfterSeconds(Nextcloud answered 429 now or the origin is paused) ·nextcloudMaintenance503 ·nextcloudRedirect502 ·nextcloudUnavailable504 (timeout/network/tls) ·nextcloudError502 ·notFound404 ·nameTaken409 (405 on MKCOL, 412 on MOVE/PUT/assembly) with optionalexisting { etag, size, mtime }·changedMeanwhile409 (etag no longer matches) ·pathConflict409 (Nextcloud 409: parent missing / folder into itself) ·moveIntoItself400 ·locked409 (423) ·notAllowed422 (Nextcloud 403 on a file op) ·invalidName400 (Nextcloud 400/415 or own name check) ·invalidPath400 ·quotaExceeded507 ·lengthRequired411 ·chunkTooLarge413 ·fileTooLarge413 ·flowExpired410 ·tooManyFlows503 ·invalidUrl400 ·confirmReconnect409 withconnectedAccounts. One functionmapNcFailure(Task 3) applies it for every file and transfer route. - D-E Login guard (
nextcloud-login-guard.ts, process-wide, injectable clock): failed password logins (Nextcloud 401 on getapppassword) are counted per Tessera user (max 3 per 15 min) and for the whole server (max 8 per 30 min — the server shares ONE IP and Nextcloud locks at 10 per 30 min); when a limit is reached the next attempt gets 429tooManyAttempts(retryAfterSeconds = until the oldest counted failure leaves the window) WITHOUT calling Nextcloud; a successful login clears only that user's failures. Login Flow starts are limited to 10 per user per 10 min (429) — flow init is not counted by Nextcloud's brute-force protection (measured), so it never touches the failure counters. Nextcloud 429 handling lives in the call gate (D-O), not here. - D-F Login Flow state (class
LoginFlowStorein the guard file): in memoryMap<flowId, { tenantId, userId, baseUrl, pollToken, expiresAt, lastPollAt }>, flowId =randomUUID(), TTL 20 min, at most one flow per user (a new start replaces the old one), at most 200 flows in total (503tooManyFlows), expired entries pruned on every access, the Nextcloud poll is called at most once per 1.5 s per flow (faster browser polls getpendingwithout a Nextcloud call). The poll token never leaves the server. The browser link is rebuilt as${baseUrl}/index.php/login/v2/flow/${token}where token is taken from the returnedloginpath with/login\/v2\/flow\/([A-Za-z0-9]{32,256})$/; the returnedpoll.endpointand the origin ofloginare discarded. An API restart loses open flows — the user simply starts again.clearTenant(tenantId)drops a tenant's flows on an address change. - D-G Settings:
PUT settings { baseUrl, confirmReconnect? }normalises with the existingnormalizeCloudUrl(exported fromapps/api/src/nextcloud-status/nextcloud-status-fetch.ts), invalid → 400invalidUrl; unchanged → returns the view; changed while ACTIVE accounts exist andconfirmReconnect !== true→ 409confirmReconnectwithconnectedAccounts; on save upsert the config,updateManyall accounts of the tenant to EXPIRED, notify the address-change listeners (Task 2 registersLoginFlowStore.clearTenant, Task 6 the server-info cache), then run the status check and return{ baseUrl, connectedAccounts, check }.POST settings/test { baseUrl }runs the existingfetchNextcloudStatus(status.php only, no credentials) unless the origin is paused by the call gate (then{ ok: false, kind: 'paused' }without a request), and maps to{ ok, kind, message, version, productName };http-statuswith 400 → "Nextcloud lehnt diese Adresse ab. Bitte nehmen Sie den Rechnernamen in die vertrauenswürdigen Domains (trusted_domains) der Nextcloud auf."; a successful check that only worked after redirects is reported as a hint to enter the final https address (runtime requests never follow redirects).GET settings→{ baseUrl, connectedAccounts }.GET status→{ configured, serverUrl, host, account }(account from Task 2). - D-H Paths: the browser sends paths only in query/JSON (never in the Tessera URL path).
validateSegment(s)rejects empty, '.', '..', '/', '\', NUL or control characters (U+0000–U+001F, U+007F) and more than 255 UTF-8 bytes;parseUserPath(raw)→ string[]: '' or '/' = root; one leading and one trailing '/' stripped; every segment throughvalidateSegment; more than 100 segments or more than 4096 characters → 400invalidPath. New names (mkdir, rename target) additionally must not end with '.part' and must not be blank → 400invalidName. Names are passed through unchanged (no Unicode normalisation). Entry names come from the decodedhrefsegments, never fromdisplayname. - D-I File API (Task 3; all Benutzen, all under the caller's own account):
GET files?path=→{ path, entries, quota: { used, available | null }, truncated }(max 5000 entries, folder itself skipped, its quota used, negative available → null = unlimited); entry{ name, path, type: 'folder'|'file', size, mime, mtime, etag, fileId, permissions, hasPreview, favorite };POST folders { path }(MKCOL);POST move { from, to }(MOVE withOverwrite: F;toequal to or insidefrom→ 400moveIntoItselfbefore any call);DELETE files?path=(DELETE → Nextcloud trash);GET preview?fileId=&v=. Etappe 2 stays open: the DAV layer keeps a genericdavRequest, the auth client anocsRequest, entries keep the permission letters (R = shareable), the row menu takes an action list. - D-J Upload protocol (Task 4; stateless towards Nextcloud, which holds the chunks):
POST uploads { path, size, replaceEtag? }→ PROPFIND Depth 0 on the target: exists and no replaceEtag → 409nameTakenwithexisting; replaceEtag given but the current etag differs → 409changedMeanwhile; size >NEXTCLOUD_FILES_MAX_CHUNKS× chunk → 413fileTooLarge; size ≤ chunk →{ mode: 'single' }; else MKCOL/remote.php/dav/uploads/{uid}/tessera-<uuid>withDestination→{ mode: 'chunked', uploadId, chunkSize }.PUT uploads/file?path=&size=&mtime=&replaceEtag=→ raw body streamed toPUT /remote.php/dav/files/{uid}/<path>withIf-None-Match: *(orIf-Match: <replaceEtag>),X-OC-Mtime.PUT uploads/:uploadId/chunks/:n?path=&size=→ n 1..10000 sent as five-digit name (00001),Destination+OC-Total-Length.POST uploads/:uploadId/complete { path, size, mtime?, replaceEtag? }→ MOVE.filewithDestination,OC-Total-Length,X-OC-Mtime,Overwrite: F(with replaceEtag: re-check the etag by PROPFIND Depth 0 immediately before, thenOverwrite: T); the MOVE runs in the background: the route waits up to 20 s and answers{ state: 'done' }or 202{ state: 'assembling' };GET uploads/:uploadId/stateanswers done / assembling / failed{ code, message }(state kept in memory per tenant+user+uploadId for 10 min after the end; reason: the Next.js proxy and Nginx Proxy Manager cut idle requests after 30–60 s while Nextcloud assembles large files); on 412 the upload folder is deleted.DELETE uploads/:uploadId→ DELETE the upload folder. Raw-body routes:content-lengthrequired (411), >NEXTCLOUD_FILES_CHUNK_SIZE→ 413chunkTooLarge, the Express request stream is handed to the transport unread (no multer interceptor of any kind, nothing buffered); client abort destroys the upstream request. uploadId must match^tessera-[0-9a-f-]{36}$; mtime integer 0..4102444800; size integer 0..(10000 × chunk). - D-K Download/preview headers (Tasks 3–4): every upstream answer is checked first — any non-2xx (and any gate failure) goes through
mapNcFailureand becomes a JSON error BEFOREsendUpstreamStreamwrites a header or pipes a byte. Only an allowlist goes to the browser — content-type, content-length, content-range, accept-ranges, etag, last-modified; downloads always getContent-Disposition: attachment; filename="<ASCII fallback>"; filename*=UTF-8''<encodeURIComponent(name)>built by Tessera,X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandbox,Cache-Control: private, no-store. ZIP name = folder name + ".zip" (root or selection in root: "Dateien.zip"). Multi-selection ZIP: every name runs throughvalidateSegment(400invalidPathbefore any call); Task 4 first measures against the test container whetherGET .../dav/files/{uid}/<dir>/?accept=zip&files=<JSON array of names>returns a ZIP with exactly the chosen entries; if yesdownload/zipuses it, if not the route answers 404notFoundand the web downloads each selected item on its own (files directly, folders as ZIP, 400 ms apart) — the measured result goes into the SUMMARY. Preview:GET /index.php/core/preview?fileId=<id>&x=256&y=256&a=1&forceIcon=0(Nextcloud rounds to size steps; CSS scales), fileId^\d{1,20}$, onlyimage/*passed (else 404), cap 5 MiB,Cache-Control: private, max-age=86400whenv(the etag) is present, elseprivate, max-age=3600; no preview → 404 and the UI shows the type tile. - D-L UI architecture: page = PageHeader (moduleSlug, title, description,
actions= AccountBar when connected) + TabBar (Dateien / Einstellungen) only for managers, plain content for Benutzen users; states: not configured → hint card; not connected or expired → ConnectPanel; connected → FileBrowser. Folder path lives in component state and is mirrored to?path=withwindow.history.replaceState(reload keeps the folder; nouseSearchParams— avoids the Next 15 Suspense build error on a prerendered route). View mode per user in localStoragetessera:nextcloud-files:view:<userId>(precedentsort-clouds.ts, user id fromuseAuthStore). Type-family colours are CSS tokens--ft-<family>-bg/--ft-<family>-fgin:rootand.darkofglobals.css(exact values in Task 5), checked for ≥ 4.5:1 contrast in both modes by a test. - D-M Test harness: helper scripts in
.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/(committed with the task commits; test-only values from the research; they never read .env). Nextcloud address for local testshttp://172.17.0.1:18080— reachable from the api container (host gateway, verified 2026-10-08 against another host port) AND from the host browser used by Playwright, so Login Flow v2 works end-to-end. Brute-force whitelist172.16.0.0/12on the test Nextcloud so repeated test runs never lock the IP; 429 behaviour is covered by unit specs.e2e-lib.shholds the shared helpers (admin login with cookie jar, HTTP status helper, module activation, address setup, anna connect, second Tessera user). - D-N Rights and route order (
@Controller('modules/nextcloud-files'), class@UseModule('nextcloud-files')): Verwalten (@ModuleManage('nextcloud-files'), never with a role decorator) = GET settings, PUT settings, POST settings/test. Benutzen (class guard only) = everything else: GET status, GET server, GET server/logo, POST connect/password, POST connect/flow, DELETE connect, GET files, DELETE files, POST folders, POST move, GET preview, GET download, GET download/zip, POST uploads, PUT uploads/file; then the parameter routes at the END: GET connect/flow/:flowId, DELETE connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE uploads/:uploadId. Each task inserts its statics before the parameter block and its parameter routes at the end; the controller spec asserts the declaration order. - D-O Call gate (
nextcloud-call-gate.ts, one process-wide@Injectable() NextcloudCallGate, injectable clock), enforced insidencRequestso no caller can bypass it: (a) server-wide pause per Nextcloud origin — ANY 429 on ANY call (login, flow, OCS, DAV, preview, transfer, status check) pauses that origin for the lock period (15 min, or the answer'sRetry-Afterseconds when present, capped at 60 min); while paused everyncRequestto that origin returns{ ok: false, kind: 'paused', retryAfterSeconds }WITHOUT calling the transport, mapped to 503nextcloudLocked; a different origin is unaffected. (b) per-credential short-circuit — calls made with a stored app password carry acredentialKey(first 16 hex chars of sha256 over the encrypted value, computed bygetSession); the first 401 on such a call marks the key dead (kept 24 h, max 10 000 keys) and aborts every in-flight request of that key (the gate holds one AbortController per live key;ncRequestcombines it with its own signal viaAbortSignal.any); later calls with a dead key return kind 'credential-dead' WITHOUT calling the transport;getSessionchecks the key first and marks the account EXPIRED (409connectionExpired). A 401 on a call without credentialKey (password login) marks nothing. - D-P App-password hygiene (Task 2): a freshly issued app password (password path or Login Flow) that is not stored —
cloud/userfails, encryption or the upsert throws, the flow was cancelled meanwhile — is revoked at once, best effort (DELETE/ocs/v2.php/core/apppasswordwith that password, 10 s, errors only logged without value). On reconnect with an existing row whose baseUrl equals the current address and whose value decrypts, the OLD app password is revoked (best effort) before the new row is written; a row for another address is never revoked (never send it to a different host).
Output: migration + two models, API module (transport + call gate, auth client, login guard, settings/account/files/transfer services, upstream mapper, server info, controller, seed), web module (settings, connect screen, file browser), uploader, tests, e2e scripts, docs, changelog, rebuilt local stack, screenshots light + dark. Six 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 on 2026-10-08):
- Closest module template is
domains(built today, quick-261008-dts):apps/api/src/domains/{domains.controller.ts, domains.module.ts, domains.seed.ts, domains-settings.service.ts, domains.types.ts}(class@UseModule,requireTenantId(req)fromreq.tenantId,@ModuleManageon manage handlers, header comment with the rights table and the route-order rule, errors as{ code, message }exceptions, seed with try/catch logging 'Domains module seeded in registry'),apps/web/src/app/(portal)/modules/domains/{page.tsx, layout.tsx, components/SettingsTab.tsx, domains-page.test.tsx}(TabBar from@/components/accounting/tab-bar,SettingsSectionfrom@/components/control-center/settings-sectionwith title/description/actions/footer/flush,PageHeaderfrom@/components/layout/page-headerwith title/description/actions/moduleSlug,useCanManageModulefrom@/lib/use-module-capability— null while loading → treat as false; page test renders withNextIntlClientProvider locale="de" messages={de}and mocks the api module viavi.mock(..., importOriginal)). - Nextcloud-Status (
apps/api/src/nextcloud-status/nextcloud-status-fetch.ts) exportsnormalizeCloudUrl(raw): string | nullandfetchNextcloudStatus(baseUrl, { fetchImpl?, timeoutMs? })→{ reachable, maintenance, versionString, productName, errorKind ('timeout'|'network'|'tls'|'http-status'|'not-nextcloud'|'too-large'|'redirect'), errorDetail }— reuse both. Its logo route (GET instances/:id/logowith@Res()) is the precedent for an<img>-loaded API route authenticated by the Tessera cookie. CryptoService(apps/api/src/crypto/crypto.service.ts) comes from the GLOBAL CryptoModule — inject, do not import a module;encrypt(plain)→iv:authTag:ciphertext,decryptthrows on bad input.PrismaServiceis global.forTenant(prisma, tenantId, userId?)fromapps/api/src/prisma/prisma-tenant.extension.tssetsapp.current_tenantandapp.current_userper operation — pass the userId for every per-user account access; settings-side access (count, expire-all) binds only the tenant. Never useinclude:or relationselect:in this module (rls inventory). Oneconst tenantPrisma = forTenant(...)per method so the inventory detector sees it.- RLS gates:
apps/api/src/prisma/rls-coverage.spec.tsneeds ENABLE + FORCE +tenant_isolation_policyper new table; the user-dimension policy form is inapps/api/prisma/migrations/20260929140000_reminder/migration.sql.apps/api/src/prisma/rls-access-inventory.spec.tscompares every (file, model) Prisma access against the Fundstellentabelle indocs/mandantentrennung-zugriffsklassifikation.md(Bereichszeile like| domains | 0 | 30 | 0 | ... |around line 184, Paarzählung paragraph around line 420, Fundstellen rows like thedomains-settings.service.tsrow around line 901). Recount with the Gate-Schleife, never copy numbers. - Latest migration:
20261008160000_domains_drop_default_nameservers. Local DB has no host port:IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1),DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera"forpnpm --filter @tessera/api exec prisma migrate deploy|status|diff. The api container also runs migrate deploy on start. apps/api/src/main.tsregisters only cookieParser, ValidationPipe (whitelist, transform) and CORS — no raw/large body parser; anapplication/octet-streamrequest body stays an unread stream onreq. The request log (apps/api/src/common/request-log.ts) logs the path without the query string — Tessera routes therefore keep file paths in query/body only.undici7.28.0 andfast-xml-parser5.10.1 are already API dependencies — no new packages (no package legitimacy gate needed). Node 24 providesAbortSignal.any.- Next.js rewrite
/api-proxy/:path*→API_INTERNAL_URL(apps/web/next.config.ts); the web image is built withNEXT_PUBLIC_API_URL=/api-proxy(apps/web/Dockerfileline 28), so the browser at http://localhost:3000 goes through the proxy, which silently cuts request bodies after 10 MiB and has a 30 s idle timeout (research, Next 15.5.19 source).experimental.middlewareClientMaxBodySizestays untouched. @tessera/sharedis consumed from source (packages/shared/package.jsonmainsrc/index.ts) — a new export needs no build step.- Web registration points:
apps/web/src/lib/module-loader.ts(dynamic import, ssr false, e.g.domainsaround line 80),apps/web/src/lib/module-identity.ts(ModuleIconIdunion currently'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile'+ ICONS map),apps/web/src/components/modules/module-tile.tsx(GLYPHS keyed by ModuleIconId),apps/web/src/lib/stores/nav-store.ts(MODULE_TITLE_KEYS),apps/web/src/app/(portal)/modules/module-layouts.test.tsx(it.each of slug + layout). Layout =ModuleAccessGate(@/components/modules/module-access-gate). - Desktop app:
apps/web/src/components/desktop/DesktopExternalLinkslistens ondocumentand openstarget=_blankanchors itself — a plain anchor clicked by the user works in browser AND desktop. Desktop downloads of same-origin<a download>links are handled byon_downloadinapps/desktop/src-tauri/src/lib.rs— not re-verified here, listed for the user check in the SUMMARY. - Per-user browser preference precedent:
apps/web/src/components/nextcloud-status/sort-clouds.ts(readSortPreference/writeSortPreference(userId), keytessera:nextcloud-status:sort:<userId>, try/catch around localStorage) withuseAuthStore((s) => s.user?.id ?? null)from@/lib/stores/auth-store. - Design tokens (
apps/web/src/app/globals.css):--primaryis the Mosaik yellowoklch(0.91 0.19 102)in both modes,--primary-strong,--card(lightoklch(1 0 0), darkoklch(0.245 0.01 260)),--well,--tile,--muted-foreground, status tokens--status-ok|warn|down|idlewith-fgvariants (Tailwind classesbg-status-warn/12 text-status-warn-fg), global button classes.btn .btn-primary .btn-secondary .btn-subtle .btn-icon,.surface,.cc-section*, reduced-motion media queries already present; font stack is--font-sans— no new font.apps/web/src/lib/color.tsexportsrelativeLuminance(hex)andreadableOnAccent(hex). Tailwind 4.3.1 (hasmotion-safe:,motion-reduce:,pointer-coarse:variants). No CSS modules in the project — new tokens go into globals.css. - Dialog/menu precedent: custom dialogs with
role="dialog"(e.g.apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx); there is no shared menu component — buildEntryMenuwithrole="menu"/menuitem, Escape closes, focus returns to the trigger. - Category
infrastructureexists inMODULE_CATEGORIES(packages/shared/src/index.ts), German label "Infrastruktur" (renameable by admins). - Users:
POST /users(admin only, body{ username, email, password (min 8), displayName?, role? }, roles SUPER_ADMIN/ADMIN/USER) — new users usually carrymustChangePassword, whichForcePasswordChangeInterceptorenforces (onlyPOST /auth/change-passwordwith{ currentPassword, newPassword },POST /auth/logout,GET /auth/mepass). Admins pass the ModuleGuard of an activated module without a Freigabe. The executor readsapps/api/src/user/user.controller.ts,apps/api/src/user/dto/create-user.dto.tsandapps/api/src/auth/auth.controller.tsbefore writing the second-user helper. - Local stack running: web :3000 (production build through /api-proxy), api :3001, db, mailhog;
admin/admin123logs in atPOST http://localhost:3001/auth/login(JSON{ username, password }, cookie jar; cookies ignore the port, so the same jar works forhttp://localhost:3000/api-proxy/...);GET /modules/catalog→[{ id, slug, isActiveForTenant }];POST /modules/<id>/activate;GET /health. Rebuild withdocker compose up -d --build api web(plainupdoes not rebuild). The api container reaches host ports via172.17.0.1(measured:http://172.17.0.1:3002→ 200), the host has172.17.0.1on docker0. Imagenextcloud:stable(34.0.4) is present locally; no Nextcloud test container is running yet. Playwright MCP writes screenshots under.playwright-mcp/(gitignored). - Pitfall from STATE.md ("Tautologischer Test"): specs against an external system assert the literal shape of the outgoing call (method, exact URL, exact header set, exact body written out in the test), never values rebuilt with the production helper. Literals:
anna:geheim→Basic YW5uYTpnZWhlaW0=;anna:app-pw-123→Basic YW5uYTphcHAtcHctMTIz;encodeURIComponent('Ärger & Ölpreis 100%.txt')=%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt;a b#c?d.txt→a%20b%23c%3Fd.txt;50%25.txt→50%2525.txt. - Commits: German subject, prefix
feat(nextcloud-files):, body ends withCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>. Never push (the user bundles pushes). PLAN/SUMMARY/STATE are committed by the orchestrator; the e2e scripts go into the task commits. No deploy to the test server. Never read .env files.
@apps/api/src/domains/domains.controller.ts @apps/api/src/domains/domains-settings.service.ts @apps/api/src/nextcloud-status/nextcloud-status-fetch.ts @apps/api/src/nextcloud-status/nextcloud-status.controller.ts @apps/api/src/crypto/crypto.service.ts @apps/api/prisma/migrations/20260929140000_reminder/migration.sql @apps/web/src/app/(portal)/modules/domains/page.tsx @apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
Task 1: Foundation slice — a manager sets and checks the Nextcloud address in the new module (DB, transport with call gate, settings service, controller, module registration, Einstellungen tab) against the local test Nextcloud apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql, apps/api/src/nextcloud-files/nextcloud-files.types.ts, apps/api/src/nextcloud-files/nextcloud-http.ts, apps/api/src/nextcloud-files/nextcloud-http.spec.ts, apps/api/src/nextcloud-files/nextcloud-call-gate.ts, apps/api/src/nextcloud-files/nextcloud-call-gate.spec.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.seed.ts, apps/api/src/nextcloud-files/nextcloud-files.module.ts, apps/api/src/app.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-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/components/modules/module-tile.tsx, apps/web/src/lib/stores/nav-store.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.php, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh The local stack (db, api, web) is running, `admin`/`admin123` logs in at http://localhost:3001/auth/login, the image `nextcloud:stable` is present (`docker image inspect nextcloud:stable`) and host port 18080 is free or already used by `tessera-nc-test`. - nextcloud-http: `buildNcUrl('https://cloud.example/nc', '/remote.php/dav/files/', ['anna', 'Ärger & Ölpreis 100%.txt'])` = `https://cloud.example/nc/remote.php/dav/files/anna/%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt`; segments `a b#c?d.txt` and `50%25.txt` encode to the literals in context; a fixed prefix outside the allowed list throws before any call; `parseUserPath('')` and `('/')` → []; `('/Projekte/2026/')` → ['Projekte','2026']; '/a//b', '/a/../b', '/./a', 'a\\b', a NUL, U+0007, a 256-byte segment, 101 segments → BadRequest code invalidPath; `validateSegment('..')` throws invalidPath; `ncRequest` (fake transport) sends `user-agent: Tessera (Nextcloud-Dateien)`, never a cookie header, and the Authorization literal `Basic YW5uYTpnZWhlaW0=` for anna/geheim; a 302 answer → failure kind 'redirect' and the transport is called exactly once (no follow); a hanging transport with a 20 ms timeout → 'timeout'; ENOTFOUND → 'network'; CERT_HAS_EXPIRED → 'tls'; `readCappedText` over its cap → 'too-large'; JSON.stringify of any failure never contains 'Basic ' or the password. - Call gate + ncRequest (fake clock, fake transport): a 429 from `https://cloud.example` → the next ncRequest to any path of that origin returns kind 'paused' with retryAfterSeconds 900 and the transport is NOT called; a request to `https://other.example` still goes out; `Retry-After: 120` → pause of 120 s; `Retry-After: 99999` → capped at 3600 s; after the pause ends calls go out again; a 401 on a call with credentialKey 'k1' → a second call with 'k1' that is still pending gets its transport signal aborted and resolves as 'credential-dead', a later call with 'k1' returns 'credential-dead' without a transport call, a call with 'k2' goes out; a 401 on a call without credentialKey marks nothing; dead keys expire after 24 h and the store never holds more than 10 000 keys. - Settings service (mocked prisma via forTenant, mocked status fetcher, real gate): GET settings without row → `{ baseUrl: null, connectedAccounts: 0 }`; PUT ' https://Cloud.Example/nc/index.php ' stores `https://Cloud.Example/nc` per normalizeCloudUrl; 'ftp://x' → 400 invalidUrl; same address again → no write; a new address with 2 ACTIVE accounts and no confirmReconnect → 409 confirmReconnect with connectedAccounts 2 and no write; with confirmReconnect true → config upserted, accounts updateMany to EXPIRED for the tenant, every registered address-change listener called with the tenant id; the test endpoint maps a reachable status to ok true with version, 'http-status' + 'HTTP 400' to the trusted-domains text, timeout to a German timeout text; a paused origin → `{ ok: false, kind: 'paused' }` and the status fetcher is NOT called; getStatus → `{ configured, serverUrl, host, account: null }`. - Controller metadata: class MODULE_SLUG_KEY 'nextcloud-files'; getSettings, saveSettings, testSettings have MODULE_MANAGE_KEY true and no ROLES_KEY; getStatus has no MODULE_MANAGE_KEY; a reusable assertion "every handler whose path contains ':' is declared after all static handlers" (index check over Object.getOwnPropertyNames of the prototype) passes and is kept for later tasks. - Web (page test with mocked `@/lib/nextcloud-files-api` and `@/lib/use-module-capability`): not configured + manager → hint with a button to Einstellungen; not configured + Benutzen → hint to ask an administrator, no Einstellungen tab and no TabBar; manager sees the tabs Dateien and Einstellungen; SettingsTab: "Verbindung prüfen" calls testNextcloudFilesSettings once per click (disabled while running) and shows the message; saving a changed address with connected users shows the confirmation with the number of users and only the confirmed save sends `confirmReconnect: true`; the hints card shows the whitelist command. **Test Nextcloud first (D-M, L-10).** Write the helper scripts under `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/` with the Write tool (no heredocs). `nc-test-setup.sh` (bash, `set -euo pipefail`, idempotent): when container `tessera-nc-test` does not exist, `docker run -d --name tessera-nc-test -p 18080:80 -e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD='Admin-Pass-12345' -e NEXTCLOUD_TRUSTED_DOMAINS='localhost 127.0.0.1 172.17.0.1 tessera-nc-test' nextcloud:stable`; start it if stopped; wait (max 180 s) until `curl -s http://localhost:18080/status.php` contains `"installed":true`; then idempotently: users `anna` ('User1-Pass-12345', display name "Anna Müller") and `zoe` ('User2-Pass-12345', display name "Zwei Faktor") via `occ user:add --password-from-env` (skip when `occ user:info` succeeds), group `twofa` with zoe and `occ twofactorauth:enforce --on --group=twofa`, TOTP for zoe via `totp.php` (copied into the container with `docker cp`; research snippet: `secret` mode prints a new secret, code mode enables) and `totp.py` (research snippet, run with `python3 -I`), storing the secret inside the container at `/var/www/zoe-totp-secret` (skip when the file exists and `occ twofactorauth:state zoe` lists totp), brute-force whitelist `occ config:app:set bruteForce whitelist_0 --value=172.16.0.0/12`, and finally prove reachability from the api container with `docker compose exec -T api node -e` fetching `http://172.17.0.1:18080/status.php` (exit non-zero if not 200). Print `nc test ready`. `e2e-lib.sh` (sourced, no top-level side effects): `API=http://localhost:3001`, `WEB=http://localhost:3000/api-proxy`, `NC_BASE=http://172.17.0.1:18080`, `E2E_DIR`, functions `e2e_login [user] [password]`, `e2e_status [json-body] [outfile]` (prints the HTTP status, body to the outfile), `e2e_activate ` (catalog lookup + activate when inactive), `e2e_set_address ` (PUT settings with `NC_BASE` and `confirmReconnect: true`). Run `nc-test-setup.sh` now.Schema + migration (D-B). In apps/api/prisma/schema.prisma after the Domains models add a German comment block (quick-261008-mzu; one Nextcloud per organisation, own account per user, only the app password encrypted, RLS like Reminder) with the two enums and two models exactly as in D-B, plus the back-relation on User. Get the exact DDL with prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script against the local DB, then hand-write apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql: German header (purpose of both tables; why the account row stores the issuing baseUrl — an app password is never sent to another host, an address change expires all rows; config policy tenant-only, account policy tenant AND user in the Reminder form — this is what keeps one user from ever reading another user's account; no system_read_policy because nothing reads across tenants; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note like the Reminder header), the enums, tables, indexes, FK, then per table ENABLE + FORCE ROW LEVEL SECURITY and the policy. prisma generate, apply locally via the container IP, migrate status up to date, migrate diff ... --exit-code exits 0.
Transport, call gate, types (D-C, D-D, D-H, D-O). nextcloud-files.types.ts: the error-code union of D-D, ncError(code, httpStatus, message, extra?) building the Nest HttpException with body { code, message, ...extra }, the German default messages, NcFailureKind ('redirect' | 'timeout' | 'network' | 'tls' | 'too-large' | 'invalid-response' | 'http' | 'paused' | 'credential-dead'), shared view types (NextcloudFilesStatusView, NextcloudFilesSettingsView, NcSession { baseUrl, ncUserId, authorization, credentialKey }). nextcloud-call-gate.ts: @Injectable() NextcloudCallGate with isPaused(origin) → { paused, retryAfterSeconds }, pause(origin, retryAfterHeader?), isDead(key), markDead(key) (aborts the key's controller), signalFor(key); German comment: the brute-force lock of Nextcloud is per IP and hits every Tessera user, every further request during the lock prolongs or wastes it, so the gate stops traffic instead of retrying; a revoked app password must not hammer Nextcloud (each 401 counts as a failed login there). nextcloud-http.ts, framework-free apart from the gate type: NextcloudTransport type and undiciTransport default (wraps request from undici, passes headersTimeout/bodyTimeout/signal, returns { statusCode, headers, body }), the allowed prefix list, buildNcUrl, validateSegment, encodeSegments, parseUserPath, validateNewName, basicAuth(user, secret), ncRequest(transport, gate, opts) that checks the gate before the transport, updates it after the answer (429 → pause, 401 with credentialKey → markDead), never throws for network/HTTP problems (returns { ok: true, status, headers, body } or { ok: false, kind, status, retryAfterSeconds? }; certificate error codes copied from apps/api/src/proxmox/proxmox-client.service.ts; the upstream body of a failure is drained/destroyed), and readCappedText(body, maxBytes). German header comment: why internal addresses are allowed and how SSRF is contained (L-01: one base URL, fixed prefixes, segment encoding, no redirects, no response URLs, address change expires), why undici request, no cookies, never log headers. Specs nextcloud-call-gate.spec.ts and nextcloud-http.spec.ts per <behavior>.
Settings service, DTO, controller, seed, module (D-A, D-G, D-N, L-01, L-07). dto/nextcloud-files-settings.dto.ts: SaveNextcloudFilesSettingsDto { baseUrl (IsString, IsNotEmpty, MaxLength 2048); confirmReconnect? (IsBoolean) }, TestNextcloudFilesSettingsDto { baseUrl }. nextcloud-files-settings.service.ts (inject PrismaService, NextcloudCallGate, an injectable status fetcher token defaulting to fetchNextcloudStatus): getBaseUrl(tenantId), getStatus(tenantId), getSettings, saveSettings, testAddress, onAddressChange(listener) per D-G — the only file touching nextcloudFilesConfig, plus tenant-bound nextcloudFilesAccount.count/updateMany for the reconnect logic; each method its own forTenant(this.prisma, tenantId) client. nextcloud-files.controller.ts: @Controller('modules/nextcloud-files'), class @UseModule('nextcloud-files'), requireTenantId like DomainsController; handlers @Get('status') getStatus; @Get('settings') @ModuleManage('nextcloud-files') getSettings; @Put('settings') @ModuleManage(...) saveSettings; @Post('settings/test') @HttpCode(200) @ModuleManage(...) testSettings. German header comment with the full rights table of D-N, the route-order rule (later tasks add statics before the parameter block and parameter routes at the end), never a role decorator on manage handlers, never 401/403 for a Nextcloud failure (D-D). nextcloud-files.seed.ts per D-A (pattern domains.seed.ts). nextcloud-files.module.ts imports ModuleRegistryModule, provides the settings service, NextcloudCallGate, { provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport } and the status-fetcher token; OnModuleInit seeds with try/catch and logs 'Nextcloud files module seeded in registry'. Register in apps/api/src/app.module.ts next to DomainsModule. Controller spec per <behavior>; module-manage-handlers.spec.ts: a NextcloudFilesController manage it.each (getSettings, saveSettings, testSettings) and a Benutzen-level it.each (getStatus) that later tasks extend.
RLS doc. Run pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory; add the Bereichszeile nextcloud-files, update Summenzeile and Paarzählung, and add Fundstellen rows for nextcloud-files-settings.service.ts / nextcloudFilesConfig and / nextcloudFilesAccount (tenant-bound admin operations: count and expire-all), muss-mandantengebunden / gebunden, German explanations; counted with the Gate-Schleife; both specs green.
Web (L-01, L-07, D-A, D-L). apps/web/src/lib/nextcloud-files-api.ts (pattern apps/web/src/lib/nextcloud-status-api.ts: NEXT_PUBLIC_API_URL, credentials: 'include', cache: 'no-store' on GETs): class NextcloudFilesRequestError(status, code, message, extra), types, getNextcloudFilesStatus, getNextcloudFilesSettings, saveNextcloudFilesSettings, testNextcloudFilesSettings. layout.tsx = ModuleAccessGate "nextcloud-files". page.tsx ('use client'; max-w-6xl wrapper): canManage = useCanManageModule('nextcloud-files') === true, loads status, PageHeader (moduleSlug, title, description), TabBar only for managers ('files' | 'settings'); Dateien tab: not configured → SettingsSection hint (manager: "Zu den Einstellungen"; others: "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten."), configured → the section data-testid="nextcloud-files-main" into which Task 2 mounts the connect screen. components/SettingsTab.tsx (SettingsSection cards): "Nextcloud-Adresse" (input, hint "Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, zum Beispiel https://cloud.ihre-firma.de. Interne Adressen sind erlaubt.", footer "Verbindung prüfen" + "Speichern", result line, connected-users line, the reconnect confirmation "{count} Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden." / "Adresse ändern" / "Abbrechen"), "Hinweise für die Nextcloud-Administration" (whitelist of the Tessera server IP with occ config:app:set bruteForce whitelist_0 --value=<IP-Adresse des Tessera-Servers> in a code element and why: all Tessera users share one IP; https recommended; the host must be in trusted_domains; two-factor accounts connect via the browser). Registrations: module-loader nextcloud-files; module-identity folder in the union + 'nextcloud-files': 'folder'; module-tile GLYPHS folder (D-A path); nav-store 'nextcloud-files': 'nextcloudFiles.title'; module-layouts test entry. Messages: namespace nextcloudFiles in de.json (Sie, real umlauts, no {query} placeholder names) and en.json with identical keys; run the umlaut guard, allowlist only correct tokens. Page test per <behavior>.
Run. Biome-lint the touched files (pnpm exec biome lint <files> from the repo root; biome check --write only on new files). Rebuild docker compose up -d --build api web, wait for curl -sf http://localhost:3001/health, check docker compose logs api for 'Nextcloud files module seeded in registry'. Write e2e/e2e-settings.sh (bash, set -euo pipefail, sources e2e-lib.sh): admin login; activate; PUT settings with NC_BASE + confirmReconnect → 200 and check.ok true; POST settings/test NC_BASE → 200, "ok":true and a version starting with 34.; POST settings/test http://172.17.0.1:1 → 200 with "ok":false; PUT ftp://x → 400 with invalidUrl; GET status → "configured":true and host 172.17.0.1:18080; GET settings → contains "baseUrl":"http://172.17.0.1:18080"; print e2e settings ok. Run the <verify> command. Commit feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht (attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-files rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/nextcloud-files module-layouts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh && echo "task1 ok"
<fails_when>a spec (transport, call gate, settings, controller, rls, manage handlers, page, layouts, messages) fails, a tsc run fails, the controller carries a role decorator, the test Nextcloud cannot be set up or reached from the api container, or any e2e-settings.sh step fails (activation, save, check with version 34, unreachable address, invalid address, status, settings)</fails_when>
Migration applied locally without drift (both tables, account policy bound to tenant and user); transport, call gate, settings and controller specs green; module seeded, activatable and registered in loader/icon/nav/layouts; a manager saves and checks the address of the real test Nextcloud through the rebuilt stack; RLS gates green; commit on main, not pushed.
Login guard (D-E, D-F, L-04). nextcloud-login-guard.ts: @Injectable() NextcloudLoginGuard (checkPasswordAttempt, recordFailure, recordSuccess, checkFlowStart) and @Injectable() LoginFlowStore (create, get, remove, shouldPoll, markPolled, clearTenant), both with an injectable now. German comment: one shared server IP, Nextcloud locks at 10 failures / 30 min for ALL users, why 3/15 min and 8/30 min, memory only (a restart forgets counters — acceptable, Nextcloud's own protection and the call gate stay). Spec per <behavior>.
Account service + DTO (D-B, D-D, D-F, D-O, D-P, L-02, L-03, L-05). dto/nextcloud-files-connect.dto.ts: ConnectPasswordDto { loginName (IsString, IsNotEmpty, MaxLength 200); password (IsString, IsNotEmpty, MaxLength 500) }. nextcloud-files-account.service.ts (inject PrismaService, CryptoService, NextcloudFilesSettingsService, NextcloudLoginGuard, LoginFlowStore, NextcloudCallGate, NEXTCLOUD_TRANSPORT): getStatus(tenantId, userId) → settings status plus account: null | { status, ncUserId, displayName, connectedVia, connectedAt }; connectWithPassword (guard check → getapppassword → 401 recordFailure + 422 credentialsOrTwoFactor, 403 → 422 useBrowserLogin, 429/paused → 503 nextcloudLocked, redirect 502, timeout/network/tls 504, other 502 → cloud/user with the NEW app password → private storeAppPassword(tenantId, userId, base, ncUser, appPassword, method) implementing D-P (revoke old same-host credential, encrypt with this.crypto.encrypt(appPassword), upsert on tenantId_userId, on any failure revoke the new one and rethrow) → recordSuccess; the password variable is never stored, logged or returned); startFlow, pollFlow, cancelFlow per D-F and <behavior>; disconnect per <behavior> (revoke only when the row's baseUrl equals the current address, errors only logged, then deleteMany where tenantId + userId); getSession(tenantId, userId) → NcSession { baseUrl, ncUserId, authorization, credentialKey } after the checks in <behavior> (dead key → markExpired); markExpired(tenantId, userId). Every method its own forTenant(this.prisma, tenantId, userId) client — the user id always from the token, never from input. In the settings service register LoginFlowStore.clearTenant as an address-change listener (module wiring or constructor injection; extend its spec). Specs per <behavior>.
Controller + RLS doc (D-N). Add statics before the parameter block: @Post('connect/password') @HttpCode(200) connectPassword, @Post('connect/flow') startFlow, @Delete('connect') disconnect; at the end @Get('connect/flow/:flowId') pollFlow and @Delete('connect/flow/:flowId') cancelFlow (ParseUUIDPipe); getStatus now delegates to the account service. Provide the new services in the module. Extend the controller spec and the Benutzen-level it.each in module-manage-handlers.spec.ts (connectPassword, startFlow, pollFlow, cancelFlow, disconnect). RLS doc: add the Fundstellen row nextcloud-files-account.service.ts / nextcloudFilesAccount (bound to tenant AND user; muss-mandantengebunden / gebunden), update Bereichszeile, Summenzeile, Paarzählung with the Gate-Schleife.
Web (L-02, L-03, L-05, L-09). Extend nextcloud-files-api.ts: connectWithPassword, startLoginFlow, pollLoginFlow, cancelLoginFlow, disconnectNextcloud. page.tsx: inside nextcloud-files-main, not connected or expired → ConnectPanel; connected → AccountBar in the PageHeader actions and the section data-testid="nextcloud-files-browser" into which Task 5 mounts the file browser. components/AccountBar.tsx: "Angemeldet als {name}" with the Nextcloud host as secondary text, button "Abmelden" with an inline confirmation ("Verbindung zu Nextcloud trennen? Tessera vergisst den Zugang. Ihre Dateien in Nextcloud bleiben unverändert." / "Trennen" / "Abbrechen"). components/ConnectPanel.tsx (centered card, max-w-lg): a header slot for the server identity (Task 6 adds logo/name; here the host), the explanation "Melden Sie sich mit Ihrem Nextcloud-Konto an. Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit widerrufen können.", form (Benutzername oder E-Mail, Passwort with autoComplete="current-password", "Anmelden", busy state), divider "oder", "Im Browser anmelden" with the hint "Für Konten mit Zwei-Faktor-Anmeldung"; after a failure clear the password field; error texts per code (credentialsOrTwoFactor: "Die Anmeldung hat nicht geklappt. Entweder stimmen Benutzername oder Passwort nicht, oder Ihr Konto nutzt die Zwei-Faktor-Anmeldung. Dann melden Sie sich bitte im Browser an." and the browser action becomes primary; tooManyAttempts: "Zu viele Fehlversuche. Bitte warten Sie {minutes} Minuten."; nextcloudLocked: "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes} Minuten erneut."). Browser path: click → startLoginFlow → waiting view with the sentence "Öffnen Sie die Anmeldung bei Nextcloud, melden Sie sich dort an und bestätigen Sie mit „Zugriff gewähren“.", an anchor styled as primary button "Anmeldung bei Nextcloud öffnen" (href={loginUrl} target="_blank" rel="noopener noreferrer" — the user's own click opens it; never open a window from script after the async call, L-03), the status line "Warten auf Bestätigung in Nextcloud …" with a small pulsing dot (motion-safe:animate-pulse), button "Abbrechen"; poll every 2000 ms via setInterval plus an immediate poll on visibilitychange to visible; keep polling while the tab is hidden (the user is in the Nextcloud tab); stop on connected (reload status), flowExpired ("Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu."), failed, Abbrechen (cancelLoginFlow) and unmount. Expired account: notice "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an." above the form. Messages de + en, umlaut guard green. Page-test cases per <behavior>.
Run. Biome-lint touched files. Rebuild docker compose up -d --build api web, wait for /health, nc-test-setup.sh. Write e2e/e2e-connect.sh (bash, set -euo pipefail, sources e2e-lib.sh): admin login; activate; e2e_set_address; DELETE connect (200 or 409 notConnected accepted) for a clean start; POST connect/password anna/User1-Pass-12345 → 200, body contains "ncUserId":"anna" and neither User1-Pass nor ncrypted; GET status → "status":"ACTIVE"; docker exec -u www-data tessera-nc-test php occ user:auth-tokens:list anna lists exactly one token named like Tessera (Nextcloud-Dateien) (if the command does not exist in this Nextcloud version, query oc_authtoken in the container's SQLite file /var/www/html/data/nextcloud.db with php -r + PDO instead — decide once, note it in the SUMMARY); POST connect/password anna again (reconnect) → 200 and still exactly one Tessera token for anna (the old one was revoked, D-P); POST connect/password zoe/User2-Pass-12345 → 422 with credentialsOrTwoFactor (every zoe attempt counts as a failure in Tessera's own guard, so repeated runs within 15 minutes can legitimately hit the per-user limit: when the answer is 429 tooManyAttempts, the script runs docker compose restart api, waits for /health, logs in again and repeats the zoe attempt once, which must then answer 422 — the in-memory counters start fresh after a restart); POST connect/flow → 200 with a loginUrl starting with http://172.17.0.1:18080/index.php/login/v2/flow/ and no token/poll field; GET connect/flow/ → "state":"pending"; DELETE connect/flow/ → 200; DELETE connect → 200 and no Tessera token is left in anna's token list; GET status → "account":null; print e2e connect ok. Browser proof with Playwright MCP (L-10, dark mode via the theme button): log in at http://localhost:3000 as admin, open the module, (a) connect anna by password → "Angemeldet als Anna Müller", Abmelden; (b) enter zoe → the two-factor explanation → "Im Browser anmelden" → click "Anmeldung bei Nextcloud öffnen" (new tab) → log in as zoe → enter the code from python3 -I .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py "$(docker exec tessera-nc-test cat /var/www/zoe-totp-secret)" → "Zugriff gewähren" → back in the Tessera tab "Angemeldet als Zwei Faktor" appears without reload; Abmelden. Save screenshots as .playwright-mcp/nextcloud-files/t2-password-connected.png, t2-flow-waiting.png, t2-flow-connected.png (copy them there if the MCP writes elsewhere). Run the <verify> command. Commit feat(nextcloud-files): Anmeldung per Passwort und im Browser (Zwei-Faktor), Abmelden mit Widerruf (attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-files rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/nextcloud-files src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && ! grep -v '^\s*(//|*|/*)' "apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx" | grep -q 'window.open(' && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh && test -f .playwright-mcp/nextcloud-files/t2-flow-connected.png && echo "task2 ok"
<fails_when>an auth-client, guard, account, settings or controller spec or a web test fails, a tsc run fails, the role-decorator or script-opened-window gate trips, any e2e-connect.sh step fails (password connect, token in Nextcloud, reconnect leaves exactly one token, 2FA answer 422, flow start/pending/cancel, revoke on disconnect), or the Login Flow v2 browser proof screenshot is missing</fails_when>
Users connect by password or via Login Flow v2 (TOTP account proven in the browser), only the encrypted app password is stored, failures are limited per user and server-wide, a stored credential that gets a 401 stops all its traffic, fresh or replaced app passwords never stay orphaned, Abmelden removes the Nextcloud token; specs and e2e green; commit on main, not pushed.
DAV layer (D-C, D-I). nextcloud-dav.ts, framework-free on top of ncRequest (gate + session credentialKey on every call): generic davRequest(transport, gate, session, method, prefix, segments, headers, body?, timeouts) (Etappe 2: SEARCH/PROPPATCH fit here), list, stat (Depth 0, also used by Task 4), mkdir, move (Destination always built with buildNcUrl from the session base, never from user input; Overwrite: F unless the caller explicitly asks for a checked replace), remove, preview. Spec per <behavior> with literal URLs and header sets.
Upstream mapping (D-D, D-K). nextcloud-upstream.ts: mapNcFailure(result, { onExpired }) → the Nest exception per D-D (the single place that turns Nextcloud answers into browser errors; German comment: never 401/403 to the browser, why) and sendUpstreamStream(res, upstream, { extraHeaders, accept?, maxBytes? }) → maps a non-2xx or rejected content type through mapNcFailure BEFORE writing anything, else writes the allowlisted headers + extras and pipeline(upstream.body, res) (client abort destroys both sides). Spec per <behavior>.
Files service + DTOs (D-D, D-H, D-I). dto/nextcloud-files-ops.dto.ts: PathQueryDto { path? (IsString, MaxLength 4096) }, CreateFolderDto { path }, MoveDto { from, to }, PreviewQueryDto { fileId (Matches ^\d{1,20}$), v? (MaxLength 200) }. nextcloud-files.service.ts (inject NextcloudFilesAccountService, NextcloudCallGate, NEXTCLOUD_TRANSPORT): list, createFolder, move, remove, preview(res, ...); every method starts with getSession(tenantId, userId) (user id from the token) and maps every failure through mapNcFailure with onExpired = () => account.markExpired(tenantId, userId). Spec per <behavior>.
Controller (D-N). Add statics before the parameter block: @Get('files') list, @Delete('files') remove, @Post('folders') createFolder, @Post('move') @HttpCode(200) move, @Get('preview') preview (@Res()). Provide the service in the module. Extend the controller spec and the Benutzen-level it.each in module-manage-handlers.spec.ts. RLS doc unchanged unless the Gate-Schleife shows new pairs (this service has no Prisma access).
Web (L-06). Extend nextcloud-files-api.ts: types NcEntry, NcListing, NcQuota; listFolder(path), createFolder(path), moveEntry(from, to), deleteEntry(path), previewUrl(fileId, etag). Test nextcloud-files-api.test.ts per <behavior>.
Run. Biome-lint touched files. Rebuild the api (docker compose up -d --build api), wait for /health, nc-test-setup.sh. Extend e2e-lib.sh with e2e_second_user <jar>: as admin create the Tessera user nc-e2e-zweit (role ADMIN, e-mail nc-e2e-zweit@tessera.local; HTTP 409/400 for an existing user accepted), log in with the known password, and when GET /auth/me reports mustChangePassword change it once via POST /auth/change-password to a second fixed password and log in again (try the second password first on later runs); and e2e_connect_anna <jar> (DELETE connect then password connect). Write e2e/e2e-files.sh (bash, set -euo pipefail, temp dir via mktemp, trap cleanup, sources e2e-lib.sh): admin login, e2e_set_address, e2e_connect_anna; F="/Tessera-E2E-$(date +%s)"; fixtures directly in Nextcloud with anna's password against http://localhost:18080/remote.php/dav/files/anna/ (MKCOL F, PUT 'Ärger & Ölpreis 100%.txt', PUT a 1×1 PNG decoded from a base64 literal); GET files?path=/ → 200 with entries and quota; GET files?path=F → contains the exact umlaut name and the PNG with "hasPreview":true; GET preview?fileId= → 200 image/png; POST folders F/Ziel → 200/201, again → 409 nameTaken; POST move of the umlaut file into F/Ziel → 200; a second fixture with the same name in F, then move it into F/Ziel → 409 nameTaken; rename F/Ziel to F/Ziel2 → 200; move F into F/Ziel2 → 400 moveIntoItself; GET files?path=/../x → 400 invalidPath; second Tessera user (e2e_second_user, separate jar): GET status → "account":null, GET files → 409 with notConnected, while admin's GET files still answers 200 (no cross-user access); DELETE files?path=F → 200 and a PROPFIND on http://localhost:18080/remote.php/dav/trashbin/anna/trash (anna's password) lists F's name; DELETE connect; print e2e files ok. Run the <verify> command. Commit feat(nextcloud-files): Dateien auflisten, Vorschau, Ordner anlegen, umbenennen, verschieben und löschen (attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-files rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run src/lib/nextcloud-files && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh && echo "task3 ok"
<fails_when>a parser, dav, upstream, files-service or controller spec or the web api test fails, a tsc run fails, the controller carries a role decorator, or any e2e-files.sh step fails (listing with quota, umlaut name, preview, mkdir conflict, move and its conflict, rename, move into itself, invalid path, second user notConnected, delete into the Nextcloud trash)</fails_when>
Listing, previews, new folder, rename/move and delete work through the API under the caller's own account with one error contract that never answers 401/403; a second Tessera user sees no account and gets notConnected; e2e green against the test Nextcloud; commit on main, not pushed.
Measure the multi-selection ZIP first (D-K). With anna's password against http://localhost:18080: create two files in a test folder, request ?accept=zip&files=<url-encoded JSON array of one name> and list the result with unzip -l; record whether only the chosen entry is inside; implement downloadZip accordingly and note the result for the SUMMARY.
DAV transfer layer (D-C, D-J, D-K). nextcloud-dav-transfer.ts on top of davRequest (gate + credentialKey on every call): putFile, uploadStart, uploadChunk, uploadAssemble, uploadAbort, download, downloadFolderZip, downloadSelectionZip (names through validateSegment), with the D-C timeouts. Spec per <behavior> with literal URLs and header sets.
Transfer service + DTO (D-D, D-J, D-K). dto/nextcloud-files-transfer.dto.ts: StartUploadDto { path; size (IsInt, Min 0); replaceEtag? (IsString, MaxLength 200) }, CompleteUploadDto { path; size; mtime? (IsInt, Min 0, Max 4102444800); replaceEtag? }, UploadQueryDto { path; size (Type Number, IsInt); mtime?; replaceEtag? }, DownloadQueryDto { path; zip? }, ZipQueryDto { dir; name (string or string[], each ≤ 255) }. nextcloud-files-transfer.service.ts (inject NextcloudFilesAccountService, NextcloudCallGate, NEXTCLOUD_TRANSPORT): startUpload, putSingle, putChunk, completeUpload, uploadState, abortUpload, download, downloadZip; raw-body handlers read req.headers['content-length'] (411/413 before touching Nextcloud), pass req itself as the upstream body, abort upstream on req 'close' when not finished; every upstream answer goes through mapNcFailure (onExpired → markExpired) before sendUpstreamStream writes anything; the in-memory assembly map Map<string, { state, code?, message?, finishedAt }> keyed ${tenantId}:${userId}:${uploadId} with pruning after 10 min. German comment blocks: why nothing is buffered (the existing multer-based upload routes keep whole files in RAM — name multer's memory storage, not the interceptor class), why the browser chunks at 8 MiB, why assembly is asynchronous (proxy idle timeouts), why Tessera builds Content-Disposition itself and forces attachment (stored XSS through uploaded HTML/SVG), why errors are mapped before the first byte. Spec per <behavior> including the it.each error-contract matrix.
Controller (D-N). Add statics before the parameter block: @Get('download') download (@Res()), @Get('download/zip') downloadZip (@Res()), @Post('uploads') startUpload, @Put('uploads/file') putSingle (@Req() raw stream); at the end after the Task 2 parameter routes: @Put('uploads/:uploadId/chunks/:n') putChunk, @Post('uploads/:uploadId/complete') completeUpload (sets 202 when assembling), @Get('uploads/:uploadId/state') uploadState, @Delete('uploads/:uploadId') abortUpload. Provide the service in the module. Extend the controller spec (order + no manage key) and the Benutzen-level it.each in module-manage-handlers.spec.ts.
Web (L-06, D-J). Extend nextcloud-files-api.ts: downloadUrl(path, { zip? }), zipUrl(dir, names) (+ cases in its test). New apps/web/src/lib/nextcloud-files-upload.ts: uploadFile(file, targetPath, { onProgress(fraction, bytes), signal, replaceEtag?, xhrFactory?, fetchImpl?, sleep? }) per <behavior> — start, single or chunked (file.slice(i * NEXTCLOUD_FILES_CHUNK_SIZE, ...), chunk numbers 1..N, mtime = Math.floor(file.lastModified / 1000)), XHR with withCredentials and upload.onprogress, retries only for network errors, 500, 502 nextcloudUnavailable/nextcloudError and 504 (waits 1 s, 3 s, 9 s; never for nextcloudLocked or any 4xx), assembly polling every 2 s up to 30 min, cleanup DELETE on abort and on final failure, rejection with NextcloudFilesRequestError (code 'aborted' for user aborts). Test nextcloud-files-upload.test.ts per <behavior>.
Run. Biome-lint touched files. Rebuild the api (docker compose up -d --build api), wait for /health, nc-test-setup.sh. Write e2e/e2e-transfer.sh (bash, set -euo pipefail, temp dir via mktemp, trap cleanup, sources e2e-lib.sh): admin login, e2e_set_address, e2e_connect_anna; F="/Tessera-E2E-T-$(date +%s)" created via POST folders; small upload of 'Ärger & Ölpreis 100%.txt' (start single + PUT uploads/file) → listing of F contains that exact name; the same name again → POST uploads 409 nameTaken with existing; 30 MB random file: POST uploads → chunked, split -b 8388608 and PUT every chunk through http://localhost:3000/api-proxy/modules/nextcloud-files/... (the Next.js proxy), complete (poll state if 202), download through the proxy and cmp identical, Range bytes=0-99 → 206 with 100 bytes, response headers contain content-disposition: attachment, x-content-type-options: nosniff and no set-cookie; a 9 MB chunk → 413; a PUT without content-length (-H 'Transfer-Encoding: chunked') → 411; download of a missing path → 404 JSON with notFound (no 401/403); folder ZIP of F starts with bytes PK; multi-selection ZIP per the measured variant; download/zip?dir=F&name=.. → 400 invalidPath; DELETE files?path=F; DELETE connect; print e2e transfer ok. Run the <verify> command. Commit feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP (attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-files module-manage-handlers && pnpm --filter @tessera/web exec vitest run src/lib/nextcloud-files && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && test -z "$(grep -rn 'FileInterceptor' apps/api/src/nextcloud-files --include=*.ts)" && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh && echo "task4 ok"
<fails_when>a dav-transfer, transfer-service (incl. the error-contract matrix), controller spec or a web lib test fails, a tsc run fails, the module carries a role decorator or a multer interceptor, or any e2e-transfer.sh step fails (umlaut upload, upload conflict, 30 MB chunked upload over the Next.js proxy with identical download and Range, header checks, 413, 411, 404 mapping, folder and selection ZIP, invalid ZIP name)</fails_when>
Uploads (single and chunked with async assembly) and downloads (file, folder ZIP, selection ZIP or measured fallback) stream through the API without buffering, every upstream failure is mapped before the first byte and never reaches the browser as 401/403, a 30 MB file travels through the Next.js proxy in 8 MiB chunks and comes back byte-identical; the browser uploader is tested; commit on main, not pushed.
Pure helpers (tests first). apps/web/src/components/nextcloud-files/: file-types.ts (extension/mime map → { family, code }, codes max 4 chars; uppercase short codes are the requested exception to the no-caps rule), file-format.ts (formatSize(bytes, locale) base 1024 with B/KB/MB/GB/TB and Intl.NumberFormat max one decimal; formatRelative(date, now, locale) with Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }); formatAbsolute; quotaText), selection.ts (pure reducer), paths.ts, drop-entries.ts (collects File objects from a drop: plain files, and dropped folders via webkitGetAsEntry recursion into { file, relativeDir } items — at most 2000 files and depth 20, beyond that a clear message; the queue creates missing sub-folders with createFolder and treats nameTaken on a folder as "already there"), use-transfers.ts (React hook: queue, concurrency 2, per-item AbortController, statuses queued/running/assembling/done/error/conflict/skipped, retry, conflict resolution per <behavior>, quota pre-check, callback when an upload into a folder finishes). Tests per <behavior> (the contrast test reads the CSS file with node:fs relative to the repo).
Components (L-06, L-09). Under apps/web/src/app/(portal)/modules/nextcloud-files/components/:
TypeTile.tsx: rounded square (rounded-md), sizes 'sm' 32 px (list) and 'lg' 72 px (grid), classes from literal maps per family (bg-ft-pdf-bg text-ft-pdf-fg…), short code centered in semibold 10 px / 15 px with slight letter spacing; folder = folder glyph (D-A path) intext-ft-folder-fgonbg-ft-folder-bg; for hasPreview images in the list the tile shows the 32 px thumbnail (object-cover,loading="lazy",decoding="async", onError back to the code).Breadcrumb.tsx: "Alle Dateien" then the segments; every crumb a button except the last (aria-current="page"); long paths collapse the middle into "…" with a menu.Toolbar.tsx: breadcrumb left; right "Neuer Ordner", "Hochladen" (hidden<input type="file" multiple>), view toggle (two icon buttons with aria-pressed, labels "Liste"/"Raster", hidden belowsmwhere the list is always used), sort select (Name, Größe, Geändert; direction toggle).SelectionBar.tsx: replaces the toolbar while something is selected: "{count} ausgewählt", Herunterladen, Verschieben, Löschen, "Auswahl aufheben" (X with aria-label); swap with a 150 ms fade only undermotion-safe:.FileList.tsx: dense table (role="grid"with rowsrole="row", header row sticky inside the card): checkbox column (visible on hover/focus/selected, always onpointer-coarse:), TypeTile sm, name (folder names are buttons, file names are download anchors), Größe right-alignedtabular-nums, Geändert (relative,titleabsolute; hidden belowsm), ⋯ button (always visible on touch, subdued otherwise); row height ~40 px; hoverbg-accent/60, selectedbg-primary/12, focus-visible inset ring; roving tabindex; right click opens EntryMenu at the pointer (preventDefault). Header "Name" checkbox selects all.FileGrid.tsx: responsive grid (grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]), each item a 4:3 surface (bg-well, rounded) with the preview image (object-cover) or a TypeTile lg, name below (two-line clamp) and size muted; selected ringring-2 ring-primary-strongplus a check badge; same menu/keyboard model.EntryMenu.tsx: items Öffnen (folders) or Herunterladen (files), "Als ZIP herunterladen" (folders), Umbenennen, Verschieben, "In Nextcloud öffnen" (anchor to${serverUrl}/index.php/f/${fileId}, target _blank, rel noopener noreferrer), Löschen (destructive colour, last); built from an action list so Etappe 2 can add Teilen.NameDialog.tsx(new folder / rename; validation from paths.ts; API errors inline),MoveDialog.tsx(own folder navigation with breadcrumb using listFolder filtered to folders, "Hierher verschieben", disabled rules per<behavior>),DeleteDialog.tsx("„{name}“ löschen?" / "{count} Elemente löschen?", text "Die Einträge kommen in den Papierkorb Ihrer Nextcloud und lassen sich dort wiederherstellen.", buttons "In den Papierkorb verschieben" / "Abbrechen"); allrole="dialog"witharia-modal, focus trap, Escape closes, focus returns.DropOverlay.tsx: absolute over the file card,pointer-events-none,.nc-drop-stripestint, centered large label "In „{target}“ ablegen" in a card-coloured pill; fades in only undermotion-safe:.TransferBar.tsx:sticky bottom-0at the bottom of the page column (full width on mobile), card surface with top border and shadow; header "Übertragungen" with "{running} laufen" and overall progress, collapse/expand button, "Erledigte entfernen"; rows per<behavior>with a 4 px progress bar inbg-primaryonbg-muted, done state with a check intext-status-ok-fg, error text intext-status-down-fg; appears with a short slide-up only undermotion-safe:when the first transfer starts.QuotaMeter.tsx: footer of the file card: "{count} Elemente" left; right a 120 px bar plus the quota text (unlimited: text only); the bar turnsbg-status-warnabove 90 %.FileBrowser.tsx: state container (path mirrored withwindow.history.replaceStateto?path=, initial path read fromwindow.location.searchon mount; listing; sort; view from localStorage; selection; focus index; dialogs; error state "Nextcloud ist gerade nicht erreichbar." + "Erneut versuchen"; notFound on a folder → back to "Alle Dateien" with "Der Ordner existiert nicht mehr."; nextcloudLocked → its text with the minutes; 5 skeleton rows withmotion-safe:animate-pulsewhile loading; truncated hint "Es werden die ersten 5000 Einträge angezeigt."), keyboard map of L-09 bound on the browser container (ignored while focus is in an input or a dialog), window-level drag listeners with a depth counter that only react todataTransfer.typescontaining 'Files' and always preventDefault on dragover/drop to keep the browser from opening files, status line after actions ("3 Elemente in den Papierkorb verschoben."), multi-download per the measured D-K result from Task 4.page.tsx: mountFileBrowserin thenextcloud-files-browsersection withonExpired(reload status) and the server URL for "In Nextcloud öffnen". Texts per L-09: Sie, German, instruction-style empty/error states, no ALL-CAPS labels, no arrow characters on buttons, no middle-dot meta strings, no decorative numbering, no tenant/licence words. Messages for every new text innextcloudFiles.*de + en (ICU plurals for counts), umlaut guard green. Tests per<behavior>:FileBrowser.test.tsx,TransferBar.test.tsx, page-test additions (connected state renders the browser; expired switches back).
Run. Biome-lint touched files. Rebuild docker compose up -d --build web, nc-test-setup.sh, then a dark-mode Playwright smoke at http://localhost:3000 as admin with anna connected: list, grid, three selected, the drop overlay (dragenter dispatched with a DataTransfer carrying a File via the evaluate tool, hovering a folder row), a 30 MB upload through the file chooser with the Übertragungsleiste, the move dialog; save .playwright-mcp/nextcloud-files/t5-dark-list.png, t5-dark-grid.png, t5-dark-drop.png, t5-dark-transfers.png; look at them against L-09 and fix what is off. Run the <verify> command. Commit feat(nextcloud-files): Dateiansicht mit Ablage-Kacheln, Ziehen und Ablegen und Übertragungsleiste (attribution line). Do not push.
pnpm --filter @tessera/web exec vitest run modules/nextcloud-files components/nextcloud-files src/lib/nextcloud-files src/messages && 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 a=w(de.nextcloudFiles,"n",{}),b=w(en.nextcloudFiles,"n",{});if(Object.keys(a).length===0||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|lizenz|licens|→|·/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && test -f .playwright-mcp/nextcloud-files/t5-dark-drop.png && test -f .playwright-mcp/nextcloud-files/t5-dark-transfers.png && echo "task5 ok"
<fails_when>a helper, contrast, FileBrowser, TransferBar, page, web lib or messages test fails, the web tsc run fails, de/en keys of nextcloudFiles differ or a text contains tenant/licence wording, an arrow or a middle dot, or the dark smoke screenshots of the drop overlay and the Übertragungsleiste are missing</fails_when>
Connected users work with their files in a calm, dense Mosaik file view with family type tiles (contrast-tested in both modes), real previews, list/grid remembered per user, selection bar, keyboard and row/right-click menus, dialogs and quota; drag & drop shows the striped target overlay incl. folder rows as targets; the Übertragungsleiste shows progress, cancel, errors and conflict choices; dark smoke reviewed; commit on main, not pushed.
Registration check (D-A). Confirm the registrations from Task 1 are intact (module-loader, module-identity folder, module-tile glyph, nav-store title, layouts test, seed, app.module) and that WIDGET_MODULE_SLUGS in packages/shared/src/index.ts has no nextcloud-files entry (L-08); fix anything missing.
Changelog + guides (L-04, L-11). CHANGELOG.md under "## Unveröffentlicht" → "### Neu" add user-facing German bullets in simple words: new module „Dateien“ (group Infrastruktur) for the company Nextcloud, activation in the Marktplatz plus Freigabe; Administratoren/Verwalten set the address and can check it; everyone connects their own account — Tessera keeps only a revocable app password, not the password; two-factor accounts connect in the browser; browse, list/grid, previews, upload by dragging or choosing (also large files, progress in the Übertragungsleiste, cancel), download (folders as ZIP), new folder, rename, move, delete into the Nextcloud trash, multi-selection and keyboard; no silent overwriting; Abmelden removes the access in Nextcloud. docs/anleitung-anwender.md: section "### Dateien (Nextcloud)" after "### Nextcloud-Status" plus the table-of-contents entry (connect by password or in the browser, what Tessera stores, working with files, drag & drop and the Übertragungsleiste, name conflicts, keyboard shortcuts table, storage display, trash, Abmelden, what "Verbindung abgelaufen" means, what to do when Nextcloud pauses requests). docs/anleitung-administration.md: subsection "### Dateien: Nextcloud anbinden" after "### Nextcloud-Status: Clouds eintragen" (address as users open it in the browser, https, trusted_domains, brute-force whitelist for the Tessera server IP with the occ command and why — Tessera pauses all requests to the Nextcloud for up to 15 minutes when Nextcloud answers "zu viele Anfragen", address change → everyone reconnects, rights Benutzen/Verwalten, deleting a Tessera user leaves the app password in the user's Nextcloud device list — remove it there). docs/anleitung-betrieb.md: in "## 3. Konfiguration" a subsection "### Dateien (Nextcloud)" (outbound access from the api container to the Nextcloud, internal CA via NODE_EXTRA_CA_CERTS on the api container, Nginx Proxy Manager for the Tessera address: client_max_body_size at least 10m and read/send timeouts of at least 120 s, upload chunks 8 MiB, unfinished uploads are removed by Nextcloud after 24 h, open browser logins and upload states live in the api process memory — a restart asks users to start again) and one row in "### Fehlerbilder" (upload stops at the first 8 MB chunk → proxy body limit). No tenant or licensing wording.
Final gates + browser proof (L-10). Recount the RLS doc with the Gate-Schleife (only if pairs changed). Full pnpm --filter @tessera/api test and pnpm --filter @tessera/web test, both tsc, biome lint on every file touched by the six tasks. Rebuild docker compose up -d --build api web, wait for /health, check the seed log line and the mapped /modules/nextcloud-files/... routes (statics before parameter routes), rerun nc-test-setup.sh, e2e-settings.sh, e2e-connect.sh, e2e-files.sh, e2e-transfer.sh. Playwright MCP at http://localhost:3000 as admin, connect anna by password, build a demo folder via the UI (folders "Projekte" and "Rechnungen", files of several families incl. a JPG/PNG with preview, a PDF, an XLSX, a ZIP, a 30 MB file uploaded through the file chooser), then capture in DARK and in LIGHT mode (theme button): t6-<mode>-connect.png (after Abmelden, with server identity), t6-<mode>-list.png, t6-<mode>-grid.png, t6-<mode>-selection.png (three selected), t6-<mode>-drop-overlay.png (dragenter dispatched with a DataTransfer carrying a File, hovering a folder row), t6-<mode>-transfers.png (running or finished 30 MB upload plus a nameTaken row), t6-<mode>-move-dialog.png, t6-<mode>-empty.png, and once t6-dark-mobile.png at 390×844 — all under .playwright-mcp/nextcloud-files/. Exercise in the browser: rename, move into "Rechnungen", delete with confirmation (then visible in the Nextcloud trash), download a file and a folder ZIP, keyboard Enter/Backspace/F2/Entf/Ctrl+A. Look at every screenshot against L-09 (calm list, stripes only during drag, readable type tiles in both modes, no caps labels, no middle dots) and fix what is off before committing. Commit feat(nextcloud-files): Nextcloud-Kennung auf der Anmeldeseite, Anleitungen und Changelog (attribution line). Do not push.
pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && 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 a=w(de.nextcloudFiles,"n",{}),b=w(en.nextcloudFiles,"n",{});if(Object.keys(a).length===0||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|lizenz|licens|→|·/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && test -z "$(awk '/export const WIDGET_MODULE_SLUGS/,/^};/' packages/shared/src/index.ts | grep -v '^\s*//' | grep -F "nextcloud-files")" && grep -q "Nextcloud" CHANGELOG.md && grep -q "^### Dateien (Nextcloud)" docs/anleitung-anwender.md && grep -q "^### Dateien: Nextcloud anbinden" docs/anleitung-administration.md && grep -q "^### Dateien (Nextcloud)" docs/anleitung-betrieb.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 files module seeded in registry" && E=.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e && bash $E/nc-test-setup.sh && bash $E/e2e-settings.sh && bash $E/e2e-connect.sh && bash $E/e2e-files.sh && bash $E/e2e-transfer.sh && test "$(ls .playwright-mcp/nextcloud-files/t6-dark-.png 2>/dev/null | wc -l)" -ge 8 && test "$(ls .playwright-mcp/nextcloud-files/t6-light-.png 2>/dev/null | wc -l)" -ge 8 && echo "final gates ok"
<fails_when>any api or web test (incl. contrast, rls, umlaut guard, module-layouts), a tsc run, the role-decorator gate, the de/en parity or wording check, a dashboard-widget entry for the module, a changelog/guide grep, the running-container or seed checks, any of the four e2e scripts on the rebuilt stack, or fewer than eight dark and eight light screenshots</fails_when>
The connect screen shows the Nextcloud identity; registrations confirmed and no dashboard widget; changelog and three guides updated; full suites, tsc, biome and all e2e scripts green on the rebuilt stack; screenshots in dark and light reviewed against L-09; commit on main, not pushed.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
browser → API (/modules/nextcloud-files/*) |
untrusted caller; tenant and user only from the validated session; rights via ModuleGuard; paths, names, ids, sizes untrusted |
| API → Nextcloud (admin-chosen, possibly internal address) | outbound HTTP(S) with the user's app password; every answer untrusted (headers, XML, JSON, file bytes, URLs) |
| user password in transit | only in memory of one API request, sent once to getapppassword |
DB at rest (NextcloudFilesAccount) |
encrypted app passwords per user |
| Nextcloud content → browser | file names, previews, downloads (possibly HTML/SVG) rendered or saved by the browser |
| shared server IP ↔ Nextcloud brute-force protection | failures of one user can lock out everyone |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-mzu-01 | Tampering / SSRF | nextcloud-http, auth client, dav, server info | high | mitigate | one admin-set base URL per organisation, fixed path prefixes, segment-wise validation and encoding, no redirects followed (3xx = error), poll.endpoint/login origin/capability URLs never called, ids and uploadIds regex-checked, Destination built from the base; specs assert literal URLs and that evil.example is never requested |
| T-mzu-02 | Information Disclosure | user password, app password | high | mitigate | password only in request memory, never stored/logged/returned; app password AES-256-GCM via CryptoService; responses carry no secret (spec with JSON.stringify); request log without query/body; decrypt failure loud (accountBroken) |
| T-mzu-03 | Information Disclosure | app password sent to a wrong host | high | mitigate | account stores its issuing baseUrl; requests, revoke and session use only that base; address change expires all accounts and is confirmed in the UI; spec: mismatch → no transport call |
| T-mzu-04 | Denial of Service | Nextcloud brute-force lock of the shared IP | high | mitigate | Tessera limits 3 failures/user/15 min and 8/server/30 min below Nextcloud's 10/30 min; call gate pauses ALL calls to an origin after any 429 (Retry-After honoured, capped); a credential is dead after its first 401 and its in-flight calls are aborted; 2FA users steered to the browser path upfront; admin docs for the whitelist |
| T-mzu-05 | Elevation of Privilege | settings routes | high | mitigate | @ModuleManage('nextcloud-files') on GET/PUT settings and POST settings/test, no role decorator; controller + module-manage-handlers specs; verify grep |
| T-mzu-06 | Information Disclosure / EoP | other users' accounts and files | high | mitigate | user id only from the token; every account access via forTenant(tenant, user); RLS tenant AND user on NextcloudFilesAccount; file operations only with the caller's own app password against /dav/files/<own uid>/ and /dav/uploads/<own uid>/; foreign flowIds → 404; spec + e2e: a second Tessera user gets notConnected |
| T-mzu-07 | Tampering | path traversal / foreign paths | medium | mitigate | parseUserPath/validateSegment reject '.', '..', empty, slash/backslash/control characters, length caps — also for ZIP selection names; paths never in the Tessera URL path; Nextcloud enforces account scope additionally |
| T-mzu-08 | Tampering (stored XSS) | downloads, previews, logo | high | mitigate | downloads always attachment with Tessera-built Content-Disposition, nosniff, CSP sandbox, header allowlist without set-cookie; previews only image/* with size cap; logo by magic bytes, SVG only with CSP sandbox; names rendered as React text |
| T-mzu-09 | Denial of Service | upload memory / proxy | medium | mitigate | streaming without buffering, content-length required, 8 MiB chunk cap (413), total size cap, abort propagation, async assembly with bounded in-memory state, concurrency 2 in the browser |
| T-mzu-10 | Tampering | silent overwrite | medium | mitigate | If-None-Match: * on new files, Overwrite: F on MOVE and assembly, replace only with a matching etag (If-Match / re-checked etag), conflict choices in the UI |
| T-mzu-11 | Spoofing | Login Flow hijack | medium | mitigate | poll token server-only, flow bound to tenant+user, one flow per user, 20-min TTL, 200 flows max, starts rate-limited; the browser link points only to the configured host |
| T-mzu-12 | Elevation of Privilege | route shadowing | medium | mitigate | static routes before parameter routes, declaration-order assertion in the controller spec |
| T-mzu-13 | Information Disclosure | TLS | medium | mitigate | certificates always verified, no off switch; internal CA via NODE_EXTRA_CA_CERTS (operations guide) |
| T-mzu-14 | Information Disclosure / Spoofing | orphaned app passwords | medium | mitigate | fresh app passwords that are not stored are revoked at once; reconnect revokes the previous same-host credential first; disconnect revokes; residual: deleting a Tessera user leaves the token in Nextcloud (documented) |
| T-mzu-15 | Information Disclosure | error passthrough on streams | medium | mitigate | every non-2xx upstream mapped by mapNcFailure before any header or byte is written; it.each matrix asserts no 401/403 and no piped bytes |
| T-mzu-16 | Repudiation / Residual | admin-chosen internal address probing | low | accept | only Verwalten can set the address; responses reach the browser only as parsed fields after a successful Nextcloud login (same accepted window as Nextcloud-Status) |
| T-mzu-SC | Tampering | npm/pip/cargo installs | low | accept | no new packages (undici, fast-xml-parser, class-validator already present) |
| </threat_model> |
| Source item | Covered by |
|---|---|
| GOAL: module Nextcloud-Dateien Etappe 1, own account per user, file work inside Tessera | Tasks 1–6 |
| L-01 one Nextcloud, address by admin/Verwalten, internal allowed, SSRF containment, address change expires | Task 1 (settings, transport), Tasks 3–4 (dav), Task 6 (server info) |
| L-02 password → getapppassword → only encrypted app password, password discarded | Task 2 |
| L-03 2FA via Login Flow v2, real-click link, fixed poll path, desktop opener | Task 2 (API + ConnectPanel + Playwright with TOTP) |
| L-04 own failure limit, never repeat 429, clear message, whitelist docs | Task 1 (call gate, settings hint), Task 2 (guard, UI), Task 6 (admin + operations guides) |
| L-05 Abmelden revokes + deletes | Task 2 (+ e2e token check, reconnect hygiene) |
| L-06 browse/breadcrumb, list/grid per user, previews, DnD + chooser + large chunked uploads, download/ZIP, mkdir, rename, move picker, delete to trash with confirm, multi-select, quota, overwrite protection | Task 3 (file API), Task 4 (transfer API + uploader), Task 5 (UI) |
| L-07 rights Benutzen vs. Verwalten | Tasks 1–4, 6 controller + manage-handlers specs, web gating |
| L-08 no dashboard widget; Etappe 2 open | Task 6 gate; generic davRequest/ocsRequest, permission letters, menu action list |
| L-09 design Mosaik-Ablage incl. tokens, stripes, transfer bar, selection bar, keyboard, connect screen, texts, mobile | Task 5 (+ contrast test, dark smoke), Task 6 (server identity, light + dark screenshots) |
| L-10 browser check light + dark against real Nextcloud, both login paths, >10 MiB upload, download, rename/move/delete | Task 2 (login paths), Tasks 3–4 (e2e), Tasks 5–6 (Playwright) |
| L-11 project rules (route order, no Object.hasOwn, DB via IP, rebuild, no .env, de/en parity, umlaut guard, changelog, guides) | Tasks 1–6 |
| Checker W1 shared failure guard (401 short-circuit per account, 429 pause for all calls) | Task 1 (call gate in ncRequest + specs), Task 2 (getSession + spec), Task 3 (mapNcFailure onExpired) |
| Checker W2 orphaned app passwords (revoke unstored fresh one, revoke old on reconnect) | Task 2 (D-P + specs + e2e single token) |
| Checker W3 transfer routes map non-2xx before piping, no 401/403 | Task 4 (it.each matrix), Task 3 (sendUpstreamStream spec) |
| Checker W4 cross-user isolation truth + RLS artifact + second-user check | Task 1 (migration policy), Task 2 (getSession spec), Task 3 (e2e second user) |
| RESEARCH: 401 ambiguity → credentialsOrTwoFactor | Task 2 |
| RESEARCH: cloud/user uid for DAV paths | Task 2 |
| RESEARCH: PROPFIND quirks (multiple propstat, lower-case hex, -3 quota, string parsing) | Task 3 |
| RESEARCH: path encoding per segment | Tasks 1, 3, 4 |
| RESEARCH: Overwrite F / If-None-Match / If-Match, 412 handling | Tasks 3–4 |
| RESEARCH: chunked upload v2 (5-digit chunk names, OC-Total-Length, .file MOVE, cleanup on 412) | Task 4 |
| RESEARCH: Next.js 10 MiB clone limit + 30 s proxy timeout | Task 4 (8 MiB chunks, async assembly), Task 6 (operations guide) |
| RESEARCH: preview via fileId, 256 px, image/* only | Task 3 |
| RESEARCH: header allowlist, no cookies, attachment + nosniff | Tasks 3–4 |
| RESEARCH: trusted_domains 400 message, maintenance 503 | Tasks 1, 3 |
| RESEARCH: TLS verified, NODE_EXTRA_CA_CERTS | Task 1 (transport), Task 6 (operations guide) |
| RESEARCH open question 3 (folder ZIP in stage 1) | decided yes (Task 4) |
| RESEARCH open question 4 (Tessera user deletion leaves app password) | documented in the admin guide (Task 6) |
| RESEARCH Etappe 2 (shares, sharees, SEARCH) | excluded by the user; architecture hooks only |
<success_criteria>
- Two new tables with RLS (tenant; tenant + user); migration applied locally without drift.
- All new specs and tests pass; full api + web suites, both tsc runs and biome on touched files are green.
- Against the local
tessera-nc-testNextcloud: address check (Task 1), password connect (anna) and Login Flow v2 with TOTP (zoe) in the browser with revoke on Abmelden and no orphaned tokens (Task 2), file operations and the second-user isolation (Task 3), 30 MB chunked upload through the Next.js proxy with identical download and mapped errors (Task 4). - The call gate stops all traffic to a Nextcloud after a 429 and all traffic of a credential after its first 401 (specs).
- The file view matches L-09 in dark and light mode (screenshots reviewed), works on a 390 px viewport, respects reduced motion and the keyboard map.
- CHANGELOG, Anwender-, Administrations- and Betriebsanleitung describe the module; six commits on main, nothing pushed. </success_criteria>