---
phase: quick-261009-dkv
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261009-dkv
description: "Modul Dateien (nextcloud-files) Etappe 2a: Teilen von Dateien und Ordnern ueber Nextcloud (Personen, Gruppen, oeffentliche Links nach der Nextcloud-Richtlinie), Ansichten Von mir geteilt / Mit mir geteilt, Freigabe-Kennzeichen in der Dateiliste, Modulversion bleibt 1.0.0 (unveroeffentlichter Eintrag ergaenzt), Modul-Changelog, CHANGELOG und alle vier Anleitungen"
date: 2026-10-09
files_modified:
# Task 1 — tracer: share with people and groups end to end, share indicator
- apps/api/src/nextcloud-files/nextcloud-shares.ts
- apps/api/src/nextcloud-files/nextcloud-shares.spec.ts
- apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts
- apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts
- apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts
- apps/api/src/nextcloud-files/nextcloud-files.types.ts
- apps/api/src/nextcloud-files/nextcloud-login-guard.ts
- apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts
- apps/api/src/nextcloud-files/nextcloud-propfind.ts
- apps/api/src/nextcloud-files/nextcloud-propfind.spec.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.module.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/nextcloud-files-api.ts
- apps/web/src/lib/nextcloud-files-api.test.ts
- apps/web/src/components/nextcloud-files/share-policy.ts
- apps/web/src/components/nextcloud-files/share-policy.test.ts
- apps/web/src/components/nextcloud-files/error-text.ts
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareIndicator.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- .planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh
# Task 2 — links under the Nextcloud policy, the two share views, incoming/pending shares
- apps/web/src/app/(portal)/modules/nextcloud-files/components/LinkShareForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.test.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx
- apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx
# Task 3 — module version, changelogs, guides, full gates, browser proof
- apps/api/src/nextcloud-files/nextcloud-files.changelog.ts
- apps/api/src/module-registry/module-changelog.spec.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
- docs/anleitung-betrieb.md
- docs/anleitung-entwicklung.md
autonomous: true
requirements: [QUICK-261009-dkv]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "A connected user opens „Teilen“ from the row menu (or the share indicator) of a file or folder that Nextcloud marks as shareable (permission letter R), finds colleagues AND groups through Nextcloud's own sharee search, adds them with „Ansehen“ or „Bearbeiten“, changes the permission and removes the share again; the recipient sees exactly these shares in Nextcloud (live e2e with the test users ben and the group tessera-team)"
- "A user creates public links for files and folders („Ansehen“, „Bearbeiten“, for folders also „Nur hochladen“), copies the link URL that Nextcloud returned, changes and deletes links; when Nextcloud enforces a link password the field is required and „Passwort erzeugen“ fills it, otherwise password protection is an optional switch; when Nextcloud enforces an expiry date the date is required, prefilled with today plus the server's days and limited to that maximum, otherwise it stays optional and an emptied field creates the link without expiry (expireDate empty string) — all derived from the user's own /ocs/v2.php/cloud/capabilities, read fresh on every policy read and every write"
- "The API re-checks password and expiry rules from the capabilities before it calls Nextcloud (a missing enforced password or expiry never reaches Nextcloud), maps every Nextcloud refusal to a German error code with Nextcloud's own message as a second line, never answers 401 or 403, marks the connection expired on a Nextcloud 401 and keeps the Etappe-1 call gate on 429; a link password never appears in any API response, error body or api log line"
- "The views „Von mir geteilt“ and „Mit mir geteilt“ (tabs for every connected user) list the user's own user, group and link shares and the shares other people made with them, including pending shares with „Annehmen“ and „Ablehnen“; every item can be opened in the file view, own shares are changed or removed from the view, incoming shares can be left; email, federated and other share types appear only as a count with a pointer to Nextcloud"
- "Shared entries carry a share indicator in list and grid view (outgoing from oc:share-types, incoming from the permission letters); the outgoing indicator opens the share dialog"
- "Tessera lets one user create at most 15 shares within 10 minutes (the 16th gets 429 tooManyShares without any Nextcloud call), so Nextcloud's own limit of 20 per 10 minutes, whose 429 would pause the whole Nextcloud for all users, is never reached through Tessera; a share for a recipient who already has one is refused with shareAlreadyExists instead of re-sending Nextcloud's notification"
- "The module still shows version 1.0.0; the unreleased 1.0.0 module-changelog entry gained the three sharing items; CHANGELOG.md and the Anwender-, Administrations-, Betriebs- and Entwicklungsanleitung describe sharing; screenshots in dark mode (and some in light mode) prove dialog, link form under an enforced password, indicator and both views against the real test Nextcloud"
artifacts:
- path: "apps/api/src/nextcloud-files/nextcloud-shares.ts"
provides: "share-specific OCS transport on top of ncRequest (JSON body, query, reads the error body), parsers for shares, sharees and the sharing policy, permission mapping"
exports: ["ocsShareRequest", "parseShare", "parseShareList", "parseSharees", "parseSharePolicy", "permissionsFor", "accessOf"]
- path: "apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts"
provides: "policy, shares of a path, sharee search, create/update/remove/accept, mine/received, pre-validation from capabilities, duplicate check, create limiter, error matrix"
exports: ["NextcloudFilesSharesService"]
- path: "apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts"
provides: "strict DTOs: kind and access enums (no raw bitmasks), strict date, length caps"
- path: "apps/web/src/components/nextcloud-files/share-policy.ts"
provides: "pure helpers: access options, password mode, expiry rule, addDays, password generator, update diff"
- path: "apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx"
provides: "share dialog on the module Dialog: people and groups section, link section, errors with Nextcloud message"
- path: "apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx"
provides: "views Von mir geteilt / Mit mir geteilt incl. pending shares"
- path: "apps/api/src/nextcloud-files/nextcloud-files.changelog.ts"
provides: "module changelog: the single unreleased 1.0.0 release extended by three sharing items"
contains: "Öffentliche Links"
- path: ".planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh"
provides: "live e2e against tessera-nc-test: sections people, links, received, version"
key_links:
- from: "apps/api/src/nextcloud-files/nextcloud-shares.ts ocsShareRequest"
to: "ncRequest (Etappe-1 transport with call gate)"
via: "fixed prefix /ocs/v2.php/, segment-encoded ids, session authorization and credentialKey"
pattern: "prefix: '/ocs/v2\\.php/'"
- from: "apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts create"
to: "NextcloudLoginGuard.checkShareCreate"
via: "counted right before the POST, after all pre-validation"
pattern: "checkShareCreate\\("
- from: "apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts"
to: "mapNcFailure with onExpired -> account.markExpired"
via: "transport failures, 401, 429 and 5xx keep the Etappe-1 error contract"
pattern: "mapNcFailure\\("
- from: "apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts"
to: "GET /ocs/v2.php/cloud/capabilities with the caller's own app password"
via: "parseSharePolicy on every policy read and every write, no cache across calls"
pattern: "'cloud', 'capabilities'"
- from: "apps/api/src/nextcloud-files/nextcloud-propfind.ts buildEntry"
to: "oc:share-types (already requested by PROPFIND_BODY)"
via: "shareTypes number array on every entry"
pattern: "shareTypes"
- from: "apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx menuActions"
to: "ShareDialog"
via: "DialogState kind share, only when the entry permissions contain R"
pattern: "kind: 'share'"
- from: "apps/api/src/nextcloud-files/nextcloud-files.seed.ts"
to: "NEXTCLOUD_FILES_CHANGELOG"
via: "latestVersion still yields 1.0.0 (no version string in the seed)"
pattern: "latestVersion\\(NEXTCLOUD_FILES_CHANGELOG\\)"
---
Module „Dateien“ (slug `nextcloud-files`), Etappe 2a: users share files and folders of their OWN Nextcloud account from inside Tessera — with colleagues and groups (Nextcloud sharee search) and as public links — always under the caller's own app password, see existing shares in two views and in the file list, change and remove them. Nextcloud's sharing policy (from the capabilities of the calling user) decides what is required. Search is NOT part of this task.
Purpose: Etappe 1 (quick 261008-mzu) made the files usable inside Tessera; without sharing users still switch to Nextcloud for the most common collaboration step.
Three tasks, executed strictly in order. Task 1 is the tracer (one complete path: share a folder with a colleague, through every layer, proven live). Task 2 expands to links under the policy plus both views and incoming shares. Task 3 bumps the module version, writes changelogs and all four guides, runs every gate on the rebuilt stack and proves the UI in the browser.
Locked decisions — from CONTEXT.md (user, NON-NEGOTIABLE):
- D-01 Share targets: BOTH colleagues — Nextcloud users AND groups, found via Nextcloud's sharee search — and public links (to hand to customers).
- D-02 Link protection follows the Nextcloud server policy, no Tessera-invented rules. Tessera reads the policy from the capabilities (password enforced, expiry enabled/enforced/days, etc.), reflects it in the form (required fields, defaults, maximums) and shows Nextcloud's policy errors understandably in German. Optional fields (expiry when not enforced) stay freely settable. The user expects the company Nextcloud to enforce a link password but no expiry — both variants must work.
- D-03 Permissions: simple choice „Ansehen“ (read) or „Bearbeiten“ (edit); for folders additionally „Nur hochladen“ (file drop / Briefkasten, mainly for links). No per-bit checkboxes.
- D-04 Overview: separate views „Von mir geteilt“ and „Mit mir geteilt“ plus a share indicator on every shared entry of the file list; shares can be opened from both places to change or remove them.
Locked decisions — orchestrator answers to the research's open questions (NON-NEGOTIABLE):
- D-05 Module version (CORRECTED by orchestrator after planning): follow the guide rule „Höchstens ein Sprung je Modul zwischen zwei Tessera-Freigaben“ (docs/anleitung-entwicklung.md, around line 319). The module has never been in a Tessera release (last release v1.10.1 on 2026-10-06; the 1.0.0 entry is dated 2026-10-08, so it is unreleased), therefore NO new version: the version stays 1.0.0 and the three sharing items are appended to the existing 1.0.0 release (version and date unchanged; a brand-new module stays 1.0.0 even with added Neu-items). Root `CHANGELOG.md` gets the bullets under „## Unveröffentlicht“, without a „Modulversion …“ suffix. No deviation from the guide.
- D-06 Only user (0), group (1) and public link (3) shares are shown and creatable; email (4), federated and all other types are filtered out (count only).
- D-07 Link password UX: policy enforces a password → required field with a „Passwort erzeugen“ button; otherwise an optional toggle.
- D-08 Expiry UX: enforced → required, prefilled with the server default and limited to the server maximum; otherwise optional; to create a link without expiry send `expireDate: ""`.
- D-09 Tessera-side limiter for share creation: 15 per 10 minutes per Tessera user, so Nextcloud's 429 never pauses the whole origin.
- D-10 Documentation is mandatory: Anwender (Teilen), Administration (Nextcloud sharing policy); per the user's standing rule also Betrieb, Entwicklung, CHANGELOG and the module changelog. App texts German with „Sie“, real umlauts.
Binding from Etappe 1 (261008-mzu, unchanged): D-C transport rules (fixed prefixes, segment encoding, no redirects, no cookies, never call a URL from a Nextcloud answer), D-D error contract (never 401/403 to the browser, `{ code, message }` with German text), D-H path rules (`parseUserPath`), D-N route rights and order (class `@UseModule`, statics before `:param`, controller spec checks the order), D-O call gate (429 pauses the origin, first 401 kills the credential), tenant and user only from the token, L-09 design rules (Mosaik tokens, no ALL-CAPS labels, no middle-dot meta strings, no arrow buttons, calm UI), L-11 project rules (de/en key parity, umlaut guard, no `.env` reads, rebuild with `--build`).
Claude's discretion (decided here, apply as written):
- D-11 API surface (all Benutzen level, `@Controller('modules/nextcloud-files')`): statics `GET shares/policy`, `GET shares/by-path?path=`, `GET sharees?term=&itemType=`, `POST shares`, `GET shares/mine`, `GET shares/received`; at the END after the existing parameter routes `PUT shares/:id`, `DELETE shares/:id`, `POST shares/:id/accept` (200). The browser sends `kind: 'user' | 'group' | 'link'` and `access: 'view' | 'edit' | 'upload'`, never a share type number or permission bitmask; the API maps: view → 1; edit → folder 15, file 3; upload → 4 (folder links only); bit 16 (reshare) is never sent; kind → shareType 0 / 1 / 3. The item type and its writability come from the API's own PROPFIND Depth 0 (`dav.stat`) on create and from `GET shares/{id}` on update — never from the browser.
- D-12 Share transport: new `ocsShareRequest` in `nextcloud-shares.ts` on top of `ncRequest` (the Etappe-1 `ocsRequest` stays untouched: it maps 403 to `app-password-given` and discards error bodies, which the login code relies on). JSON bodies for POST/PUT (passwords never in a URL), body read on every status (2xx cap 8 MiB, non-2xx cap 64 KiB, capabilities cap 1 MiB), `ocs.meta.message` sanitised (control characters removed, whitespace collapsed, max 300 characters), share lists capped at 2000 entries with `truncated`.
- D-13 Policy is read from `GET /ocs/v2.php/cloud/capabilities` with the caller's own credential on every `GET shares/policy` and before every create and every link update — no cache (it is per user, an admin change is visible at once, and the e2e toggles it between calls).
- D-14 Error codes (added to `NcErrorCode` + `NC_ERROR_DEFAULTS`, German defaults): `sharingDisabled` 409 „Teilen ist in Ihrer Nextcloud ausgeschaltet.“ (also used with the message „Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.“), `linkSharingDisabled` 409 „Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.“, `shareAccessInvalid` 400 „Diese Berechtigung ist für diesen Eintrag nicht möglich.“, `shareRecipientInvalid` 422 „Diese Person oder Gruppe kennt Ihre Nextcloud nicht.“, `shareAlreadyExists` 409 „Der Eintrag ist schon so geteilt. Ändern Sie die vorhandene Freigabe.“, `sharePasswordRequired` 400 „Ihre Nextcloud verlangt für Links ein Passwort.“, `sharePasswordRejected` 400 „Nextcloud lehnt dieses Passwort ab. Bitte wählen Sie ein längeres oder weniger gebräuchliches Passwort.“, `shareExpiryRequired` 400 „Ihre Nextcloud verlangt für Links ein Ablaufdatum.“, `shareExpiryInvalid` 400 „Dieses Ablaufdatum lässt Ihre Nextcloud nicht zu. Es darf nicht in der Vergangenheit und nicht nach dem erlaubten Höchstdatum liegen.“, `shareRejected` 422 „Nextcloud hat diese Freigabe abgelehnt.“, `shareNotFound` 404 „Diese Freigabe gibt es nicht mehr.“, `tooManyShares` 429 with `retryAfterSeconds` „Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie einige Minuten.“. Codes that come from a Nextcloud answer carry `ncMessage` (sanitised) as extra field.
- D-15 Error matrix (Nextcloud 34 source, verified at planning: password-policy failures are HTTP 400; „Passwords are enforced“ on create 403; expiry in the past or beyond the maximum is a GenericShareException with code 404 and arrives as HTTP 404 on create AND update; missing enforced expiry on create 403; any other update failure 400 „Failed to update share.“; update of an incoming share 403; unknown id 404; delete without right 403). Applied by one function `mapShareFailure(operation, result, sent)`: transport failures, credential-dead, 429 and every status ≥ 500 go to `mapNcFailure` with `onExpired`. create: 400 + password sent → `sharePasswordRejected`; 404 + non-empty expireDate sent → `shareExpiryInvalid`; 404 for user/group → `shareRecipientInvalid`; 404 for link → `notFound`; 400/403/other 4xx → `shareRejected`. update: 400 + non-empty password sent → `sharePasswordRejected`; 400 or 404 + expireDate field sent → `shareExpiryInvalid`; 404 otherwise → `shareNotFound`; 400/403/other 4xx → `shareRejected`. remove/accept/read by id: 404 → `shareNotFound`; other 4xx → `shareRejected`. by-path read: 404 → `notFound`. Never switch on message text (it is localised).
- D-16 Pre-validation before any write call (from the fresh policy and the stat/GET result): API disabled → `sharingDisabled`; group while group sharing is off → `sharingDisabled` with the group message; link while links are off → `linkSharingDisabled`; access not offered for this item (upload on a file or for user/group; edit on an item without the letters W, C or K / without update or create bit; link edit or upload while public upload is off) → `shareAccessInvalid`; recipient already has a share of the same kind on this path (by-path list) → `shareAlreadyExists`; a second link while `multiple_links` is false → `shareAlreadyExists`; link without password (create) or password `""` (update) while enforced → `sharePasswordRequired`; link expireDate absent or `""` on create, or `""` on update, while enforced → `shareExpiryRequired`; expireDate not `^\d{4}-\d{2}-\d{2}$` or not a real calendar date → `shareExpiryInvalid` (date RANGE is left to Nextcloud — its timezone decides near midnight). Password, expireDate and label are dropped for user/group shares (never forwarded); on create a link expireDate that is absent is not sent (server default applies) — the web always sends it for links (a date or `""`).
- D-17 Received and pending: `GET shares/received` = `GET shares?shared_with_me=true` + `GET shares/pending` (a 404/405 on the pending call means an older server without the route → empty pending list, not an error); accept = `POST shares/pending/{id}`; decline and leave = `DELETE shares/{id}` by the recipient. „Öffnen“ navigates the file view to `file_target` (folder) or to its parent with the file focused.
- D-18 Password generator in the browser (CSPRNG via `crypto.getRandomValues`, length max(20, policy minLength) capped at 64, at least one upper case letter, lower case letter, digit and special character, no look-alike characters). Deviation from the research's suggestion to call `password_policy/api/v1/generate`: that app is optional on the company server, a 20-character CSPRNG value meets every usual rule, and Nextcloud still validates (its 400 message is shown). Link label: yes (optional, max 255); note field: no. Notifications: Nextcloud's own behaviour (no `sendMail`). The link URL is the `url` Nextcloud returned, shown only to the share owner, only when it is http/https, in a read-only input with „Link kopieren“ (navigator.clipboard, fallback: select the text); Tessera never requests it. Expiry of user/group shares is not sent (Nextcloud applies its own default/enforcement) and is shown read-only.
- D-19 UI: `ShareDialog` built on the module `Dialog` (wide; this also keeps the file-view shortcuts out of the search field), title „„{name}“ teilen“, section „Personen und Gruppen“ (combobox search with debounce 300 ms, starts at max(1, minSearchLength) characters, results as listbox, already-shared recipients disabled, access select next to the field, default „Ansehen“; rows with name, „Gruppe“ marker, access select, read-only expiry, remove button), section „Link“ (Task 2), footer „Fertig“; errors as `role="alert"` with the code text and a second line „Meldung der Nextcloud: …“. User/group remove acts at once; link delete asks inline. Share indicator: outgoing = icon button (link icon if a link exists, else people icon) with aria-label, opens the dialog; incoming = static icon with title and screen-reader text „Mit Ihnen geteilt“. Tabs Dateien / Von mir geteilt / Mit mir geteilt for every connected user (Einstellungen stays for managers); the share tabs and the Teilen action are hidden only when the policy says sharing is off.
- D-20 Test strategy: specs assert literal outgoing calls (method, exact URL, exact JSON body, headers) — never values rebuilt with the production helper (STATE pitfall „Tautologischer Test“). One live e2e script `e2e-shares.sh [people|links|received|version|all]` against `tessera-nc-test`; it adds the Nextcloud user `ben` (no two-factor) and the group `tessera-team` (with ben), toggles policies with occ and resets everything in a trap, and switches Nextcloud's own rate limit off for its run only (`ratelimit.protection.enabled`, reset in the trap) so repeated runs never trigger an origin pause; Tessera's limiter is proven by unit specs. Budget: one `all` run creates at most 6 shares through Tessera.
Output: share layer, service, DTOs, routes, propfind share types, web client, policy helpers, share dialog with link form, share indicator, two views, tab and navigation changes, e2e script, module changelog 1.0.0 extended, changelogs, four guides, screenshots. Three commits on main, NOT pushed.
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/261009-dkv-CONTEXT.md
@.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/261009-dkv-RESEARCH.md
@.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-SUMMARY.md
Discovered facts the executor can rely on (verified during planning on 2026-10-09):
- Transport (`apps/api/src/nextcloud-files/nextcloud-http.ts`): `ncRequest(transport, gate, opts)` with `NcRequestOptions { baseUrl, prefix, segments?, query?: Record, method, headers?, body?: string|Buffer|Readable|null, authorization?, credentialKey?, ocs?: boolean, headersTimeoutMs?, bodyTimeoutMs?, signal? }`; returns `{ ok: true, status, headers, body }` for every HTTP status (also 4xx) or `NcFailure { ok: false, kind, status?, retryAfterSeconds? }`; it already turns a 429 into an origin pause plus `{ ok: false, kind: 'http', status: 429 }` and a 401 with credentialKey into `kind: 'credential-dead'`. `/ocs/v2.php/` is in `ALLOWED_PREFIXES` — nothing to add. `buildNcUrl` encodes every query key and value with `encodeURIComponent` in insertion order (so `shareType[0]` becomes `shareType%5B0%5D`). `ocs: true` adds `OCS-APIRequest: true` and `Accept: application/json`; custom headers cookie/host/authorization are forbidden. `readCappedText(body, maxBytes)` returns `{ ok, text }` or `too-large`/transport kinds. `parseUserPath(raw)` → segments (400 invalidPath). `basicAuth`.
- Etappe-1 OCS helper `ocsRequest` in `nextcloud-auth-client.ts` maps 403 to `app-password-given` and drains error bodies with no message — do not use or change it for shares.
- Error contract (`nextcloud-files.types.ts`): `NcErrorCode` union + `NC_ERROR_DEFAULTS: Record` (every new code needs a default), `ncErrorDefault(code, extra?, message?)`, `ncError(code, status, message, extra?)`, `NcSession { baseUrl, ncUserId, authorization, credentialKey }`. `mapNcFailure(result, { onExpired })` in `nextcloud-upstream.ts` is the Etappe-1 mapper (credential-dead/401 → connectionExpired + onExpired, 429/paused → nextcloudLocked, 503 → nextcloudMaintenance, other ≥500 → nextcloudError, timeouts → nextcloudUnavailable, redirect → nextcloudRedirect).
- Service pattern: `NextcloudFilesService` (`nextcloud-files.service.ts`) — constructor `(account: NextcloudFilesAccountService, gate: NextcloudCallGate, @Inject(NEXTCLOUD_TRANSPORT) transport)`, private `session(tenantId, userId)` = `account.getSession`, private `fail()` = `throw await mapNcFailure(result, { onExpired: () => this.account.markExpired(tenantId, userId) })`. `dav.stat(transport, gate, session, segments)` (`nextcloud-dav.ts`) → `{ ok: true, status, entry: NcEntry | null }` (404 → entry null) or NcFailure. The new service touches no Prisma model → RLS inventory and `docs/mandantentrennung-zugriffsklassifikation.md` stay unchanged.
- Spec harness: `nextcloud-files.service.spec.ts` `setup()` with a fake `NextcloudTransport` returning `{ statusCode, headers, body: Readable.from([Buffer]) }`, `SESSION = { baseUrl: 'https://cloud.example/nc', ncUserId: 'anna', authorization: 'Basic YW5uYTphcHAtcHctMTIz', credentialKey: 'k1' }`, real `NextcloudCallGate`, `account = { getSession, markExpired }` mocks, `codeOf(e)` reads `e.response.code`. The share service spec needs a queue of replies (one per call) and records `calls[i].method/url/headers/body`.
- Login guard (`nextcloud-login-guard.ts`): injectable clock `now`, `pruneTimes(list, now, windowMs)`, `checkFlowStart(userId)` (10 per 10 min, throws `tooMany(ms)` which builds `tooManyAttempts`) — `checkShareCreate` is the sibling with its own constants and the `tooManyShares` code.
- PROPFIND (`nextcloud-propfind.ts`): `PROPFIND_BODY` already requests ``, the parser has `isArray` for `share-type`, `buildEntry` does not read it; `NcEntry` fields name, path, type, size, mime, mtime, etag, fileId, permissions (letters, R = shareable), hasPreview, favorite. Own root entries show `RGDNVCK` (folders) / `RGDNVW` (files); received items carry `S` [research A1, verify live in Task 2].
- Controller (`nextcloud-files.controller.ts`): class `@UseModule('nextcloud-files')`, constructor `(settings, account, files, transfer, serverInfo)`, `requireTenantId(req)` / `requireUserId(req)`; `saveSettings` carries `@Roles(ADMIN, SUPER_ADMIN)` since CR-02 (the Etappe-1 role-grep gate no longer applies; the specs check rights). Static routes end with `@Put('uploads/file')`, then the parameter block starts with `@Get('connect/flow/:flowId')` and ends with `@Delete('uploads/:uploadId')`. `nextcloud-files.controller.spec.ts` has „Pfade und Methoden“ (RequestMethod GET 0, POST 1, PUT 2, DELETE 3), „alle anderen Handler stehen auf Benutzen-Ebene“ and the declaration-order check. `apps/api/src/module-registry/module-manage-handlers.spec.ts` lists Benutzen handlers of `NextcloudFilesController` in an `it.each` (comment „Spätere Aufgaben … ergänzen diese Liste“). Module providers in `nextcloud-files.module.ts`.
- Web: `apps/web/src/lib/nextcloud-files-api.ts` — private `request(path, { method, json })` (credentials include, GET no-store, throws `NextcloudFilesRequestError(status, code, message, extra)`), web `NcEntry` mirrors the API. `components/nextcloud-files/error-text.ts` — `KNOWN` set, `errorText(t, error, locale)` (special cases nextcloudLocked minutes, quotaExceeded), `toErrorLike`. `FileBrowser.tsx` — props `{ serverUrl, onExpired }`, `DialogState` union (newFolder/rename/move/delete), `menuActions(entry): EntryAction[]` (open/downloadZip/download, rename, move, openInNextcloud, delete), `focusAfterLoad` ref, `load(path, { quiet })`, reads `?path=` on mount and mirrors it with `replaceState`; `isTypingTarget` ignores keys inside `[role="dialog"]`. `Dialog.tsx` props `{ title, onClose, children, footer?, wide?, initialFocus? }` (focus trap, Escape). `EntryAction { id, label, icon, href?, onSelect?, destructive?, separated? }`. `TypeTile` takes `entry: { name, type, mime }`. Icons in `components/icons.tsx` are lucide-style `export const XIcon = (p: P) => (…)`. `page.tsx` — `TabId = 'files' | 'settings'`, `TabBar` from `@/components/accounting/tab-bar` rendered only for `canManage`, FileBrowser only rendered in the files tab (switching tabs remounts it), `SettingsSection`, `PageHeader` with AccountBar in the files tab. Tests: `FileBrowser.test.tsx` mocks `@/lib/nextcloud-files-api` via `importOriginal`; page test renders with `NextIntlClientProvider locale="de" messages={de}`.
- Messages: namespace `nextcloudFiles` in `apps/web/src/messages/de.json`/`en.json` (sections tabs, notConfigured, connect, account, errors, settings, browser {menu, …}, dialogs, transfers, codes). `umlaut-guard.spec.ts` fails on any new token with ae/oe/ue/ss that is not on `UMLAUT_ALLOWLIST` in `umlaut-dictionary.ts` (add correct German words there); message variables must not contain such letter pairs (use `{name}`, `{count}`, `{date}`, `{days}`, `{term}`, `{detail}`, `{minutes}` — never `{query}`).
- Module changelog: `apps/api/src/nextcloud-files/nextcloud-files.changelog.ts` has exactly one release 1.0.0 dated 2026-10-08 with four `new` items; the seed uses `latestVersion(NEXTCLOUD_FILES_CHANGELOG)`; `apps/api/src/module-registry/module-changelog.spec.ts` checks format (strictly descending versions, real dates newest first, de+en, no replacement spellings, no tenant/licence words) and pins in the test „domains und nextcloud-files haben genau eine Version 1.0.0 vom 2026-10-08“ (around line 202). The Marktplatz reads `GET /modules/changelog/:slug`. CHANGELOG.md has „## Unveröffentlicht“ → „### Neu“ with the Etappe-1 bullets „Neues Modul „Dateien“ …“, „Dateien, Anmeldung: …“, „Dateien, Arbeiten mit Dateien: …“, „Dateien, Hochladen und Herunterladen: …“; module version bumps are mentioned like „Modulversion 1.1.0.“ (see the DKV bullet).
- Guides: `docs/anleitung-anwender.md` „### Dateien (Nextcloud)“ (line ~245; the „**Arbeiten mit Dateien:**“ paragraph lists the row-menu actions „Öffnen, Herunterladen, Umbenennen, Verschieben, „In Nextcloud öffnen“ und Löschen“); `docs/anleitung-administration.md` „### Dateien: Nextcloud anbinden“ (line ~359); `docs/anleitung-betrieb.md` „### Dateien (Nextcloud)“ in chapter 3 (line ~186, bullets with bold lead-ins) and the „### Fehlerbilder“ table; `docs/anleitung-entwicklung.md` „## Konventionen und Fallstricke“ (line ~693, paragraphs „**Titel (quick-id):** …“).
- Test Nextcloud `tessera-nc-test` (Nextcloud 34.0.4, running): host `http://localhost:18080`, from the api container `http://172.17.0.1:18080` (`NC_BASE`); users admin/Admin-Pass-12345, anna/User1-Pass-12345 („Anna Müller“), zoe (two-factor, „Zwei Faktor“); groups admin, twofa. Verified config keys: link password enforced = app config `core shareapi_enforce_links_password` (lexicon BOOL: `occ config:app:set core shareapi_enforce_links_password --value=true --type=boolean`), link default expiry `core shareapi_default_expire_date` (BOOL), link expiry enforced `core shareapi_enforce_expire_date` (BOOL), days `core shareapi_expire_after_n_days` (string, default 7) — reset each with `occ config:app:delete core `; pending shares for anna: `occ user:setting anna files_sharing default_accept no`, reset `occ user:setting --delete anna files_sharing default_accept`; Nextcloud rate limits off: `occ config:system:set ratelimit.protection.enabled --value=false --type=boolean`, reset `occ config:system:delete ratelimit.protection.enabled`; `createShare` carries `UserRateLimit(limit: 20, period: 600)`. Capabilities: `files_sharing.public.expire_date.days` is a STRING when present. The password policy runs in the SHARING context (`minLength` 10 in the test server).
- e2e harness (`.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh`, sourced): `API`, `WEB`, `NC_BASE`, `E2E_TMP`, `e2e_fail`, `e2e_login [user] [pw]` (default admin/admin123), `e2e_status [json] [outfile]` (prints the status, body to `$E2E_TMP/body.out`), `e2e_expect `, `e2e_contains `, `e2e_activate`, `e2e_set_address`, `e2e_connect_anna ` (prints status), `e2e_wait_health`, `NC_OCC …` (occ as www-data in the test container). `e2e-files.sh` shows the style (python3 -I for JSON, curl -u for direct Nextcloud calls, `trap … EXIT`).
- Stack: db, api :3001, web :3000 (production build via `/api-proxy`) and mailhog run; rebuild with `docker compose up -d --build api` (plus `web` when web code changed) — plain `up` does not rebuild. Playwright MCP is configured in `.mcp.json` (chromium); Etappe 1 used a throwaway playwright-core script in the scratchpad when MCP tools were not available. Screenshots go to `.playwright-mcp/nextcloud-files/` (gitignored). Dark mode is switched with the theme button (user preference: check in dark first). Never measure by calling fetch from inside the page (it misleads in both directions) — use the UI.
- Git: the index already contains unrelated staged deletions (`.planning/.continue-here.md`, `.planning/HANDOFF.json`) and a modified `.planning/STATE.md` — they belong to the orchestrator. Commit ONLY the task's files: `git add ` then `git commit -m "…" -- `. German subject, prefix `feat(nextcloud-files):`, body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) `. Never push (the user bundles pushes). No deploy to the test server. Never read `.env` files.
- Literals for specs: `anna:app-pw-123` → `Basic YW5uYTphcHAtcHctMTIz`; `encodeURIComponent('/Projekte/Ärger 100%')` = `%2FProjekte%2F%C3%84rger%20100%25`; `encodeURIComponent('shareType[0]')` = `shareType%5B0%5D`.
@apps/api/src/nextcloud-files/nextcloud-http.ts
@apps/api/src/nextcloud-files/nextcloud-files.service.ts
@apps/api/src/nextcloud-files/nextcloud-upstream.ts
@apps/api/src/nextcloud-files/nextcloud-files.types.ts
@apps/api/src/nextcloud-files/nextcloud-login-guard.ts
@apps/api/src/nextcloud-files/nextcloud-files.controller.ts
@apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx
@apps/web/src/app/(portal)/modules/nextcloud-files/components/Dialog.tsx
@apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx
Task 1: Tracer — share a file or folder with a colleague or a group, end to end (share layer, service, routes, web client, share dialog people section, row menu, share indicator), proven live against the test Nextcloud
apps/api/src/nextcloud-files/nextcloud-shares.ts, apps/api/src/nextcloud-files/nextcloud-shares.spec.ts, apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts, apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts, apps/api/src/nextcloud-files/nextcloud-files.types.ts, apps/api/src/nextcloud-files/nextcloud-login-guard.ts, apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts, apps/api/src/nextcloud-files/nextcloud-propfind.ts, apps/api/src/nextcloud-files/nextcloud-propfind.spec.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.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/lib/nextcloud-files-api.test.ts, apps/web/src/components/nextcloud-files/share-policy.ts, apps/web/src/components/nextcloud-files/share-policy.test.ts, apps/web/src/components/nextcloud-files/error-text.ts, apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareIndicator.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh
The local stack (db, api, web) runs, `curl -s http://localhost:18080/status.php` contains `"installed":true` (container tessera-nc-test) and `bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh` prints `nc test ready`.
- Share transport (fake transport, real gate): GET shares of `/Projekte/Ärger 100%` requests exactly `https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares?path=%2FProjekte%2F%C3%84rger%20100%25&reshares=true` with method GET and the headers `authorization: Basic YW5uYTphcHAtcHctMTIz`, `ocs-apirequest: true`, `accept: application/json` and no cookie header; sharee search for term `ben` on a folder requests exactly `https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1`; creating a user share sends POST `https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares` with `content-type: application/json` and the body literal `{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}`; update sends PUT `https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares/17` with body `{"permissions":1}`; delete sends DELETE to the same URL without body; capabilities = GET `https://cloud.example/nc/ocs/v2.php/cloud/capabilities`. A 404 answer `{"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Wrong share ID, share does not exist"},"data":[]}}` yields `{ ok: true, status: 404, message: 'Wrong share ID, share does not exist' }`; a message with control characters and 500 characters comes back cleaned and cut to 300; a non-JSON 2xx yields kind invalid-response; a 2xx body over 8 MiB yields too-large; a 429 pauses the origin (the next call is not sent); a 401 yields credential-dead.
- Parsers: the research's live user-share JSON (copied verbatim into the spec) → `{ id: '1', kind: 'user', path: '/Projekte', name: 'Projekte', itemType: 'folder', mime: null, itemWritable: true, permissions: 31, access: 'edit', shareWith: 'zoe', shareWithName: 'Zwei Faktor', ownerId: 'anna', ownerName: 'Anna Müller', canEdit: true, canDelete: true, expiration: '2026-12-31', label: '', url: null, hasPassword: false, target: '/Projekte', sharedAt: '2026-10-09T07:52:58.000Z' }` and JSON.stringify of the result contains none of storage_id, attributes, mail_send, item_source, token; a link fixture with `password: 'redacted'`, a token and `url: 'http://cloud.example/nc/index.php/s/AbC123'` owned by anna and parsed for self anna → kind link, hasPassword true, url kept, the string redacted absent; the same fixture parsed for self zoe → url null; url `javascript:alert(1)` → null; share_type 4 → null (caller counts it as hidden); `accessOf`: 1 → view, 17 → view, 4 → upload, 3 → edit, 15 → edit, 31 → edit, 0 and 16 → custom; `permissionsFor('edit','folder')` 15, `('edit','file')` 3, `('view', any)` 1, `('upload','folder')` 4; `parseShareList` accepts an array (GET) and an object (POST/PUT answer), keeps at most 2000 and flags truncated. `parseSharees` on the research fixture → `[{ kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' }, { kind: 'group', id: 'twofa', label: 'twofa', detail: null }]` (exact and normal lists merged, deduped by kind and id, remotes/emails/lookup dropped, max 25). `parseSharePolicy` on the research's live capabilities → `{ enabled: true, groupsEnabled: true, links: { enabled: true, passwordRequired: false, passwordSuggested: false, expiryDefaultDays: null, expiryEnforced: false, uploadAllowed: true, multipleLinks: true }, internalExpiry: { defaultDays: null, enforced: false }, minSearchLength: 0, passwordMinLength: 10 }`; `public: { enabled: false }` → links.enabled false and every link flag false; `expire_date: { enabled: true, days: '7', enforced: true }` → expiryDefaultDays 7, expiryEnforced true; days 'abc' or '0' → null; missing files_sharing or `api_enabled: false` → enabled false.
- Service (queue of fake replies, mocked account, real gate, guard with fake clock): create `{ path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' }` sends in this order PROPFIND Depth 0 on `https://cloud.example/nc/remote.php/dav/files/anna/Projekte`, GET capabilities, GET shares?path=%2FProjekte&reshares=true, POST with the literal body above, and returns the parsed share; a file `/Bericht.txt` with access edit sends permissions 3, view sends 1; access upload for a user share, or edit on an entry with letters `RG`, → 400 shareAccessInvalid and no POST; ben already in the by-path list as user → 409 shareAlreadyExists and no POST; kind group while groupsEnabled false → 409 sharingDisabled with the group message and no POST; api disabled → 409 sharingDisabled; path '' or '/' → 400 invalidPath without any call. Error matrix (it.each) on the create POST: 404 → 422 shareRecipientInvalid; 403 with message 'You cannot share a folder that contains other shares' → 422 shareRejected with ncMessage equal to that text; 400 → 422 shareRejected; 500 → nextcloudError; 503 → nextcloudMaintenance; 401 → 409 connectionExpired and markExpired called once; 429 → 503 nextcloudLocked; no case ends as HTTP 401 or 403. Update `17 { access: 'view' }` sends GET shares/17 then PUT `{"permissions":1}`; a share with can_edit false → 422 shareRejected without PUT; PUT 404 → 404 shareNotFound; ids 'abc' and a 21-digit id → 404 shareNotFound without any call; an update without fields returns the current share and sends no PUT. Remove 17 → DELETE; 404 → shareNotFound; 403 → 422 shareRejected. Sharees with term '' → `{ sharees: [] }` without a call. Policy: two calls → two capability requests (no cache). Limiter: 15 creates in 10 minutes pass, the 16th → 429 tooManyShares with retryAfterSeconds 600 and no POST; 10 minutes later creates pass again; a create refused by pre-validation does not count.
- Guard: `checkShareCreate(userId)` allows 15 per 10 minutes per user, user B unaffected by user A, retryAfterSeconds counts to the end of the window of the oldest create.
- PROPFIND: `033` → `shareTypes: [0, 3]`; an empty `` → `[]`; a non-number value is ignored.
- Controller: routes `getSharePolicy` [0,'shares/policy'], `listSharesForPath` [0,'shares/by-path'], `searchSharees` [0,'sharees'], `createShare` [1,'shares'], `updateShare` [2,'shares/:id'], `deleteShare` [3,'shares/:id']; none carries MODULE_MANAGE_KEY or ROLES_KEY; the declaration-order check passes (the two `:id` handlers are declared after every static handler); tenant and user reach the service from the token only; without a user in the token → ForbiddenException and no service call.
- Web: `listSharesForPath('/Ärger 100%')` fetches `…/modules/nextcloud-files/shares/by-path?path=%2F%C3%84rger%20100%25`; `searchSharees('ben', 'folder')` fetches `…/sharees?term=ben&itemType=folder`; `createShare` POSTs the JSON input; `accessOptions` → folder writable user share [view, edit], entry with letters `RG` [view]; `generatePassword` (injected random) has length max(20, minLength), at least one of each class, no look-alike characters. ShareDialog (mocked api): shows existing rows; typing `be` calls `searchSharees('be', 'folder')` once after the debounce; choosing ben calls `createShare({ path, kind: 'user', shareWith: 'ben', access: 'view' })` once and shows the new row; an already shared recipient is disabled in the list; changing a row's access calls `updateShare(id, { access: 'edit' })`; remove calls `deleteShare(id)`; an error `shareRejected` with `extra.ncMessage` shows the German text and „Meldung der Nextcloud: …“; `connectionExpired` calls onExpired. FileBrowser: the row menu shows „Teilen“ only for entries whose permissions contain R and opens the dialog titled „„Projekte“ teilen“; an entry with shareTypes [0] shows the indicator button (aria-label with the name) that opens the dialog; an entry with S in its permissions shows the incoming marker.
**Share layer (per D-03, D-06, D-11, D-12, D-14).** New `apps/api/src/nextcloud-files/nextcloud-shares.ts` with a header comment naming quick 261009-dkv and the Etappe-1 rules it keeps. `ocsShareRequest(transport, gate, session, { method, segments, query?, json?, maxBytes? })` calls `ncRequest` with `prefix: '/ocs/v2.php/'`, the segments (shares base `['apps','files_sharing','api','v1','shares']`, ids appended as their own segment), `ocs: true`, `authorization` and `credentialKey` from the session, `headers: { 'content-type': 'application/json' }` plus `body: JSON.stringify(json)` only when json is given, 15 s timeouts. It reads the body on every status (2xx cap = maxBytes, default 8 MiB; non-2xx cap 64 KiB), parses `ocs.meta.message` (sanitised by a `cleanText(value, max)` helper: remove U+0000–U+001F and U+007F, collapse whitespace, cut to 300) and `ocs.data`, and returns `{ ok: true, status, message, data }` or the NcFailure untouched (also `too-large`, `invalid-response` for non-JSON 2xx; non-JSON non-2xx just has message null). Types: `NcShareKind = 'user' | 'group' | 'link'`, `NcShareAccess = 'view' | 'edit' | 'upload' | 'custom'`, `NcShareView` with exactly the fields of the behavior block (plus `pending?: boolean` used in Task 2), `NcSharee { kind: 'user' | 'group', id, label, detail: string | null }`, `NcSharePolicy` with the shape of the behavior block. `permissionsFor(access, itemType)` and `accessOf(permissions)` per D-11 (mask 15 before deriving; upload = create without read). `parseShare(raw, selfId)` returns null for share types other than 0/1/3 or ids not matching `^\d{1,20}$`, builds name from the last segment of `file_target` for received shares and of `path` otherwise, `itemWritable` from `item_permissions & 6`, `mime` from `mimetype` for files (null for folders), `expiration` from the first 10 characters when they form a date, `sharedAt` from `stime` seconds, `hasPassword` only for links with a non-empty password field, `url` only for links whose `uid_owner` equals selfId and whose URL parses with protocol http: or https:, every display string through `cleanText` (max 255) — never copies unknown fields. `parseShareList(data, selfId)` → `{ shares, hidden, truncated }` (array or single object, cap 2000). `parseSharees(data)` and `parseSharePolicy(capabilities)` per behavior (numbers accepted as number or numeric string, `days` clamped 1..3650, `minSearchLength` 0..32, `password_policy.minLength` 1..256 or null; `multiple_links` missing while links are on → true). Never call `api.generate` or any URL from capabilities. Write `nextcloud-shares.spec.ts` first (literal URLs and bodies).
**Error contract and limiter (per D-09, D-14).** In `nextcloud-files.types.ts` add all twelve codes of D-14 with their German defaults (Task 2 uses the link codes; defining them once keeps the type closed). In `nextcloud-login-guard.ts` add `SHARE_CREATE_LIMIT = 15`, `SHARE_CREATE_WINDOW_MS = 10 * 60 * 1000` and `checkShareCreate(userId)` (same pruneTimes pattern as `checkFlowStart`, throws `tooManyShares` with `retryAfterSeconds`); extend its spec.
**Service (per D-01, D-03, D-11, D-13, D-15, D-16).** New `nextcloud-files-shares.service.ts`, `@Injectable() NextcloudFilesSharesService(account, gate, @Inject(NEXTCLOUD_TRANSPORT) transport, guard: NextcloudLoginGuard)`, header comment with the rights rule (caller's own session only, tenant and user from the token, no database access). A private `loadPolicy(session)` calls `ocsShareRequest` with segments `['cloud', 'capabilities']` (cap 1 MiB) and `parseSharePolicy`, on every use (D-13). Methods: `policy(tenantId, userId)`; `sharesForPath(tenantId, userId, rawPath)` → `{ path, shares, hidden, truncated }` (root → invalidPath); `sharees(tenantId, userId, term, itemType)` → `{ sharees }` (trimmed term shorter than 1 → no call); `create(tenantId, userId, input)` for kinds user and group in this task (link arrives in Task 2): parseUserPath, root → invalidPath, session, `dav.stat` (404 → notFound), policy, D-16 checks, by-path duplicate check, `guard.checkShareCreate(userId)`, POST, parse the returned object; `update(tenantId, userId, id, input)`: id regex → shareNotFound without call, GET by id, `can_edit` false → shareRejected, access validated against kind and item, PUT with only the changed permissions (no field → return current share); `remove(tenantId, userId, id)` → `{ deleted: true }`. One private `mapShareFailure(operation, result, sent)` implements D-15 and throws; transport failures go to `mapNcFailure(result, { onExpired: () => this.account.markExpired(tenantId, userId) })`. Never log bodies, passwords, tokens or URLs. Write `nextcloud-files-shares.service.spec.ts` first per behavior (reply queue; it.each error matrix asserting codes and that no HTTP status is 401 or 403).
**DTOs, routes, module (per D-11).** `dto/nextcloud-files-shares.dto.ts`: `ShareByPathQueryDto { path: string (IsString, MaxLength 4096) }`, `ShareeQueryDto { term (IsString, MaxLength 100, no control characters), itemType (IsIn file, folder) }`, `CreateShareDto { path, kind (IsIn user, group in this task), shareWith (IsString, MaxLength 255, Matches no control characters), access (IsIn view, edit, upload) }`, `UpdateShareDto { access? }`. Controller: inject the new service as the sixth constructor argument; static handlers `getSharePolicy`, `listSharesForPath`, `searchSharees`, `createShare` placed right after `putSingle` (before the parameter block); `updateShare` (`@Put('shares/:id')`) and `deleteShare` (`@Delete('shares/:id')`) at the very END; ids reach the service as plain strings (the service validates). Update the header comment's route list. Register the service in `nextcloud-files.module.ts`. Extend `nextcloud-files.controller.spec.ts` (constructor arguments, „Pfade und Methoden“, Benutzen level, order, token pass-through) and the Benutzen `it.each` list in `module-manage-handlers.spec.ts` with the six handler names.
**Share types on entries.** In `nextcloud-propfind.ts` read `share-types` → `share-type` values into `shareTypes: number[]` (integers 0..99, deduped, ascending) on `NcEntry`; update `nextcloud-propfind.spec.ts` and every other spec fixture that builds full NcEntry objects so tsc stays green.
**Web client and helpers.** In `apps/web/src/lib/nextcloud-files-api.ts` add `shareTypes: number[]` to `NcEntry`, the types `NcShare`, `NcShareKind`, `NcShareAccess`, `NcSharee`, `NcSharePolicy` (mirroring the API) and `getSharePolicy()`, `listSharesForPath(path)`, `searchSharees(term, itemType)`, `createShare(input)`, `updateShare(id, input)`, `deleteShare(id)` (paths and terms only in the query or JSON, encoded with encodeURIComponent); extend its test. New `components/nextcloud-files/share-policy.ts` (+ test): `type ShareTarget = { path, name, type: 'file' | 'folder', mime: string | null, writable: boolean }`, `targetFromEntry(entry)` (writable when letters contain W, C or K), `accessOptions(target, kind, policy)` per D-16, `generatePassword(minLength, random = crypto.getRandomValues bound)` per D-18. In `error-text.ts` add the new codes to `KNOWN`, a `tooManyShares` case with minutes like `nextcloudLocked`, and `ncMessageOf(error)` returning the trimmed `extra.ncMessage` string or null.
**Dialog, menu, indicator (per D-19, D-04).** New `components/ShareDialog.tsx` on `Dialog` (wide) with props `{ target: ShareTarget, onClose, onChanged, onExpired }`: loads policy and `listSharesForPath` on open (loading and error states), section „Personen und Gruppen“ per D-19 (combobox with `aria-expanded`, `aria-controls`, `aria-activedescendant`, arrow keys and Enter; results grouped users first; groups hidden when policy.groupsEnabled is false; one create at a time, controls disabled while busy), rows per D-19, a muted note when `hidden > 0` („{count} weitere Freigaben, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.“), policy enabled false → only the text of `sharingDisabled`. After every successful change call `onChanged`. New `components/ShareIndicator.tsx` per D-19 (outgoing button / incoming marker, focus ring, Mosaik tokens). Add lucide-style icons to `icons.tsx`: ShareIcon (lucide share-2), LinkIcon (link), UserIcon (user), UsersIcon (users), CopyIcon (copy). `FileBrowser.tsx`: `DialogState` gains `{ kind: 'share'; target: ShareTarget }`; `menuActions` inserts `{ id: 'share', label: t('menu.share'), icon: ShareIcon, onSelect }` after move, only when `entry.permissions.includes('R')` and the new prop `sharingEnabled` (default true) is true; no share action in the multi-selection bar; render ShareDialog for that state; `onChanged` reloads the current folder quietly. `FileList.tsx` / `FileGrid.tsx` render ShareIndicator next to the name (outgoing when `shareTypes.length > 0`, incoming when permissions contain S) without breaking the dense row layout or truncation; clicking it opens the dialog through a callback from FileBrowser (it must not select or open the row). Extend `FileBrowser.test.tsx` (mock the new api functions) and write `ShareDialog.test.tsx` per behavior. Messages: add `nextcloudFiles.share.*` (dialog texts), `browser.menu.share`, indicator texts and every new `codes.*` text plus `codes.ncDetail` „Meldung der Nextcloud: {detail}“ in de AND en (formal Sie, real umlauts, no tenant or licence words); add correct German tokens with ae/oe/ue/ss to `UMLAUT_ALLOWLIST` when the guard asks.
**Live e2e (per D-20).** Write `.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh` with the Write tool (bash, `set -euo pipefail`, sources `../../261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh` relative to its own directory, header comment: test values only, reads no .env). Argument: section `people`, `links`, `received`, `version` or `all` (default all; this task implements setup, cleanup and `people`; Task 2 adds `links` and `received`, Task 3 `version`). Setup: wait for health, admin login, activate, set address, `e2e_connect_anna` (expect 200), probe `GET $API/modules/nextcloud-files/shares/policy` — anything but 200 fails with the hint „API-Container neu bauen: docker compose up -d --build api“; ensure Nextcloud user `ben` (password `User3-Pass-12345`, display name „Ben Beispiel“, created with `docker exec -e OC_PASS=… -u www-data tessera-nc-test php occ user:add --password-from-env --display-name=… ben` only when `occ user:info ben` fails) and group `tessera-team` containing ben (idempotent); switch Nextcloud rate limits off for the run; create the fixture folder `/Tessera-Teilen-` with `Bericht.txt` and subfolder `Briefkasten` in anna's account via DAV (`curl -u anna:…`). The `trap … EXIT` deletes the fixture folder (removes its shares), deletes ben's fixtures, resets every occ key the script touched (rate limit, link password and expiry keys, anna's default_accept) and removes `$E2E_TMP`. Section people: policy JSON has enabled true and links.passwordRequired false; sharees for `ben` (itemType folder) contain user ben, for `tessera` contain group tessera-team; create user share on the fixture folder for ben with access edit → 201, kind user, access edit, permissions 15; ben's own `GET …/shares?shared_with_me=true` (curl -u ben, OCS headers, JSON) lists the folder; the same create again → 409 shareAlreadyExists and ben still sees exactly one share; update to view → 200 permissions 1 and ben sees permissions 1; group share of `Bericht.txt` for tessera-team with view → 201; `GET shares/by-path` of the folder lists exactly the user share; `GET files?path=/` shows the fixture folder with shareTypes containing 0 and `GET files?path=` shows Bericht.txt with shareTypes containing 1; DELETE the user share → 200, ben no longer sees it, by-path empty; `DELETE shares/abc` → 404 shareNotFound; every error status seen is neither 401 nor 403. Print `e2e shares people ok`.
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(nextcloud-files): Teilen mit Personen und Gruppen – Durchstich` with exactly the files of this task (see context, Git). Do not push.
pnpm --filter @tessera/api exec vitest run src/nextcloud-files module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/nextcloud-files components/nextcloud-files src/lib/nextcloud-files src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/nextcloud-files apps/web/src/components/nextcloud-files "apps/web/src/app/(portal)/modules/nextcloud-files" apps/web/src/lib/nextcloud-files-api.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)}' && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh people && echo "task1 ok"
A connected user shares a file or folder with a Nextcloud user or group from the row menu, changes the permission and removes the share; the share indicator appears in list and grid; the API validates before calling Nextcloud, never answers 401/403 and limits creates to 15 per 10 minutes; specs, tsc, biome and the live `people` section are green on the rebuilt stack; one commit on main, not pushed.
Task 2: Public links under the Nextcloud policy (password, expiry, upload-only, copy), views „Von mir geteilt“ and „Mit mir geteilt“ with pending shares, opening shared items in the file view
apps/api/src/nextcloud-files/nextcloud-shares.ts, apps/api/src/nextcloud-files/nextcloud-shares.spec.ts, apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts, apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/lib/nextcloud-files-api.test.ts, apps/web/src/components/nextcloud-files/share-policy.ts, apps/web/src/components/nextcloud-files/share-policy.test.ts, apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/LinkShareForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh
Task 1 is committed: `git log --oneline -1 -- apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts` shows the tracer commit and `e2e-shares.sh people` passes.
- Link create (service): policy passwordRequired and no password → 400 sharePasswordRequired after only the PROPFIND and capabilities calls; with password, `expireDate: ''` and label `Kunde` on folder `/Projekte` access view → POST body literal `{"path":"/Projekte","shareType":3,"permissions":1,"password":"Geheim-Pass-2026!","expireDate":"","label":"Kunde"}`; access upload on folder → `"permissions":4`; upload on a file, upload or edit while uploadAllowed false → 400 shareAccessInvalid without POST; links disabled → 409 linkSharingDisabled; multipleLinks false and a link exists on the path → 409 shareAlreadyExists; expiry enforced and expireDate absent or '' → 400 shareExpiryRequired without POST; expireDate '2026-02-30' → 400 shareExpiryInvalid without POST; POST 400 with password sent → sharePasswordRejected with ncMessage; POST 404 with expireDate '2026-12-01' → shareExpiryInvalid with ncMessage; POST 403 → shareRejected. JSON.stringify of every result and of every thrown error body never contains the password.
- Link update: password '' while passwordRequired → sharePasswordRequired without PUT; expireDate '' while expiryEnforced → shareExpiryRequired without PUT; bodies contain only the changed fields (`{"password":"Neu-Pass-2026!x"}`, `{"expireDate":""}`, `{"permissions":4}`, `{"label":"Angebot"}`); PUT 400 with non-empty password → sharePasswordRejected; PUT 400 or 404 with expireDate sent → shareExpiryInvalid; PUT 404 otherwise → shareNotFound.
- Lists: mine = GET `https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares` → `{ shares, hidden, truncated }` with types 0/1/3; received = GET `…/shares?shared_with_me=true` plus GET `…/shares/pending` → `{ shares, pending, hidden, truncated }` with types 0/1 only and `pending: true` on pending items; pending call answering 404 or 405 → `pending: []`; accept 17 → POST `https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/17` → `{ accepted: true }`; accept 404 → shareNotFound.
- Controller: `listMyShares` [0,'shares/mine'] and `listReceivedShares` [0,'shares/received'] declared before the parameter block; `acceptShare` [1,'shares/:id/accept'] at the end with HttpCode 200; all Benutzen level.
- Web helpers: `linkPasswordMode` → required / suggested / optional; `expiryRule(policy, '2026-10-09')` enforced 7 days → `{ required: true, defaultDate: '2026-10-16', maxDate: '2026-10-16', minDate: '2026-10-09' }`, default 7 not enforced → `{ required: false, defaultDate: '2026-10-16', maxDate: null, minDate: '2026-10-09' }`, none → defaultDate and maxDate null; `addDays('2026-12-28', 7)` = '2027-01-04'; `linkUpdateDiff(share, form)` returns only changed fields; `accessOptions` for a folder link with uploadAllowed → [view, edit, upload], file link → [view, edit], uploadAllowed false → [view].
- Link UI (mocked api): enforced password → password field required, submit disabled until filled, „Passwort erzeugen“ fills a value of at least 20 characters shown in plain text with a copy button; not enforced → switch „Mit Passwort schützen“ off (on when passwordSuggested); enforced expiry → date prefilled with defaultDate, `max` set, no way to clear it; optional expiry cleared → createShare receives `expireDate: ''`; „Nur hochladen“ offered only for folders; the created link row shows the URL in a read-only input and „Link kopieren“ calls `navigator.clipboard.writeText(url)` and shows „Kopiert“; „Löschen“ asks inline and only the confirmation calls deleteShare; an error sharePasswordRejected shows the Nextcloud message as second line.
- SharesView: mode byMe groups shares by path (item name, parent folder, recipients incl. „Link“, access, expiry) with „Freigaben bearbeiten“ opening ShareDialog and „Im Ordner zeigen“; mode withMe lists owner, access and expiry with „Öffnen“ (calls onOpen with folder path, or parent plus file name) and „Freigabe verlassen“ (confirmation, then deleteShare); pending items appear in „Noch nicht angenommen“ with „Annehmen“ (acceptShare) and „Ablehnen“ (deleteShare); empty states with instructions; `hidden > 0` note; connectionExpired → onExpired.
- Page: a connected Benutzen user sees the tabs Dateien, Von mir geteilt, Mit mir geteilt and no Einstellungen; a manager also Einstellungen; not connected Benutzen user → no TabBar (as before); policy enabled false → no share tabs and FileBrowser gets sharingEnabled false; „Öffnen“ in Mit mir geteilt switches to Dateien and FileBrowser starts in the target folder with the file focused; choosing a tab in the TabBar clears that start.
- FileBrowser: props `initialPath`/`initialFocus` win over `?path=` on mount and focus the named entry after the first load.
**API: links (per D-01, D-02, D-03, D-07, D-08, D-11, D-15, D-16).** Extend `CreateShareDto` with kind `link` and optional `password` (IsString, MaxLength 256), `expireDate` (IsString, Matches `^(\d{4}-\d{2}-\d{2})?$`), `label` (IsString, MaxLength 255); `UpdateShareDto` gains the same optional fields (empty string = remove password / remove expiry / clear label). In the service: link create builds the JSON body in the order path, shareType 3, permissions, password (when given), expireDate (when the field is present, also `""`), label (when given); run every D-16 link check against the fresh policy and the by-path list before `checkShareCreate`; link update fetches the policy, applies the update checks and sends only changed fields; a real-date check (`new Date(value + 'T00:00:00Z')` round-trip) backs the DTO regex. The D-15 matrix branches for password and expiry are driven by the `sent` record (which fields were sent and whether non-empty). Add `mine(tenantId, userId)`, `received(tenantId, userId)` (two calls, pending tolerant of 404/405) and `accept(tenantId, userId, id)`; extend both specs first per behavior.
**API: routes.** Controller `listMyShares` (`@Get('shares/mine')`) and `listReceivedShares` (`@Get('shares/received')`) next to the Task-1 statics, `acceptShare` (`@Post('shares/:id/accept')`, `@HttpCode(200)`) at the very end; update the header route list, the controller spec and the Benutzen list in `module-manage-handlers.spec.ts`.
**Web: helpers, client, link form (per D-07, D-08, D-18).** `nextcloud-files-api.ts`: link fields in the create/update input types, `listMyShares()`, `listReceivedShares()`, `acceptShare(id)` (+ test). `share-policy.ts`: `linkPasswordMode(policy)`, `todayLocal()` (local calendar date `YYYY-MM-DD`), `addDays(date, n)` (pure calendar arithmetic in UTC), `expiryRule(policy, today)`, `linkUpdateDiff(share, form)`, link branch of `accessOptions` (+ tests). New `components/LinkShareForm.tsx` used inline by ShareDialog for create and edit: access radio group (Ansehen / Bearbeiten / Nur hochladen with one short explanation each, „Nur hochladen“ = „Andere legen Dateien in diesen Ordner, sehen aber nichts darin.“), password per D-07 (label states when Nextcloud requires it, „Passwort erzeugen“, show/hide, hint „Mindestens {count} Zeichen.“ when passwordMinLength is known; in edit mode „Passwort ändern“ and, only when not required, „Passwort entfernen“), expiry per D-08 (native date input with `min`/`max`, label states when Nextcloud requires it and the maximum in days; optional with default → prefilled plus „Ohne Ablaufdatum“; optional without default → switch „Ablaufdatum festlegen“), label field („Hilft Ihnen, mehrere Links auseinanderzuhalten.“), submit „Link erstellen“ / „Speichern“ and „Abbrechen“; after creating a link with a password show „Geben Sie das Passwort getrennt vom Link weiter. Tessera kann es später nicht mehr anzeigen.“ with a copy button while the panel is open. The browser re-checks nothing beyond the form rules; the API is the gate.
**Web: dialog link section (per D-19, D-04).** ShareDialog gains section „Link“: links disabled → the `linkSharingDisabled` text; otherwise link rows (label or „Link“, access, „mit Passwort“, „gültig bis {date}“, URL in a read-only input, „Link kopieren“ with clipboard fallback to selecting the text, „Ändern“, „Löschen“ with inline confirmation „Link löschen? Wer ihn hat, kommt danach nicht mehr an „{name}“.“) and „Link erstellen“ (or „Weiteren Link erstellen“ when multipleLinks allows it). Errors per section with the Nextcloud message line. Extend `ShareDialog.test.tsx` per behavior.
**Web: views and navigation (per D-04, D-06, D-17).** New `components/SharesView.tsx` with prop `mode: 'byMe' | 'withMe'`, `onOpen(path, focusName?)`, `onExpired`: loads `listMyShares` or `listReceivedShares`, dense list in the Mosaik style of FileList (TypeTile via `{ name, type: itemType, mime }`, name, muted parent folder, recipients or owner, access label, expiry), actions per behavior, ShareDialog for „Freigaben bearbeiten“ (target from the share: path, name, itemType, mime, itemWritable), reload after every change, empty states („Sie haben noch nichts geteilt. Öffnen Sie im Reiter „Dateien“ das Menü einer Datei oder eines Ordners und wählen Sie „Teilen“.“ / „Mit Ihnen hat noch niemand etwas geteilt.“), truncated and hidden notes; `SharesView.test.tsx` per behavior. `FileBrowser.tsx`: props `initialPath?: string`, `initialFocus?: string` used on mount instead of `?path=` (focus via the existing `focusAfterLoad`). `page.tsx`: `TabId` gains `sharedByMe` and `sharedWithMe`; tabs per D-19; TabBar rendered when there is more than one tab; when connected load `getSharePolicy()` once (connectionExpired → reloadStatus; other errors → policy null, tabs and Teilen stay visible); pass `sharingEnabled={policy?.enabled !== false}`; state `browserStart` set by `onOpen` (folder → path; file → parent plus name) together with the switch to the files tab, cleared whenever the user picks a tab; AccountBar also on the share tabs. Extend the page test and `FileBrowser.test.tsx`. Messages de + en for everything new (tabs, link form, views), allowlist as needed.
**Live e2e (per D-20).** Extend `e2e-shares.sh`. Section links: link on the fixture folder with access view and `expireDate: ""` → 201, kind link, `url` starting with `$NC_BASE/`, hasPassword false, expiration null; link with access upload on `Briefkasten` → permissions 4; upload on `Bericht.txt` → 400 shareAccessInvalid and anna's direct Nextcloud list for that path (curl -u anna) has no new link; set the link password policy → `GET shares/policy` shows links.passwordRequired true; link without password → 400 sharePasswordRequired with no new link in Nextcloud; link with a random strong password (`Tessera-E2E-` plus 16 random characters from /dev/urandom via python3 -I secrets) → 201, hasPassword true, the response body does not contain the password; PUT password `abc` → 400 sharePasswordRejected with a non-empty ncMessage; PUT password '' → 400 sharePasswordRequired; reset the password key; set default expiry, enforced, 7 days → policy shows expiryDefaultDays 7 and expiryEnforced true; create with `expireDate: ""` → 400 shareExpiryRequired; create with today+3 (`date -u -d '+3 days' +%F`) → 201 with that expiration; PUT expireDate today+30 → 400 shareExpiryInvalid (record the Nextcloud status seen for the SUMMARY by printing it); PUT expireDate '' → 400 shareExpiryRequired; create with expireDate `31.12.2026x` → 400 and no new link in Nextcloud; reset the expiry keys → policy back to expiryDefaultDays null; `GET shares/mine` lists the created links; delete every link → 200; `docker compose logs api --since