diff --git a/.planning/STATE.md b/.planning/STATE.md
index 2ebf8d3..4bb39b0 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
-Last activity: 2026-10-06 - Quick 261006-dcs Linux-App weißes Fenster Arch
+Last activity: 2026-10-08 - Quick 261008-dts Modul Domains (AutoDNS)
Progress: [██████████] 99%
@@ -497,6 +497,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261005-d5d | Kalender-Test: 15-s-Zeitgrenze fuer Exchange (EWS), Meldung „nicht erreichbar“ | 2026-10-05 | e49d4c7..831c7b8 | [261005-d5d-kalender-test-zeitgrenze](.planning/quick/261005-d5d-kalender-test-zeitgrenze/) |
| 261005-jqd | Einstellungen und Verwaltung aufgeraeumt (gemeinsame Navigation, Seitenkopf, Karten; „Verwaltung“) | 2026-10-05 | 0a35a32 + (dieser Commit) | [261005-jqd-verwaltung-aufgeraeumt](.planning/quick/261005-jqd-verwaltung-aufgeraeumt/) |
| 261006-dcs | Linux-App: weißes Fenster auf Arch/EndeavourOS (libwayland aus AppImage entfernt) | 2026-10-06 | (dieser Commit) | [261006-dcs-linux-app-weisses-fenster-arch](.planning/quick/261006-dcs-linux-app-weisses-fenster-arch/) |
+| 261008-dts | Modul Domains: AutoDNS-Anbindung (Zugang Demo/Live, Kunden, Kontakte, Domainliste, Registrieren ohne Doppelbestellung, Aufträge) — Needs Review (Demo-Prüfung offen) | 2026-10-08 | 53b73dd | [261008-dts-modul-domains-autodns-anbindung-kontakte](.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/) |
## Deferred Items
diff --git a/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-PLAN.md b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-PLAN.md
new file mode 100644
index 0000000..92225f5
--- /dev/null
+++ b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-PLAN.md
@@ -0,0 +1,380 @@
+---
+phase: quick-261008-dts
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+quick_id: 261008-dts
+description: "Neues Modul Domains: AutoDNS-Anbindung (Demo/Live), Kontakte mit Kundenzuordnung, Domainliste, Registrierung mit Schutz vor Doppelbestellungen"
+date: 2026-10-08
+files_modified:
+ # Task 1 — tracer: DB (all four tables) -> AutoDNS client -> settings + connection test -> module page with Einstellungen tab
+ - apps/api/prisma/schema.prisma
+ - apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql
+ - apps/api/src/domains/autodns-client.ts
+ - apps/api/src/domains/autodns-client.spec.ts
+ - apps/api/src/domains/domains.types.ts
+ - apps/api/src/domains/domains-settings.service.ts
+ - apps/api/src/domains/domains-settings.service.spec.ts
+ - apps/api/src/domains/dto/domains-settings.dto.ts
+ - apps/api/src/domains/domains.controller.ts
+ - apps/api/src/domains/domains.controller.spec.ts
+ - apps/api/src/domains/domains.seed.ts
+ - apps/api/src/domains/domains.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/domains-api.ts
+ - apps/web/src/app/(portal)/modules/domains/layout.tsx
+ - apps/web/src/app/(portal)/modules/domains/page.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/domains-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
+ # Task 2 — customers, contacts (read, create, assign), domain list
+ - apps/api/src/domains/autodns-parse.ts
+ - apps/api/src/domains/autodns-parse.spec.ts
+ - apps/api/src/domains/domains-cache.ts
+ - apps/api/src/domains/domains-directory.service.ts
+ - apps/api/src/domains/domains-directory.service.spec.ts
+ - apps/api/src/domains/dto/domains-customer.dto.ts
+ - apps/api/src/domains/dto/domains-contact.dto.ts
+ - apps/web/src/components/domains/group-by-customer.ts
+ - apps/web/src/components/domains/group-by-customer.test.ts
+ - apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx
+ # Task 3 — availability, orders with money safety, job tracking, changelog, docs, full gates
+ - apps/api/src/domains/domain-name.ts
+ - apps/api/src/domains/domain-name.spec.ts
+ - apps/api/src/domains/domains-orders.service.ts
+ - apps/api/src/domains/domains-orders.service.spec.ts
+ - apps/api/src/domains/dto/domains-order.dto.ts
+ - apps/web/src/components/domains/order-status.ts
+ - apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx
+ - CHANGELOG.md
+ - docs/anleitung-anwender.md
+ - docs/anleitung-administration.md
+autonomous: true
+requirements: [QUICK-261008-dts]
+
+estimate:
+ tokens: 190000
+ raw_tokens: 190000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "After activation in the Marktplatz and a Freigabe, a user with Benutzen opens the module Domains and sees the tabs Domains, Kontakte, Kunden and Aufträge; a user with Verwalten or an administrator additionally sees Registrieren and Einstellungen, and the API answers 403 to Benutzen-only users on every settings, connection-test, customer-write, contact-create, contact-assign, availability, order-create, submit and cancel route"
+ - "A manager stores AutoDNS user, password and context separately for the Demo and the Live system, switches between them only after an explicit confirmation (default Demo), sets the default nameservers, and 'Verbindung testen' sends exactly one GET /hello to the fixed host of that environment with Basic auth, X-Domainrobot-Context and a Tessera User-Agent; the password is stored AES-encrypted via CryptoService and never appears in any API response"
+ - "The Kontakte tab lists every AutoDNS contact of the active environment with its customer or 'Nicht zugeordnet', can re-read the list from AutoDNS on demand, and lets managers create PERSON/ORG contacts (name, organisation, address, phone, e-mail) and assign one or many contacts to a customer; one customer can be marked 'Eigene Firma'; the list filters and groups by customer"
+ - "The Domains tab lists every AutoDNS domain of the active environment with customer (from the owner contact's assignment, else 'Nicht zugeordnet'), owner, expiry date and status, filterable and groupable by customer"
+ - "Registering requires availability FREE from DomainStudio, owner/admin/tech/zone contacts, 2 to 6 nameservers prefilled from the settings, a summary with environment and price, a ticked confirmation and the button 'Jetzt verbindlich registrieren'; the server sends POST /domain at most once per order (atomic DRAFT to SUBMITTING claim, no retry), answers 409 to a second confirmation or after an environment switch, and stores UNKNOWN instead of guessing when the outcome is unclear"
+ - "Order status follows the AutoDNS job (läuft, erfolgreich, fehlgeschlagen, Rückfrage nötig) whenever the Aufträge tab opens or refreshes; an unclear order is reconciled against AutoDNS and can only be discarded after a reconciliation found neither the domain nor a job"
+ - "All AutoDNS traffic goes through one client with the two fixed base URLs, at most one request start per 350 ms per process, a 20 s timeout, no retries, and status.type ERROR counts as failure even with HTTP 200; AutoDNS login failures reach the browser as 502, never as 401"
+ artifacts:
+ - path: "apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql"
+ provides: "DomainsConfig, DomainsCustomer, DomainsContactAssignment, DomainsOrder with tenant_isolation_policy"
+ contains: "DomainsOrder"
+ - path: "apps/api/src/domains/autodns-client.ts"
+ provides: "fixed Demo/Live base URLs, autodnsRequest (never throws), envelope parser, rate limiter"
+ exports: ["AUTODNS_BASE_URLS", "autodnsRequest", "parseAutodnsEnvelope", "AutodnsRateLimiter"]
+ - path: "apps/api/src/domains/domains-settings.service.ts"
+ provides: "encrypted per-environment credentials, masked settings view, connection test, active credentials"
+ - path: "apps/api/src/domains/domains-directory.service.ts"
+ provides: "customers, live contact/domain lists with customer join, contact create, assignment"
+ - path: "apps/api/src/domains/domains-orders.service.ts"
+ provides: "availability, draft, single-shot submit with atomic claim, job refresh, reconciliation, cancel"
+ - path: "apps/web/src/app/(portal)/modules/domains/page.tsx"
+ provides: "module page with environment badge and six tabs gated by useCanManageModule"
+ - path: "apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx"
+ provides: "availability, contact and nameserver choice, summary, explicit binding confirmation"
+ key_links:
+ - from: "apps/api/src/domains/domains-orders.service.ts submitOrder"
+ to: "domainsOrder.updateMany where status DRAFT and active environment, then autodnsRequest POST /domain once"
+ via: "count === 1 gate before the network call"
+ pattern: "status: 'DRAFT'"
+ - from: "apps/api/src/domains/domains-settings.service.ts"
+ to: "CryptoService encrypt/decrypt"
+ via: "per-environment password columns, masked response"
+ pattern: "crypto\\.encrypt\\("
+ - from: "apps/api/src/domains/domains-directory.service.ts listDomains"
+ to: "domainsContactAssignment of the active environment"
+ via: "owner contact id -> customer"
+ pattern: "ownerc"
+ - from: "apps/api/src/domains/domains.controller.ts"
+ to: "ModuleGuard"
+ via: "class UseModule('domains') + handler ModuleManage('domains')"
+ pattern: "@ModuleManage\\('domains'\\)"
+ - from: "apps/web/src/app/(portal)/modules/domains/page.tsx"
+ to: "useCanManageModule('domains')"
+ via: "Registrieren/Einstellungen tabs and write controls only for managers"
+ pattern: "useCanManageModule\\('domains'\\)"
+---
+
+
+New Tessera module "Domains" (slug `domains`) connected to the AutoDNS / InterNetX Domainrobot JSON API. Stage 1 delivers: settings with encrypted per-environment access and connection test, AutoDNS contacts with a local customer assignment, the domain list, and domain registration that can never order twice. Asynchronous AutoDNS jobs are tracked.
+
+Locked decisions from the request (cited below as L-xx):
+- L-01 Module "Domains" on the AutoDNS JSON API — Live `https://api.autodns.com/v1`, Demo `https://api.demo.autodns.com/v1`; HTTP Basic auth plus header `X-Domainrobot-Context`; no API key; a dedicated API user without 2FA.
+- L-02 Settings: API access (user, password AES-encrypted via CryptoService like the LDAP bind password, never returned to the client, context as a number field per environment), Demo/Live switch, default nameservers, connection test.
+- L-03 Contacts: list of the AutoDNS domain contacts; create via form (Person/Organisation, address, phone, e-mail); read in existing AutoDNS contacts; a contact can be assigned to a customer (domains mostly for customers, also for the own company — the own company is one customer entry); list filterable/groupable by customer.
+- L-04 Register a domain: availability check (DomainStudio), choose contacts from the list (owner/admin-c/tech-c/zone-c), nameservers prefilled, summary plus explicit confirmation "Jetzt verbindlich registrieren" (costs money). Double orders impossible: local order, atomic status change, exactly one POST without retry, UNKNOWN on an unclear outcome, order bound to its environment. Asynchronous jobs: track job status.
+- L-05 Domain list: domains from AutoDNS with customer (derived from the owner contact), owner, expiry date, status.
+- L-06 Rights: viewing with "Benutzen"; registering, creating contacts, customers and settings with "Verwalten" (or admin) — per route like Nextcloud-Status.
+- L-07 Transfer, cancellation (Kündigung) and DNS zones are out of scope; the AutoDNS client keeps a generic request method so these fit in without restructuring.
+- L-08 Patterns: Nextcloud-Status (261002-k67), Handelsware-Datev (settings tab), Design Mosaik (PageHeader, SettingsSection).
+- L-09 Tests with a mocked API (injected fetch); the real check against the Demo system happens only after the user enters Demo credentials.
+- L-10 UI texts German (formal Sie) and English; no tenant wording ("Mandant") in any UI text, changelog or guide.
+- L-11 CHANGELOG entry under "Unveröffentlicht" in simple words like the existing entries.
+- L-12 The module is usable after activation in the Marktplatz plus a Freigabe.
+
+Claude's discretion (decided here, apply as written):
+- D-A Identity: slug `domains`, name "Domains", version '1.0.0', category `domain-tools` (next to Domaincheck; admins can move it), description de "Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen" / en "Register domains with AutoDNS, assign contacts and customers", isSystem true. New ModuleIconId `earth` (lucide "earth" glyph: circle cx 12 cy 12 r 10 plus the paths `M21.54 15H17a2 2 0 0 0-2 2v4.54`, `M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17`, `M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05`) so it differs from Domaincheck's globe.
+- D-B Data model, one migration `20261008120000_domains_autodns`: enums `AutodnsEnvironment { DEMO LIVE }` and `DomainOrderStatus { DRAFT SUBMITTING SUBMITTED SUCCESS FAILED UNKNOWN CANCELED }`; tables `DomainsConfig` (singleton per tenantId), `DomainsCustomer`, `DomainsContactAssignment`, `DomainsOrder` (columns in Task 1). AutoDNS stays the source of truth for contacts and domains (read live, never mirrored); locally only what AutoDNS does not know (customer assignment) or what money safety needs (orders). AutoDNS contact ids and job ids are stored as decimal strings (opaque identifiers, no int32 overflow, no bigint JSON trouble); the API exposes contact ids as numbers.
+- D-C AutoDNS client: base URL only from the constant map DEMO/LIVE (no free URL input, no SSRF surface), TLS verified, `redirect: 'error'` (credentials never follow a redirect), `undiciFetch` with injectable `fetchImpl`, 20 s timeout per call, process-wide limiter (one request start per 350 ms — the documented limit is 3 per second per IP), NO retry anywhere (also not for reads), response body capped at 5 MiB, envelope parsed as `status.code ?? status.resultCode`, failure when HTTP is not 2xx OR `status.type === 'ERROR'` OR any `messages[].status === 'ERROR'`; error texts only from `messages[].text` (each cut to 200 chars, at most 5) — never headers, never the password. Header `X-Domainrobot-Demo` is never sent; the environment is chosen by base URL only.
+- D-D Credentials: separate columns per environment; save encrypts with `CryptoService.encrypt`; an empty or missing password field keeps the stored one (LDAP pattern); responses carry only `hasPassword`; a decrypt failure throws a loud InternalServerError ('Das gespeicherte AutoDNS-Passwort ließ sich nicht entschlüsseln. Bitte tragen Sie es in den Einstellungen neu ein.') and is logged — never treated as "no password". An environment counts as configured when user, password and context are all set. The Live context field is prefilled with 4 in the UI when empty; the Demo context has no default (A1 in the research).
+- D-E Environments: new installations start on DEMO. Switching to LIVE needs a UI confirmation dialog AND `confirmLive: true` in the request (400 code `confirmLiveRequired` otherwise). Every contact assignment and every order carries its environment; all reads use the active environment. A permanent badge in the page header shows "Demo-System (Testbetrieb)", "Live-System – Registrierungen kosten Geld" or "AutoDNS nicht eingerichtet".
+- D-F Error mapping: AutoDNS auth/permission failures map to HTTP 502 with code `autodnsAuth` (never 401/403 — the web treats 401 as an expired Tessera session); other AutoDNS failures 502 `autodnsError` with the joined message texts; timeout/network 504 `autodnsUnavailable`; not configured 409 `notConfigured`. The connection test always answers 200 with `{ ok, kind, message }`.
+- D-G "Bestehende Kontakte einlesen" = the contact list is read live from AutoDNS (button "Aus AutoDNS neu einlesen" bypasses the cache); unassigned contacts appear as "Nicht zugeordnet" and managers assign one or many to a customer. AutoDNS contacts are not edited or deleted in this stage (owner changes can affect domains, research pitfall 9).
+- D-H Customers: own table, name unique per organisation (409 `customerNameTaken`), at most one "Eigene Firma" (setting it clears the flag on the others), deleting a customer with assigned contacts → 409 `customerInUse`. No company name or nameserver is preset anywhere.
+- D-I Lists: page size 100, at most 2000 entries per list with `truncated: true` beyond, in-memory cache of 60 s keyed by tenant + environment + config version (`DomainsConfig.updatedAt`), invalidated by contact creation and by a successful submit; `?refresh=1` bypasses it.
+- D-J Domain list: `POST /domain/_search` with `keys[]=expire&keys[]=ownerc`; owner name from the (cached) contact list by owner id; customer = customer of the owner contact's assignment in the active environment, otherwise null ("Nicht zugeordnet"); status shown from `registryStatus` mapped to German labels with the raw value as fallback, plus "Kündigung vorgemerkt" when `cancelationStatus` is set.
+- D-K Availability: input normalised (trim, lowercase, strip `http(s)://`, path and trailing dot), converted with `domainToASCII` from `node:url` (umlaut domains become punycode), pre-filtered with an anchored hostname pattern; `POST /domainstudio` with `searchToken` = first label and `sources.initial` = `{ tlds: [rest], services: ['WHOIS', 'PRICE'] }`, currency EUR; only the envelope whose `domain` equals the requested name counts; only WHOIS status `FREE` is orderable (everything else including ERROR/TIMEOUT is not); price = the 1-year entry (else the first), `null` when missing → UI shows "Preis nicht ermittelbar" in the summary.
+- D-L Orders: one open order per (tenant, environment, domain) enforced by the nullable column `openKey` with `@@unique([tenantId, environment, openKey])` (Postgres lets NULLs repeat, Prisma can express it, no drift). `openKey` = domain name while the order is DRAFT, SUBMITTING, SUBMITTED, UNKNOWN or SUCCESS; set to null on FAILED and CANCELED. SUCCESS keeps the key on purpose: right after a registration a lagging WHOIS could still say FREE. A new draft for a domain with an existing DRAFT updates that draft (same id); any other open state → 409 `orderOpen`.
+- D-M Submit protocol: `updateMany where { id, tenantId, status: DRAFT, environment: }` → data `{ status: SUBMITTING, confirmedAt, confirmedByUserId, confirmedByUsername }`; count 0 → load the row → 404 when missing, 409 `environmentChanged` when its environment differs from the active one, else 409 `alreadySubmitted`. Count 1 → exactly one `POST /domain` (20 s timeout, no retry) → parsed success with job → SUBMITTED + jobId + jobStatus; parsed AutoDNS refusal (business/auth/http with envelope) → FAILED + errorText, openKey null; thrown error, timeout or unparseable body → UNKNOWN. Never back to DRAFT. A SUBMITTING row older than 120 s (process died mid-call) becomes UNKNOWN on the next refresh.
+- D-N Job tracking is pull-based: `POST orders/refresh` checks up to 20 open orders (oldest `lastCheckedAt` first) whenever the Aufträge tab opens, on "Aktualisieren", and every 30 s while open orders exist and the page is visible. No cron job and no system-context read (the module needs no `forSystem` call and no `system_read_policy`); `refreshOrder` is the single entry point. Job mapping: SUCCESS → SUCCESS; FAILED/CANCELED → FAILED/CANCELED (openKey null, errorText from messages); RUNNING/WAIT/DEFERRED/NOT_SET → stays SUBMITTED with that jobStatus; SUPPORT → stays SUBMITTED, shown as "Rückfrage nötig". UNKNOWN reconciliation: `GET /domain/{name}` succeeds → SUCCESS; else `POST /job/_search` filtered by `object` = domain, newest job created after `confirmedAt` minus 5 min → SUBMITTED with that job; else stays UNKNOWN with `lastCheckedAt` set.
+- D-O Registration form: period fixed 1 year; all four contacts required; defaults admin-c = owner, tech-c and zone-c = first contact of the "Eigene Firma" customer if one exists, else the owner; nameservers prefilled from the settings, 2 to 6 required; the request never asks AutoDNS to skip its WHOIS check (no query parameters on `POST /domain`).
+- D-P Rights per route (all under `@Controller('modules/domains')`, class `@UseModule('domains')`): Benutzen = GET status, GET customers, GET contacts, GET domains, GET orders, POST orders/refresh (only syncs state from AutoDNS, changes nothing there). Verwalten (`@ModuleManage('domains')`, never with a role decorator) = GET settings, PUT settings, POST connection-test, POST customers, PUT customers/:id, DELETE customers/:id, POST contacts, POST contacts/assign, POST availability, POST orders, POST orders/:id/submit, POST orders/:id/cancel. Static routes are declared before every `:id` route.
+
+Output: migration + models, API module (client, parsers, cache, three services, controller, seed), module page with six tabs, tests, docs, changelog, rebuilt local stack. Three atomic 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/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-RESEARCH.md
+
+Discovered facts the executor can rely on (verified during planning on 2026-10-08):
+- Templates: `apps/api/src/nextcloud-status/{nextcloud-status.controller.ts, nextcloud-status.module.ts, nextcloud-status.seed.ts, nextcloud-status-fetch.ts}` (controller with `requireTenantId`, class `@UseModule`, handler `@ModuleManage`, seed via `seedModule`, injected `fetchImpl`), `apps/api/src/handelsware-datev/{handelsware-datev.controller.ts, handelsware-datev.service.ts}` (singleton config via `forTenant(...)..findUnique({ where: { tenantId } })` + `upsert`, errors as `{ code, message }` objects), `apps/api/src/proxmox/proxmox-client.service.ts` (`undiciFetch` instead of global fetch, AbortController timeout, certificate error codes, short error details).
+- `CryptoService` (`apps/api/src/crypto/crypto.service.ts`) is provided by the GLOBAL `CryptoModule` — inject it, do not import a module. `encrypt(plain)` → `iv:authTag:ciphertext`; `decrypt` throws on bad input. LDAP precedent for keep-if-empty and masking: `apps/api/src/ldap/ldap-config.service.ts` around `decryptBindPassword` and the update path.
+- `PrismaService` is global; `forTenant` from `apps/api/src/prisma/prisma-tenant.extension.ts` wraps every model op in a one-element transaction that sets the tenant — `updateMany` through it returns `{ count }` and is atomic in Postgres (a concurrent second UPDATE re-checks the WHERE after the first commits). Never hold a transaction across an AutoDNS call. Never use `include:` or relation `select:` in this module (rls inventory).
+- Global `ValidationPipe({ whitelist: true, transform: true })` in `apps/api/src/main.ts`; `class-validator` 0.15, `class-transformer`, `undici` 7.28.0 are already dependencies — no new packages.
+- Guard: `ModuleGuard` needs the module activated for the tenant (also for admins); admins and MANAGE grants pass `@ModuleManage`. `apps/api/src/module-registry/module-manage-handlers.spec.ts` has helpers `expectManage(controller, name, slug)` and the USE-level pattern (see the `NextcloudStatusController` blocks).
+- RLS gates: `apps/api/src/prisma/rls-coverage.spec.ts` needs ENABLE + FORCE + `tenant_isolation_policy` for each new table in the migration; `apps/api/src/prisma/rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (also maintain the Bereichszeile, the Summenzeile and the Paarzählung paragraph — follow the `handelsware-datev` and `module-categories` rows, recount with the Gate-Schleife `for d in apps/api/src/*/`, never copy numbers). Planned pairs: `domains-settings.service.ts`/`domainsConfig` (Task 1), `domains-directory.service.ts`/`domainsCustomer` and `/domainsContactAssignment` (Task 2), `domains-orders.service.ts`/`domainsOrder` (Task 3), all `muss-mandantengebunden` / `gebunden`. No `forSystem` anywhere in this module.
+- Migration convention: hand-written SQL with a German header comment (model `apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql`; enum precedent `20261002140000_module_grant_level` uses `CREATE TYPE ... AS ENUM`). Latest existing migration: `20261003120000_module_categories`. Local DB has no host port: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, then `DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera"` for `pnpm --filter @tessera/api exec prisma migrate deploy|status|diff`. The api container also runs migrate deploy on start.
+- Web registration points: `apps/web/src/lib/module-loader.ts` (dynamic page import, ssr false), `apps/web/src/lib/module-identity.ts` (`ModuleIconId` union + ICONS map), `apps/web/src/components/modules/module-tile.tsx` (GLYPHS map keyed by ModuleIconId, inline SVG children), `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). Module route `/modules/domains` (own layout with `ModuleAccessGate`) and the sidebar route `/modules//domains` via the generic page and module-loader.
+- UI building blocks: `PageHeader` (`@/components/layout/page-header`, props title/description/actions/moduleSlug), `TabBar` (`@/components/accounting/tab-bar`), `SettingsSection` (`@/components/control-center/settings-section`, card with title/description/actions/footer/flush; its `cc-section` styles are global in `apps/web/src/app/globals.css`), `useCanManageModule` (`@/lib/use-module-capability`, null while loading → treat as false). Status tokens: `bg-status-ok|warn|down|idle`, pill form `bg-status-warn/12 text-status-warn-fg` (literal class strings only). No shared confirm-dialog component exists — build the confirmation inline like `CloudForm.tsx` in nextcloud-status.
+- i18n: new top-level namespace `domains` in `apps/web/src/messages/de.json` and `en.json` (free, verified). `apps/web/src/messages/umlaut-guard.spec.ts` rejects ae/oe/ue/ss tokens in de.json unless listed in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) — write real umlauts, allowlist only legitimately correct tokens after running the test. There is no general de/en parity test; Task 3 verifies the `domains` keys with a node check.
+- Local stack is running (api, db, web, mailhog); `admin` / `admin123` logs in at `http://localhost:3001/auth/login` (200 on 2026-10-08); `GET /modules/catalog` returns `{ id, slug, isActiveForTenant, ... }`; `POST /modules//activate` activates as admin; `GET /health` answers `{"status":"ok"}`. Rebuild with `docker compose up -d --build api` (plain `up` does not rebuild).
+- AutoDNS facts (research, verified against the OpenAPI): envelope `{ status: { code, text, type }, stid, object: { type, value, summary }, messages: [{ code, text, status }], data: [...] }`; `GET /hello` tests login; `POST /contact/_search` and `POST /domain/_search` take `{ filters, view: { limit, offset }, orders }` and report the total in `object.summary`; `POST /contact` answers `data[0].id`; `POST /domain` is asynchronous and answers a job (`data[0].id`, fallback `object.value` when `object.type === 'job'`); `GET /job/{id}` status enum RUNNING, SUCCESS, FAILED, CANCELED, SUPPORT, DEFERRED, NOT_SET, WAIT (read `data[0].job.status ?? data[0].status`); DomainStudio WHOIS status at `data[i].services.whois.data.status`, price entries at `data[i].services.price.data.prices[]` (read `amount`/`currency` directly or under `price`). Wrong login: HTTP 401 with `messages[0].code` `EF00202`.
+- Pitfall from STATE.md ("Tautologischer Test"): tests against an external system must assert the SHAPE and literal values of the outgoing call (method, exact URL, exact header set, exact JSON body written out in the test), never values rebuilt with the production helper. Example literal: user `api-user`, password `geheim` → `Authorization: Basic YXBpLXVzZXI6Z2VoZWlt`.
+- Commits: German subject, conventional prefix `feat(domains):`, body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) `. Never push (the user bundles pushes). PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor. No deploy to the test server.
+
+@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
+@apps/api/src/handelsware-datev/handelsware-datev.service.ts
+@apps/api/src/proxmox/proxmox-client.service.ts
+@apps/api/src/crypto/crypto.service.ts
+@apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
+@apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
+@apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx
+
+
+
+
+
+ Task 1: Tracer — a manager stores AutoDNS access and tests the connection (DB → encrypted settings → AutoDNS client → API → module page with Einstellungen)
+ apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql, apps/api/src/domains/autodns-client.ts, apps/api/src/domains/autodns-client.spec.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/domains-settings.service.ts, apps/api/src/domains/domains-settings.service.spec.ts, apps/api/src/domains/dto/domains-settings.dto.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/domains/domains.seed.ts, apps/api/src/domains/domains.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/domains-api.ts, apps/web/src/app/(portal)/modules/domains/layout.tsx, apps/web/src/app/(portal)/modules/domains/page.tsx, apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx, apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/domains/domains-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
+ The local stack (db, api, web) is running and `admin`/`admin123` logs in at http://localhost:3001/auth/login.
+
+ - autodnsRequest (injected fetch, limiter with 0 ms spacing unless stated): DEMO targets exactly `https://api.demo.autodns.com/v1/hello`, LIVE exactly `https://api.autodns.com/v1/hello`; an environment value outside DEMO/LIVE throws before any fetch; GET sends exactly the headers Authorization `Basic YXBpLXVzZXI6Z2VoZWlt` (user api-user, password geheim), `X-Domainrobot-Context` '4', Accept 'application/json', User-Agent starting with 'Tessera/' — and no Content-Type; POST with body adds Content-Type 'application/json' and sends the JSON body; options carry `redirect: 'error'`; `keys: ['expire','ownerc']` appends `?keys[]=expire&keys[]=ownerc`; a path containing '..', '?' or '//' throws before fetch.
+ - Envelope: HTTP 200 + status.type SUCCESS → ok true with data, object (type/value/summary), statusCode; HTTP 200 + status.type ERROR → ok false kind 'business' with the message texts; `status.resultCode` is read when `code` is missing; HTTP 401 → kind 'auth', 403 → 'forbidden', 429 → 'rate-limit', other non-2xx with envelope → 'business', without envelope → 'http'; HTML or empty 200 body → 'invalid-response'; never-resolving fetch with a 20 ms timeout → 'timeout'; rejection with cause.code ENOTFOUND → 'network'; CERT_HAS_EXPIRED → 'tls'; body over the 5 MiB cap → 'invalid-response'; message texts cut to 200 chars, at most 5; JSON.stringify(result) never contains the password or 'Basic '; a failing fetch is called exactly once (no retry).
+ - AutodnsRateLimiter (fake clock): three scheduled calls start at t=0, ≥350 ms, ≥700 ms; a rejected task does not block the next one.
+ - DomainsSettingsService (mocked prisma via forTenant, mocked CryptoService with encrypt → 'enc()'): getSettings without row → environment DEMO, demo/live { user null, hasPassword false, context null }, defaultNameServers [], configured { demo false, live false }; saveSettings with demoPassword 'geheim' stores demoEncryptedPassword 'enc(geheim)'; saving without password (or empty) keeps the stored encrypted value; only provided fields change (partial update); response and JSON.stringify(response) contain neither 'geheim' nor 'enc(' nor any key with 'ncrypted'; environment LIVE from DEMO without confirmLive → BadRequest code confirmLiveRequired, with confirmLive true → saved; defaultNameServers lowercased, trimmed, exactly one entry → BadRequest, 7 entries → BadRequest, invalid hostname → BadRequest; getStatus → { environment, configured (active env), demoConfigured, liveConfigured, defaultNameServers }; testConnection for an unconfigured environment → { ok false, kind 'not-configured' } without fetch; configured DEMO → exactly one GET to `https://api.demo.autodns.com/v1/hello` with the decrypted password, 200 SUCCESS → { ok true }, 401 → { ok false, kind 'auth', message mentions Benutzername, Passwort und Kontext }; decrypt throwing → InternalServerErrorException, not a silent "no password"; getActiveCredentials returns { environment, credentials, configVersion } or throws ConflictException code notConfigured.
+ - Controller metadata: class MODULE_SLUG_KEY 'domains' with ModuleGuard; getStatus has no MODULE_MANAGE_KEY; getSettings, saveSettings, testConnection have MODULE_MANAGE_KEY true and no ROLES_KEY.
+ - Web page test (mock `@/lib/domains-api`, `@/lib/use-module-capability`, next-intl like the nextcloud-status page test): manager sees the tab "Einstellungen" and the badge "Demo-System (Testbetrieb)"; status LIVE shows "Live-System – Registrierungen kosten Geld"; not configured shows "AutoDNS nicht eingerichtet" plus the setup hint; a non-manager does not see "Einstellungen"; SettingsTab: password inputs start empty with the placeholder for a stored password when hasPassword is true; the Live context input shows 4 when the stored value is null; choosing "Live-System" opens a confirmation and only the confirmed save sends `confirmLive: true`; "Verbindung testen" calls testConnection once per click, is disabled while running and shows the success or error text.
+
+
+**Schema + migration (D-B, L-02).** In `apps/api/prisma/schema.prisma`, after the Nextcloud-Status models, add a German comment block (quick-261008-dts; AutoDNS is the source of truth; ids as strings per D-B; RLS like ProxmoxServer; no relation to Tenant) and: enum `AutodnsEnvironment { DEMO LIVE }`; enum `DomainOrderStatus { DRAFT SUBMITTING SUBMITTED SUCCESS FAILED UNKNOWN CANCELED }`; model `DomainsConfig` (`id` uuid, `tenantId String @unique`, `environment AutodnsEnvironment @default(DEMO)`, `demoUser String?`, `demoEncryptedPassword String?`, `demoContext Int?`, `liveUser String?`, `liveEncryptedPassword String?`, `liveContext Int?`, `defaultNameServers String[] @default([])`, createdAt, updatedAt @updatedAt, `@@index([tenantId])`); model `DomainsCustomer` (`id`, `tenantId`, `name`, `isOwnCompany Boolean @default(false)`, back-relation `assignments DomainsContactAssignment[]`, timestamps, `@@unique([tenantId, name])`, `@@index([tenantId])`); model `DomainsContactAssignment` (`id`, `tenantId`, `environment AutodnsEnvironment`, `autodnsContactId String`, `customerId String` with relation to DomainsCustomer `onDelete: Restrict`, timestamps, `@@unique([tenantId, environment, autodnsContactId])`, `@@index([tenantId])`, `@@index([customerId])`); model `DomainsOrder` (`id`, `tenantId`, `environment AutodnsEnvironment`, `domainName String`, `openKey String?`, `status DomainOrderStatus @default(DRAFT)`, `payload Json`, `jobId String?`, `jobStatus String?`, `errorText String?`, `createdByUserId String`, `confirmedByUserId String?`, `confirmedByUsername String?`, `confirmedAt DateTime?`, `lastCheckedAt DateTime?`, timestamps, `@@unique([tenantId, environment, openKey])`, `@@index([tenantId])`). To get the exact DDL Prisma expects (TEXT[] default, FK clause, index names), run `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script` against the local DB BEFORE writing the file and use that DDL as the body. Hand-write `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql`: German header (purpose of the four tables; openKey rule from D-L; `tenant_isolation_policy` WITHOUT user dimension because these are organisation data; NO `system_read_policy` because no background job reads across tenants, D-N; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note as in the handelsware header), both `CREATE TYPE ... AS ENUM`, the tables, indexes, FK, then per table ENABLE + FORCE ROW LEVEL SECURITY and `CREATE POLICY tenant_isolation_policy ... USING ("tenantId" = current_tenant_id())`. Run `pnpm --filter @tessera/api exec prisma generate`, apply locally via the container IP (`migrate deploy`), confirm `migrate status` is up to date and `migrate diff ... --exit-code` exits 0.
+
+**AutoDNS client (D-C, L-01, L-07).** `apps/api/src/domains/autodns-client.ts`, framework-free: `AUTODNS_BASE_URLS` constant (DEMO/LIVE URLs from L-01, `as const`), `AutodnsCredentials { environment; user; password; context: number }`, `AutodnsFailureKind` ('auth' | 'forbidden' | 'rate-limit' | 'business' | 'http' | 'timeout' | 'network' | 'tls' | 'invalid-response'), result union `{ ok: true; httpStatus; statusCode; statusType; object; data: unknown[]; messages: string[]; stid }` / `{ ok: false; kind; httpStatus: number | null; statusCode; messages; stid }`. Export pure `parseAutodnsEnvelope(httpStatus, text)`, `buildAutodnsHeaders(credentials, hasBody)`, class `AutodnsRateLimiter` (constructor `minIntervalMs = 350`, injectable `now` and `sleep`; `schedule(task)` chains starts ≥ minIntervalMs apart; failures do not break the chain) with a module-level default instance, and `autodnsRequest(credentials, method: 'GET' | 'POST' | 'PUT', path, opts?: { body?, keys?, fetchImpl?, timeoutMs?, limiter? })` that NEVER throws for network/HTTP problems (only for programming errors: unknown environment, bad path). Path must start with '/', contain no '..', '?' or '//'; callers encode dynamic segments with encodeURIComponent. Use `undiciFetch` by default (comment why not global fetch, pattern proxmox-client.service.ts), `redirect: 'error'`, AbortController with `AUTODNS_TIMEOUT_MS = 20_000`, capped body reader `AUTODNS_MAX_BODY_BYTES = 5 * 1024 * 1024`, certificate codes → 'tls' (copy the set from proxmox-client.service.ts), User-Agent `Tessera/${process.env.APP_VERSION || 'dev'}`. German header comment: fixed hosts (no SSRF), Basic auth + context header (L-01), no retry ever and why (money: a repeated POST /domain could register twice; login: repeated wrong logins can lock the user), 3 requests per second per IP, HTTP 200 with status.type ERROR is a failure, never log or return headers. Keep the generic `autodnsRequest` so transfer/cancellation/zones (L-07) need no new transport. Spec `autodns-client.spec.ts` per `` with literal URLs, headers and bodies.
+
+**Settings service + DTO (D-D, D-E, D-F, L-02).** `apps/api/src/domains/domains.types.ts` for shared view types. `dto/domains-settings.dto.ts` `SaveDomainsSettingsDto`, every field optional: `environment` (IsIn DEMO/LIVE), `confirmLive` (IsBoolean), `demoUser`/`liveUser` (IsString, MaxLength 100), `demoPassword`/`livePassword` (IsString, MaxLength 200), `demoContext`/`liveContext` (IsInt, Min 1, Max 2147483647, nullable via ValidateIf), `defaultNameServers` (IsArray, ArrayMaxSize 6, each IsString MaxLength 253). `domains-settings.service.ts` (`@Injectable`, inject PrismaService and CryptoService; every method its own `const tenantPrisma = forTenant(this.prisma, tenantId)`; all access to `domainsConfig` only here): `getStatus`, `getSettings` (masked view per ``), `saveSettings` (read current row, enforce confirmLive for a switch to LIVE, normalise and validate nameservers — 0 or 2..6, anchored hostname pattern, German messages 'Bitte geben Sie mindestens zwei Nameserver an.' / 'Höchstens sechs Nameserver sind möglich.' / 'Der Nameserver „{name}“ ist kein gültiger Rechnername.' — encrypt non-empty passwords, upsert only provided fields, return the masked view), `testConnection(tenantId, environment)` (single `autodnsRequest(GET '/hello')`, result `{ ok, kind?, message }` with German messages: success 'Verbindung erfolgreich. AutoDNS hat die Anmeldung bestätigt.', auth 'Anmeldung bei AutoDNS fehlgeschlagen. Bitte prüfen Sie Benutzername, Passwort und Kontext.', timeout/network/tls their own short German texts, business → 'AutoDNS meldet: '), `getActiveCredentials(tenantId)` → `{ environment, credentials, configVersion: updatedAt ms }` or ConflictException `{ code: 'notConfigured', message: 'AutoDNS ist für das gewählte System noch nicht eingerichtet. Bitte hinterlegen Sie den Zugang in den Einstellungen.' }`, plus a private `decryptPassword` that throws the loud error from D-D. Also export a small helper `autodnsFailureToHttp(result)` (in this file or domains.types.ts) that maps a failed result to the D-F exceptions with `{ code, message }` — used by Tasks 2 and 3. Spec per ``.
+
+**Controller + seed + module (L-06, L-12, D-A, D-P).** `domains.controller.ts`: `@Controller('modules/domains')`, class `@UseModule('domains')`, `requireTenantId` like NextcloudStatusController; handlers in this order: `@Get('status') getStatus`; `@Get('settings') @ModuleManage('domains') getSettings`; `@Put('settings') @ModuleManage('domains') saveSettings`; `@Post('connection-test') @ModuleManage('domains') testConnection` (body `{ environment }` validated by a tiny DTO with IsIn). German header comment: rights table of D-P, rule "static routes before any `:id` route" (Tasks 2 and 3 add `:id` routes at the end), never a role decorator on manage handlers. `domains.seed.ts` per D-A (pattern nextcloud-status.seed.ts). `domains.module.ts` imports ModuleRegistryModule, provides DomainsSettingsService, OnModuleInit seeds with try/catch and logs 'Domains module seeded in registry'. Register `DomainsModule` in `apps/api/src/app.module.ts` next to NextcloudStatusModule. `domains.controller.spec.ts` asserts the metadata from `` (Reflect.getMetadata on prototype methods). In `apps/api/src/module-registry/module-manage-handlers.spec.ts` add a `DomainsController` block: it.each over the manage handlers with `expectManage(..., 'domains')` and a USE-level it.each (`getStatus`).
+
+**RLS inventory doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `domains`, update Summenzeile and Paarzählung, and add the Fundstellentabelle row `apps/api/src/domains/domains-settings.service.ts` / `domainsConfig` (`muss-mandantengebunden`, `gebunden`, German explanation: singleton per organisation, encrypted passwords, policy without user dimension, no system policy) in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife; both specs green.
+
+**Web tracer (L-02, L-08, L-10, D-D, D-E).** `apps/web/src/lib/domains-api.ts` (pattern nextcloud-status-api.ts: `NEXT_PUBLIC_API_URL`, `credentials: 'include'`, `cache: 'no-store'` on GETs): class `DomainsRequestError(status, code, message)` built from the API `{ code, message }`; types `DomainsEnvironment`, `DomainsStatus`, `DomainsSettings`, `SaveDomainsSettingsInput`, `ConnectionTestResult`; functions `getDomainsStatus`, `getDomainsSettings`, `saveDomainsSettings`, `testDomainsConnection(environment)`. `layout.tsx` = ModuleAccessGate moduleSlug "domains" (copy handelsware-datev/layout.tsx). `components/EnvironmentBadge.tsx`: pill with literal classes per D-E (Demo `bg-status-warn/12 text-status-warn-fg`, Live `bg-status-down/12 text-status-down-fg`, not configured `bg-status-idle/12 text-status-idle-fg`). `page.tsx` ('use client'): `const canManage = useCanManageModule('domains') === true`; loads `getDomainsStatus` once (exposes a reload callback to children); `PageHeader moduleSlug="domains"` with title, description and the badge as `actions`; `TabBar` with typed tab ids (this task: only 'settings' for managers; Tasks 2/3 add 'domains', 'contacts', 'customers', 'register', 'orders'); when the active environment is not configured, a hint card ("AutoDNS ist noch nicht eingerichtet." + for managers a button "Zu den Einstellungen", for others "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten."). `components/SettingsTab.tsx` built from `SettingsSection` cards, each card saving only its own fields (partial PUT): "System" (radio "Demo-System (Testbetrieb)" / "Live-System (kostenpflichtig)", explanation, switching to Live opens an inline confirmation "Ab jetzt laufen Registrierungen über das Live-System von AutoDNS und kosten Geld." with "Live-System verwenden" / "Abbrechen"; only the confirmed save sends `confirmLive: true`), "Zugang Demo-System" and "Zugang Live-System" (Benutzername, Passwort type password autoComplete new-password with placeholder "Gespeichert – leer lassen, um es beizubehalten" when hasPassword, Kontext as number input — Live prefilled 4 when null — hint "Verwenden Sie einen eigenen AutoDNS-Benutzer für Tessera ohne Zwei-Faktor-Anmeldung."; footer "Verbindung testen" + "Speichern"; the test button is disabled while running and while the card has unsaved changes, with hint "Bitte zuerst speichern"), "Standard-Nameserver" (2 to 6 inputs with add/remove, hint that the nameservers must already be set up, footer "Speichern"). After each save reload the status (badge). Registrations: module-loader.ts entry `domains`; module-identity.ts `earth` in the `ModuleIconId` union and `domains: 'earth'`; module-tile.tsx GLYPHS `earth` with the D-A glyph; nav-store.ts `domains: 'domains.title'`; module-layouts.test.tsx add `['domains', DomainsLayout]`. Messages: new top-level `domains` namespace in de.json (formal Sie, real umlauts) and en.json with identical keys (title "Domains", description, environment.*, tabs.*, notConfigured.*, settings.*, errors.request). Run the umlaut guard; allowlist only correct tokens if it fails. `domains-page.test.tsx` per ``.
+
+**Tracer run.** Biome-lint the touched files (`pnpm exec biome lint ` from the repo root; `biome check --write` only on new files). Rebuild the api (`docker compose up -d --build api`), wait until `curl -sf http://localhost:3001/health` answers, check `docker compose logs api` for 'Domains module seeded in registry', then run the `` command. Commit `feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen` (attribution line). Do not push.
+
+
+ pnpm --filter @tessera/api exec vitest run src/domains rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/domains module-layouts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && A=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && MID=$(curl -sf -b "$A" http://localhost:3001/modules/catalog | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s).find(x=>x.slug==="domains");if(!m)process.exit(1);process.stdout.write(m.isActiveForTenant?"":m.id)})') && { [ -z "$MID" ] || curl -sf -b "$A" -X POST "http://localhost:3001/modules/$MID/activate" >/dev/null; } && curl -sf -b "$A" http://localhost:3001/modules/domains/status | grep -q '"environment"' && S=$(curl -sf -b "$A" http://localhost:3001/modules/domains/settings) && echo "$S" | grep -q '"hasPassword"' && ! echo "$S" | grep -qi 'ncrypted' && echo "tracer e2e ok"
+ non-zero exit and no "tracer e2e ok": a spec, tsc run, the login, the catalog lookup (module not seeded), the activation, GET status or GET settings failed, or the settings answer leaks an encrypted-password field
+
+ Migration applied locally without drift; client, settings service and controller specs green; module seeded and activatable; GET status and the masked GET settings answer through the real stack; the module page shows the environment badge and the Einstellungen tab with per-environment access, Live confirmation, nameservers and connection test; registrations (loader, icon, nav title, layouts test) done; RLS gates green; commit on main, not pushed.
+
+
+
+ Task 2: Customers, AutoDNS contacts (read in, create, assign) and the domain list, filterable and groupable by customer
+ apps/api/src/domains/autodns-parse.ts, apps/api/src/domains/autodns-parse.spec.ts, apps/api/src/domains/domains-cache.ts, apps/api/src/domains/domains-directory.service.ts, apps/api/src/domains/domains-directory.service.spec.ts, apps/api/src/domains/dto/domains-customer.dto.ts, apps/api/src/domains/dto/domains-contact.dto.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/domains/domains.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/domains-api.ts, apps/web/src/components/domains/group-by-customer.ts, apps/web/src/components/domains/group-by-customer.test.ts, apps/web/src/app/(portal)/modules/domains/page.tsx, apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx, apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx, apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx, apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx, apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx, apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts
+
+ - parseContacts: AutoDNS contact objects → { id (number), type, displayName (organization, else 'fname lname', else alias, else '#id'), fname, lname, organization, address (string[]), pcode, city, country, email, phone, alias }; id given as string '123' → 123; entries without a numeric id are skipped; parseDomains: → { name, expire (ISO string or null), registryStatus, cancelationStatus, ownerContactId (from ownerc.id or a bare number, else null) }.
+ - TtlCache (domains-cache.ts, injected clock): get after 59 s hits, after 61 s misses; set prunes expired entries; delete by prefix.
+ - listContacts (mocked autodnsRequest + prisma): first call POST /contact/_search with body exactly { filters: [], view: { limit: 100, offset: 0 }, orders: [{ key: 'lname', type: 'ASC' }] }; object.summary 250 → three calls with offsets 0, 100, 200; summary 5000 → stops at 2000 entries and truncated true; each contact carries customerId/customerName from the assignment of the ACTIVE environment only (an assignment of the other environment with the same contact id is ignored); second call within 60 s does not call AutoDNS; refresh true does; config version change misses the cache; AutoDNS auth failure → 502 code autodnsAuth; not configured → 409 notConfigured.
+ - createContact: PERSON dto → exactly one POST /contact with body exactly { type: 'PERSON', fname: 'Erika', lname: 'Muster', address: ['Musterstraße 1'], pcode: '12345', city: 'Berlin', country: 'DE', email: 'erika@example.com', phone: '+49 30 123456' } (no organization key); ORG adds organization and requires it (BadRequest without); response data[0].id 4711 → returns { id: 4711, ... }; with customerId → creates the assignment (environment active, autodnsContactId '4711'); unknown customerId → NotFoundException BEFORE the AutoDNS call; the contact cache of the tenant/environment is cleared.
+ - assignContacts: { contactIds: [1, 2, 2], customerId } → upsert per distinct id in the active environment; customerId null → deleteMany of those ids in the active environment; foreign customerId → NotFoundException; more than 500 ids → BadRequest.
+ - Customers: create trims the name, duplicate → 409 customerNameTaken; isOwnCompany true clears the flag on all other customers of the tenant first; update/delete of a foreign id → NotFoundException (where id + tenantId); delete with assignments → 409 customerInUse; listCustomers returns { id, name, isOwnCompany, contactCount (assignments in the active environment) } ordered by name.
+ - listDomains: POST /domain/_search?keys[]=expire&keys[]=ownerc with body { filters: [], view: { limit: 100, offset: 0 }, orders: [{ key: 'name', type: 'ASC' }] }; owner name from the contact list; customer from the owner's assignment (active environment), null when unassigned or no owner; truncated flag like contacts.
+ - Controller: getStatus, listCustomers, listContacts, listDomains have no MODULE_MANAGE_KEY; createCustomer, updateCustomer, deleteCustomer, createContact, assignContacts have MODULE_MANAGE_KEY true and no ROLES_KEY; every handler whose path contains ':id' is declared after all static handlers (index check on Object.getOwnPropertyNames of the prototype).
+ - groupByCustomer (web): groups by customerName with German collation, "Nicht zugeordnet" group last; the own-company customer group first; filter value 'all' / '' / 'unassigned'; text search case-insensitive over the given fields; input not mutated.
+ - ContactForm: PERSON needs Vorname, Nachname, Straße, PLZ, Ort, Land, E-Mail, Telefon; ORG additionally Organisation; phone must start with '+' (hint "Internationale Schreibweise, z. B. +49 30 123456"); invalid e-mail blocks submit; submit calls createContact once with the trimmed values and the chosen customer; API error text is shown in the form; the submit button is disabled while saving.
+ - Page: a USE user sees Domains, Kontakte, Kunden but not Einstellungen and no "Neuer Kontakt", no assignment controls, no customer edit buttons; a manager sees all of them; Domains tab with a mocked list renders groups per customer with "Nicht zugeordnet" last, the expiry date as dd.mm.yyyy and the status label; choosing a customer in the filter hides the other groups.
+
+
+**Parsers and cache (D-G, D-I, D-J).** `autodns-parse.ts` (pure, no Nest): `parseContacts(data)`, `parseDomains(data)` defensive as in `` (unknown fields ignored, strings capped at 200 chars). `domains-cache.ts`: small `TtlCache` (ttl ms, injectable `now`, `get`, `set`, `deleteByPrefix`), German comment why in-memory and why the key contains the config version (D-I). Specs per ``.
+
+**Directory service + DTOs (L-03, L-05, D-G, D-H, D-I, D-J).** `dto/domains-customer.dto.ts`: `DomainsCustomerDto { name (IsString, IsNotEmpty, MaxLength 120); isOwnCompany? (IsBoolean) }`. `dto/domains-contact.dto.ts`: `CreateDomainsContactDto { type (IsIn PERSON/ORG); organization? (MaxLength 120); fname, lname (IsNotEmpty, MaxLength 80); street (IsArray, ArrayMinSize 1, ArrayMaxSize 3, each IsString IsNotEmpty MaxLength 100); pcode (MaxLength 20); city (MaxLength 80); country (Matches /^[A-Z]{2}$/); email (IsEmail, MaxLength 200); phone (Matches /^\+[0-9][0-9 .\-\/]{5,30}$/); customerId? (IsUUID) }` and `AssignDomainsContactsDto { contactIds (IsArray, ArrayMinSize 1, ArrayMaxSize 500, each IsInt Min 1); customerId (IsUUID or null via ValidateIf) }`. `domains-directory.service.ts` (inject PrismaService and DomainsSettingsService; all access to `domainsCustomer` and `domainsContactAssignment` only here; each method its own `forTenant` client with `where` including tenantId; no include/relation select): `listCustomers`, `createCustomer`, `updateCustomer`, `deleteCustomer` (P2002 → 409, assignments → 409, foreign id → 404), `listContacts(tenantId, { refresh })` → `{ environment, contacts, truncated, fetchedAt }`, `createContact(tenantId, dto)` (build the exact AutoDNS body from ``; send phone trimmed with inner whitespace collapsed; never send a customer field to AutoDNS), `assignContacts(tenantId, dto)`, `listDomains(tenantId, { refresh })` → `{ environment, domains: [{ name, expire, status, cancelationPending, ownerContactId, ownerName, customerId, customerName }], truncated, fetchedAt }`, and a public `findContactsByIds(tenantId, ids)` (used by Task 3 for the summary). Paging helper loops `view.offset` in steps of 100 until `object.summary` or 2000 entries are reached — calls run sequentially through the client's limiter, never in parallel. AutoDNS failures go through `autodnsFailureToHttp` (D-F). Spec per `` with mocked `autodnsRequest` (vi.mock of `./autodns-client` keeping `parseAutodnsEnvelope` real is fine) and literal request bodies.
+
+**Controller routes (L-06, D-P).** Add, in this order after the Task 1 handlers and before any `:id` route: `@Get('customers') listCustomers`; `@Post('customers') @ModuleManage('domains') createCustomer`; `@Get('contacts') listContacts` (query `refresh`, '1' or 'true' → true); `@Post('contacts') @ModuleManage('domains') createContact`; `@Post('contacts/assign') @ModuleManage('domains') assignContacts`; `@Get('domains') listDomains` (query refresh); then at the end `@Put('customers/:id') @ModuleManage('domains') updateCustomer` and `@Delete('customers/:id') @ModuleManage('domains') deleteCustomer` (ParseUUIDPipe on `:id`). Provide DomainsDirectoryService in `domains.module.ts`. Extend `domains.controller.spec.ts` (metadata + declaration order) and the `DomainsController` block in `module-manage-handlers.spec.ts`.
+
+**RLS doc.** Add the Fundstellentabelle rows `domains-directory.service.ts` / `domainsCustomer` and / `domainsContactAssignment` (`muss-mandantengebunden`, `gebunden`, German explanation incl. environment in the assignment key), update the `domains` Bereichszeile, Summenzeile and Paarzählung via the Gate-Schleife; rls specs green.
+
+**Web (L-03, L-05, L-06, L-08, D-G, D-H).** `domains-api.ts`: types `DomainsCustomer`, `DomainsContact`, `DomainsDomain`, list result types with `truncated`, functions `listCustomers`, `createCustomer`, `updateCustomer`, `deleteCustomer`, `listContacts({ refresh })`, `createContact`, `assignContacts`, `listDomains({ refresh })`. `apps/web/src/components/domains/group-by-customer.ts`: generic pure helpers `filterByCustomer(items, filter)`, `groupByCustomer(items, customers)` and `matchesText(item, query, fields)` per `` (literal "unassigned" key, label from messages). `page.tsx`: tabs 'domains' (default, everyone), 'contacts' (everyone), 'customers' (everyone), 'settings' (managers); customers are loaded once in the page and passed down (reload after changes). `DomainsTab.tsx`: toolbar with search field ("Domain suchen"), customer filter select (Alle Kunden / each customer / Nicht zugeordnet), toggle "Nach Kunde gruppieren" (default on), button "Aus AutoDNS neu laden"; table inside `SettingsSection flush` per group: Domain, Kunde, Inhaber, Ablaufdatum (Intl.DateTimeFormat of the active locale, dd.mm.yyyy in German), Status (label map ACTIVE → "Aktiv", PENDING → "In Bearbeitung", HOLD → "Gesperrt (Registry)", LOCK → "Gesperrt", other → raw value; plus "Kündigung vorgemerkt"); empty state, loading state, truncated hint "Es werden die ersten 2000 Einträge angezeigt.", error from the API message. `ContactsTab.tsx`: same toolbar ("Aus AutoDNS neu einlesen" button for everyone, with the short explanation that existing AutoDNS contacts appear here automatically and can be assigned to a customer); columns Name, Organisation, Ort, E-Mail, Kunde; managers get row checkboxes plus a bar "Ausgewählte zuordnen: [Kunde ▾ incl. „Zuordnung entfernen“] Zuordnen" and the button "Neuer Kontakt" opening `ContactForm.tsx` (Typ radio Person/Organisation, Organisation, Vorname, Nachname, Straße und Hausnummer + optional second line, PLZ, Ort, Land select built from a constant ISO list (DACH, all EU countries, GB, NO, US) labelled via `Intl.DisplayNames` of the locale, default DE, Telefon, E-Mail, Kunde (optional select); client validation per ``; "Speichern" / "Abbrechen"). `CustomersTab.tsx`: list with name, badge "Eigene Firma", number of assigned contacts; managers: inline "Kunde anlegen" (name + checkbox "Das ist unsere eigene Firma"), rename/flag edit, delete with inline confirmation and the 409 text shown. All write controls only when `canManage`. Messages for all new texts in `domains.*` de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per ``: `group-by-customer.test.ts`, `ContactForm.test.tsx`, new cases in `domains-page.test.tsx`.
+
+Biome-lint touched files, commit `feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste` (attribution line). Do not push.
+
+
+ pnpm --filter @tessera/api exec vitest run src/domains rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/domains components/domains 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/domains/domains.controller.ts)"
+ a domains/rls/manage spec or web test fails, a tsc run fails, or the controller carries a role decorator
+
+ Customers can be created, flagged as eigene Firma, renamed and deleted (blocked while in use); the contact list reads all AutoDNS contacts of the active environment with paging, cache and on-demand re-read, shows the customer per contact and lets managers create contacts and assign one or many to a customer; the domain list shows customer (via owner), owner, expiry and status; both lists filter and group by customer; all write routes behind ModuleManage with order and metadata specs green; RLS doc updated; commit on main, not pushed.
+
+
+
+ Task 3: Register a domain without any chance of a double order, track the AutoDNS job, then changelog, guides, full gates and local rebuild
+ apps/api/src/domains/domain-name.ts, apps/api/src/domains/domain-name.spec.ts, apps/api/src/domains/autodns-parse.ts, apps/api/src/domains/autodns-parse.spec.ts, apps/api/src/domains/domains-orders.service.ts, apps/api/src/domains/domains-orders.service.spec.ts, apps/api/src/domains/dto/domains-order.dto.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/domains/domains.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/domains-api.ts, apps/web/src/components/domains/order-status.ts, apps/web/src/app/(portal)/modules/domains/page.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx, apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx, apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx, apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
+
+ - normalizeDomainName: ' https://Beispiel.DE/pfad ' → 'beispiel.de'; 'beispiel.de.' → 'beispiel.de'; 'müller.de' → 'xn--mller-kva.de' (unicode form kept for display); 'beispiel' (no dot), 'bei spiel.de', '-a.de', a label over 63 chars, '' → null; splitDomain('beispiel.co.uk') → { label: 'beispiel', tld: 'co.uk' }.
+ - checkAvailability: invalid name → BadRequest code invalidDomain without fetch; exactly one POST /domainstudio with body exactly { searchToken: 'beispiel', currency: 'EUR', sources: { initial: { tlds: ['de'], services: ['WHOIS', 'PRICE'] } } }; envelope for beispiel.de with whois FREE and a 1-year price 4.9 EUR → { domain: 'beispiel.de', available: true, whoisStatus: 'FREE', price: { amount: 4.9, currency: 'EUR' } }; ASSIGNED → available false; ERROR/TIMEOUT → available false with that status; only an envelope for another domain → available false, status 'NO_RESULT'; no price entry → price null.
+ - createOrder (draft): availability is re-checked server-side and must be FREE (else 409 notAvailable, no row); nameServers fewer than 2 or more than 6 or invalid → BadRequest; missing contact id → BadRequest; creates DRAFT with environment = active, openKey = domain, payload { ownerContactId, adminContactId, techContactId, zoneContactId, nameServers, periodYears: 1, price, availabilityCheckedAt }, createdByUserId; returns the summary incl. contact display names (findContactsByIds); an existing DRAFT for the same domain/environment is updated (same id); an existing SUBMITTING/SUBMITTED/UNKNOWN/SUCCESS → 409 orderOpen; P2002 race → 409 orderOpen.
+ - submitOrder (in-memory prisma mock whose updateMany really flips status only when the where still matches): first call → updateMany where contains id, tenantId, status 'DRAFT', environment 'DEMO' and data status 'SUBMITTING' with confirmedByUserId/Username/At; exactly ONE POST to `https://api.demo.autodns.com/v1/domain` — URL without any query string — with body exactly { name: 'beispiel.de', period: { unit: 'YEAR', period: 1 }, ownerc: { id: 11 }, adminc: { id: 11 }, techc: { id: 22 }, zonec: { id: 22 }, nameServers: [{ name: 'ns1.example.com' }, { name: 'ns2.example.com' }] }; job data[0] { id: 987, status: 'RUNNING' } → SUBMITTED, jobId '987', jobStatus 'RUNNING'; Promise.all of two submits → one result, one ConflictException code alreadySubmitted, the POST happened exactly once; environment switched to LIVE after the draft → 409 environmentChanged and no fetch; already SUBMITTED → 409 alreadySubmitted; foreign id → 404.
+ - submit outcomes: never-resolving fetch (20 ms timeout) → UNKNOWN, fetch called once; rejection ECONNRESET → UNKNOWN; HTTP 200 with HTML → UNKNOWN; HTTP 200 + status.type ERROR with message 'Domain not available' → FAILED, errorText contains it, openKey null; HTTP 401 → FAILED with the login text, openKey null; success without any job id → SUBMITTED with jobId null.
+ - refreshOpenOrders: SUBMITTED with jobId → GET /job/987: SUCCESS → SUCCESS (openKey kept), cache invalidated; FAILED → FAILED + errorText + openKey null; RUNNING → stays SUBMITTED, jobStatus RUNNING, lastCheckedAt set; SUPPORT → stays SUBMITTED, jobStatus SUPPORT; SUBMITTING with confirmedAt 3 min ago → UNKNOWN, with confirmedAt 30 s ago → unchanged; UNKNOWN: GET /domain/beispiel.de success → SUCCESS; not found, job search returns a job for beispiel.de created after confirmedAt → SUBMITTED with that job id; nothing found → stays UNKNOWN with lastCheckedAt set; at most 20 orders per call, oldest lastCheckedAt first; an AutoDNS failure for one order does not stop the others.
+ - cancelOrder: DRAFT → CANCELED, openKey null; UNKNOWN with lastCheckedAt set → CANCELED; UNKNOWN never checked → 409 checkFirst; any other status → 409 notCancelable.
+ - listOrders: newest first, at most 200, view { id, environment, domainName, domainNameUnicode, status, jobStatus, errorText, confirmedByUsername, confirmedAt, createdAt, lastCheckedAt, price }.
+ - Controller: checkAvailability, createOrder, submitOrder, cancelOrder have MODULE_MANAGE_KEY true and no ROLES_KEY; listOrders and refreshOrders have none; 'orders/refresh' is declared before every ':id' handler.
+ - RegisterTab: "Verfügbarkeit prüfen" shows "frei" with price or the not-available text; contact selects only after FREE, defaults admin = owner, tech/zone = first contact of the Eigene-Firma customer else owner; nameservers prefilled from status.defaultNameServers; "Zusammenfassung anzeigen" calls createOrder; the summary shows environment badge, domain, Laufzeit 1 Jahr, price or "Preis nicht ermittelbar", the four contacts and the nameservers; "Jetzt verbindlich registrieren" is disabled until the checkbox is ticked; two fast clicks call submitOrder exactly once (ref guard) and the button shows "Wird übermittelt …"; result SUBMITTED shows the in-progress text, FAILED the error, UNKNOWN the "Ergebnis ungeklärt" text with the advice not to order again; "Abbrechen" calls cancelOrder.
+ - OrdersTab: refreshOrders is called on mount; rows show domain, Demo/Live, status label (Entwurf, Wird übermittelt, In Bearbeitung, Rückfrage nötig, Registriert, Fehlgeschlagen, Ergebnis ungeklärt, Verworfen), confirmed by/at and error text; with an open order a 30 s interval refreshes (fake timers) and stops when none are open; "Verwerfen" appears for managers only on DRAFT and on UNKNOWN with lastCheckedAt, asks for confirmation (UNKNOWN text: only discard after checking in AutoDNS that the domain was not ordered) and then calls cancelOrder.
+
+
+**Domain names and parsers (D-K, D-N).** `domain-name.ts`: `normalizeDomainName(raw)` → `{ ascii, unicode } | null` using `domainToASCII`/`domainToUnicode` from `node:url` and an anchored hostname pattern (labels 1–63 chars, letters/digits/hyphen, no leading/trailing hyphen, at least two labels, total ≤ 253), `splitDomain(ascii)`. Extend `autodns-parse.ts` with `parseDomainStudio(data, wantedAscii)`, `extractJobFromSubmit(result)` (`data[0].id`/`data[0].status`, fallback `object.value` when `object.type === 'job'`), `parseJob(data)` (`data[0].job ?? data[0]` → `{ id, status, subStatus, messages }`). Specs per ``.
+
+**Orders service + DTOs (L-04, D-K, D-L, D-M, D-N, D-O).** `dto/domains-order.dto.ts`: `CheckAvailabilityDto { domain (IsString, IsNotEmpty, MaxLength 300) }`, `CreateDomainsOrderDto { domain; ownerContactId, adminContactId, techContactId, zoneContactId (IsInt, Min 1); nameServers (IsArray, ArrayMinSize 2, ArrayMaxSize 6, each IsString MaxLength 253) }`. `domains-orders.service.ts` (inject PrismaService, DomainsSettingsService, DomainsDirectoryService; all `domainsOrder` access only here, each method its own `forTenant` client, `where` always with tenantId): `checkAvailability`, `createOrder(tenantId, userId, dto)`, `submitOrder(tenantId, user, id)` implementing D-M exactly — the claim via `updateMany` with `status: 'DRAFT'` and the active environment in the `where`, the count check BEFORE any network call, exactly one `autodnsRequest` POST '/domain' without `keys` and without query parameters, outcome mapping per D-M, no loop and no retry around the call (German comment block above the method: why count === 1, why UNKNOWN instead of DRAFT, why no retry, why the environment is part of the claim — cite L-04 and research pitfalls 1–3), `refreshOpenOrders(tenantId)` and `refreshOrder` per D-N, `cancelOrder` per ``, `listOrders`. Error objects `{ code, message }` with German messages: notAvailable 'Die Domain ist nicht frei und kann nicht registriert werden.', orderOpen 'Für diese Domain gibt es bereits einen offenen Auftrag. Bitte sehen Sie unter „Aufträge“ nach.', alreadySubmitted 'Dieser Auftrag wurde bereits abgeschickt.', environmentChanged 'Das System wurde inzwischen gewechselt. Bitte prüfen Sie die Verfügbarkeit erneut.', checkFirst 'Bitte aktualisieren Sie den Auftrag zuerst, damit Tessera bei AutoDNS nachsehen kann.', notCancelable 'Dieser Auftrag kann nicht mehr verworfen werden.'. After SUCCESS and after a successful submit clear the tenant's domain/contact cache entries (directory exposes `invalidate(tenantId)`). Spec per `` — the concurrency case uses an in-memory order row whose mocked `updateMany` evaluates the where against the current row synchronously, so the test proves the count gate, not just the call.
+
+**Controller routes (L-06, D-P).** Add before the `:id` block: `@Post('availability') @ModuleManage('domains') checkAvailability`; `@Get('orders') listOrders`; `@Post('orders') @ModuleManage('domains') createOrder`; `@Post('orders/refresh') refreshOrders`; and at the end, after the customers `:id` handlers: `@Post('orders/:id/submit') @ModuleManage('domains') submitOrder` (passes `user.id` and `user.username` from `@CurrentUser`) and `@Post('orders/:id/cancel') @ModuleManage('domains') cancelOrder` (ParseUUIDPipe). Provide DomainsOrdersService in the module. Extend the controller spec (metadata + order) and the `DomainsController` block in `module-manage-handlers.spec.ts`. Add the Fundstellentabelle row `domains-orders.service.ts` / `domainsOrder` (German: claim via updateMany count gate, openKey uniqueness, audit columns) and update Bereichszeile, Summenzeile, Paarzählung with the Gate-Schleife.
+
+**Web (L-04, L-06, D-E, D-N, D-O).** `domains-api.ts`: types `AvailabilityResult`, `DomainsOrder`, `OrderSummary`; `checkAvailability`, `createOrder`, `submitOrder`, `cancelOrder`, `listOrders`, `refreshOrders`. `apps/web/src/components/domains/order-status.ts`: literal class map and label key per status (SUBMITTED with jobStatus SUPPORT → "Rückfrage nötig"; SUCCESS → status-ok; FAILED → status-down; UNKNOWN → status-warn; DRAFT/CANCELED → status-idle) and `isOpen(order)`. `page.tsx`: add 'register' (managers, placed after Kunden) and 'orders' (everyone) tabs; final tab order Domains, Kontakte, Kunden, Registrieren, Aufträge, Einstellungen. `RegisterTab.tsx` per `` and D-O: step 1 domain field + "Verfügbarkeit prüfen"; step 2 four contact selects grouped by customer (`optgroup`), nameserver inputs (2–6, add/remove), "Zusammenfassung anzeigen"; step 3 summary in a `SettingsSection` with the environment badge, checkbox text Live "Ich bestätige die verbindliche und kostenpflichtige Registrierung bei AutoDNS." / Demo "Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).", primary button "Jetzt verbindlich registrieren" (disabled until ticked; a `useRef` flag blocks a second call even before re-render), "Abbrechen"; result panel with link/button to the Aufträge tab. `OrdersTab.tsx` per `` (refresh on mount, "Aktualisieren" button, 30 s interval only while open orders exist and `document.visibilityState === 'visible'`, cleared on unmount). Messages in `domains.*` de + en; umlaut guard green. Tests: `RegisterTab.test.tsx`, `OrdersTab.test.tsx`, page tab-visibility cases for Registrieren/Aufträge in `domains-page.test.tsx`.
+
+**Changelog + guides (L-11, L-10).** `CHANGELOG.md` under "## Unveröffentlicht" add "### Neu" above the existing "### Geändert" with one user-facing German bullet in simple words: new module „Domains“ (group Domains), activation in the Marktplatz plus Freigabe; connection to AutoDNS with Demo and Live system, own access per system, password stored encrypted, connection test; Kontakte from AutoDNS with Kunden-Zuordnung (eigene Firma as a customer), new contacts via form, filter/group by customer; Domainliste with customer, owner, expiry, status; Registrieren with availability check, contact and nameserver choice, summary and the button „Jetzt verbindlich registrieren“, protection against double orders, status in „Aufträge“; who may do what (Benutzen sees, Verwalten registers/creates/sets up). `docs/anleitung-anwender.md`: section "### Domains" after "### Domaincheck" plus its entry in the table of contents (tabs, filter/grouping, how to register, what the order states mean, what "Ergebnis ungeklärt" means and why not to order again). `docs/anleitung-administration.md`: subsection "### Domains: AutoDNS anbinden" after "### Nextcloud-Status: Clouds eintragen" (create a dedicated AutoDNS API user without two-factor login, context per system — Live usually 4, Demo as stated by InterNetX —, start with the Demo system, connection test once per click because repeated wrong logins can lock the user, default nameservers must already be set up, outbound access from the api container to api.autodns.com and api.demo.autodns.com, AutoDNS allows three requests per second, Verwalten rights). No tenant or licensing wording.
+
+**Final gates.** Run the full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on all files touched by the three tasks. Rebuild `docker compose up -d --build api web`, wait for `/health`, check `docker compose logs api` for 'Domains module seeded in registry' and the mapped `/modules/domains/...` routes (orders/refresh before orders/:id/submit), no migration errors. Commit `feat(domains): Domain registrieren mit Schutz vor Doppelbestellung, Aufträge, Changelog und Anleitung` (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/domains/domains.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.domains,"domains",{}),b=w(en.domains,"domains",{});if(Object.keys(a).length<40||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)}' && grep -q "AutoDNS" CHANGELOG.md && grep -q "^### Domains" docs/anleitung-anwender.md && grep -q "^### Domains: AutoDNS anbinden" docs/anleitung-administration.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 "Domains module seeded in registry" && A=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && curl -sf -b "$A" http://localhost:3001/modules/domains/customers | grep -q '^\[' && curl -sf -b "$A" http://localhost:3001/modules/domains/orders | grep -q '^\[' && echo "final gates ok"
+ any api or web test, tsc, the role-decorator gate, the de/en key parity or wording check, the CHANGELOG/guide greps, the running-container checks, the seed log line, or the customers/orders calls through the rebuilt stack fail
+
+ Availability check, draft, single-shot submit with atomic claim, UNKNOWN handling, job tracking and reconciliation work with the specs green (including the parallel double-submit case with exactly one POST); Registrieren and Aufträge tabs behave as specified for managers and Benutzen users; CHANGELOG and both guides describe the module; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser → API (`/modules/domains/*`) | untrusted caller; tenant, user and role only from the validated session; rights by ModuleGuard |
+| API → AutoDNS (`api.autodns.com`, `api.demo.autodns.com`) | outbound HTTPS with stored credentials; responses untrusted; POST /domain spends money |
+| DB at rest (`DomainsConfig`) | AutoDNS passwords stored there |
+| AutoDNS response → browser | message texts and contact data rendered in the UI |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-dts-01 | Information Disclosure | AutoDNS password (settings service, responses, logs) | high | mitigate | AES-256-GCM via CryptoService per environment column; responses carry only `hasPassword`; client result and errors never contain headers or the password (spec asserts via JSON.stringify); request log records only method/path/status; decrypt failure is loud, never "no password" |
+| T-dts-02 | Elevation of Privilege | settings, connection test, customer/contact writes, availability, order create/submit/cancel | high | mitigate | `@ModuleManage('domains')` on each, no role decorator; controller spec + module-manage-handlers spec; verify greps for role decorators |
+| T-dts-03 | Tampering / Repudiation (financial) | domain registration | critical | mitigate | atomic DRAFT→SUBMITTING claim with count gate before the network call; `openKey` unique per tenant/environment/domain; exactly one POST, no retry, UNKNOWN on unclear outcome, reconciliation before discard; explicit checkbox + button; audit columns confirmedByUserId/Username/At; parallel double-submit spec |
+| T-dts-04 | Spoofing / SSRF | AutoDNS client | medium | mitigate | base URL only from the fixed DEMO/LIVE map, TLS verified, `redirect: 'error'`, path validation, dynamic segments encoded |
+| T-dts-05 | Information Disclosure | cross-tenant rows (config, customers, assignments, orders) | high | mitigate | forTenant on every access, `where` with tenantId, 404 for foreign ids, tenant_isolation_policy on all four tables, rls-coverage + rls-access-inventory |
+| T-dts-06 | Denial of Service | AutoDNS rate limit / account lock | medium | mitigate | process-wide 350 ms spacing, sequential paging capped at 2000, 60 s cache, refresh capped at 20 orders, connection test exactly one call per click, no loops on login failure |
+| T-dts-07 | Tampering | Demo/Live mix-up | high | mitigate | separate credentials per environment; environment in every assignment and order; claim requires order environment = active environment (409 otherwise); switch to Live needs dialog + `confirmLive`; permanent badge; default Demo |
+| T-dts-08 | Elevation of Privilege | route shadowing | medium | mitigate | static routes declared before `:id` routes; declaration-order assertion in the controller spec |
+| T-dts-09 | Tampering / Injection | domain names, ids, contact fields | medium | mitigate | `normalizeDomainName` (domainToASCII + anchored pattern), class-validator DTOs (IsInt ids, IsUUID customer ids, Matches for country/phone), ParseUUIDPipe on `:id` |
+| T-dts-10 | Information Disclosure | AutoDNS error passthrough | low | mitigate | only `messages[].text`, max 5 × 200 chars; AutoDNS 401/403 mapped to 502 so the browser session is not mistaken as expired |
+| T-dts-SC | Tampering | npm/pip/cargo installs | low | accept | no new packages (undici, class-validator, class-transformer already present); nothing to verify |
+
+
+
+- Each task's `` command passes; Task 1 additionally proves the path through the rebuilt api with curl, Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut guard, module-layouts) and calls the module through the rebuilt stack.
+- `prisma migrate status` up to date locally; `migrate diff --exit-code` exits 0.
+- Source coverage audit:
+
+| Source item | Covered by |
+|-------------|------------|
+| GOAL: module Domains with AutoDNS settings, contacts, domain list, safe registration | Tasks 1–3 |
+| L-01 hosts, Basic auth + context header, no API key, API user without 2FA | Task 1 (client, settings hint), Task 3 (admin guide) |
+| L-02 encrypted password never returned, context per environment, Demo/Live switch, default nameservers, connection test | Task 1 |
+| L-03 contact list, create form, read in existing contacts, customer assignment, eigene Firma, filter/group by customer | Task 2 |
+| L-04 availability, contact choice, prefilled nameservers, summary + confirmation, no double orders, job tracking | Task 3 |
+| L-05 domain list with customer via owner, owner, expiry, status | Task 2 |
+| L-06 rights per route | Tasks 1–3 (controller + module-manage-handlers spec), web gating |
+| L-07 transfer/cancellation/zones excluded, generic client kept | Task 1 (generic `autodnsRequest`) |
+| L-08 Nextcloud-Status / Handelsware / PageHeader + SettingsSection patterns | Tasks 1–3 |
+| L-09 mocked API tests; Demo check after credentials | Tasks 1–3 specs; SUMMARY checklist |
+| L-10 German Sie + English, no tenant wording | Tasks 1–3 + node wording check |
+| L-11 CHANGELOG under Unveröffentlicht | Task 3 |
+| L-12 usable after activation + Freigabe | Task 1 (seed, guard, curl activation) |
+| RESEARCH: no /domain/_check, DomainStudio WHOIS+PRICE | Task 3 (D-K) |
+| RESEARCH: async POST /domain + GET /job, job search reconciliation | Task 3 (D-M, D-N) |
+| RESEARCH: envelope status.type ERROR on HTTP 200, code/resultCode | Task 1 (client) |
+| RESEARCH: 3 requests/s, paging, no per-row enrichment | Tasks 1–2 (limiter, paging, cache) |
+| RESEARCH: Demo context unclear → free integer per environment | Task 1 (D-D) |
+| RESEARCH: route order, module-manage-handlers, RLS specs | Tasks 1–3 |
+| RESEARCH: .de nameserver check, contact roles | Task 3 (D-O, guide) |
+| RESEARCH open question 2 (missing price) | Task 3 (D-K summary hint) |
+| RESEARCH open question 3 (cron vs. on open) | decided D-N (pull-based, no cron) |
+| RESEARCH: no dashboard widget in stage 1 | respected (no widget files) |
+
+
+
+- Four new tables with RLS policies; migration applied locally without drift.
+- Client, parser, cache, settings, directory, orders, controller specs and the web tests pass; full api + web suites, both tsc runs and biome on touched files are green.
+- Benutzen users see domains, contacts, customers and orders; Verwalten users and admins additionally set up AutoDNS, create contacts and customers, assign contacts and register domains; the API enforces this with ModuleManage.
+- A registration can be submitted to AutoDNS at most once per order; a second click, a second tab or an environment switch gets 409; an unclear outcome is stored as UNKNOWN and reconciled.
+- CHANGELOG and both guides describe the module; three commits on main, nothing pushed.
+
+
+
diff --git a/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-RESEARCH.md b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-RESEARCH.md
new file mode 100644
index 0000000..14e3a9d
--- /dev/null
+++ b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-RESEARCH.md
@@ -0,0 +1,217 @@
+# Quick 261008-dts: Modul "Domains" (AutoDNS) - Research
+
+**Researched:** 2026-10-08
+**Domain:** AutoDNS/InterNetX Domainrobot JSON API + Tessera-Modulmuster
+**Confidence:** MEDIUM (Endpunkte und Schemas HIGH aus OpenAPI; reale Antworten der Auftrags-Endpunkte und Demo-Kontext nicht ohne Zugangsdaten pruefbar)
+
+Es gibt keine CONTEXT.md fuer diese Aufgabe. Es wird kein neues npm-Paket benoetigt (HTTP ueber `undici` 7.28.0, `class-validator`, `@nestjs/schedule` sind bereits in `apps/api/package.json`), daher entfaellt das Package Legitimacy Audit. [VERIFIED: apps/api/package.json Zeilen 25, 32, 52]
+
+## Project Constraints (aus CLAUDE.md / Memory)
+- UI-Texte Deutsch, siezen; keine "Mandant"-Begriffe in neuen UI-Texten; keine Lizenzierungs-/Mandantenfaehigkeits-Themen anschneiden.
+- Keine firmenspezifischen Werte hart in generische Admin-UI (Standard-Nameserver also nur als Einstellung, nicht als Konstante).
+- Neue Module: `@UseModule(slug)` auf Klassenebene ist Pflicht, Schreib-/Pruefrouten zusaetzlich `@ModuleManage(slug)`; NIE ein Rollen-Decorator auf Verwalten-Handlern.
+- Statische Routen VOR `:id`-Routen (Unit-Tests fangen das nicht, Reihenfolge im Controller-Spec festschreiben).
+- Zugangsdaten nie an den Client zurueckgeben (LDAP-Muster: Maske `'********'`).
+- Tests mit gemockter API; kein Docker-Deploy auf Testserver durch Claude.
+- Alle Aenderungen laufen ueber einen GSD-Workflow.
+
+## Summary
+
+Die AutoDNS-JSON-API deckt alle vier Etappe-1-Funktionen ab. Es gibt KEINEN `POST /domain/_check`; die Verfuegbarkeitspruefung laeuft ueber `POST /domainstudio` (WHOIS-Dienst je Domain, optional PRICE). `POST /domain` ist asynchron und liefert einen Job; der Verlauf wird ueber `GET /job/{id}` verfolgt. Die Antwort-Huelle ist fuer alle Routen gleich (status/stid/object/data/messages).
+
+Im Code ist das Nextcloud-Status-Modul (Controller/Seed/Modul) plus Handelsware-Datev (Einstellungs-Tab, Singleton-Konfiguration) das beste Vorbild; die AES-Verschluesselung kommt aus `CryptoService`. Der groesste fachliche Risikopunkt ist Geldsicherheit: `POST /domain` darf pro Bestellung hoechstens einmal abgeschickt werden, und ein Timeout heisst NICHT "nicht bestellt".
+
+**Primary recommendation:** Zwei Umgebungen (DEMO/LIVE) mit je eigenem Zugang, feste Basis-URLs (keine freie URL-Eingabe), lokale Bestelltabelle mit atomarem Statuswechsel DRAFT -> SUBMITTING vor dem API-Aufruf, Kontakte und Domains live aus AutoDNS lesen und nur die Kundenzuordnung lokal halten.
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary | Rationale |
+|---|---|---|---|
+| AutoDNS-Aufrufe, Zugangsdaten | API/Backend | - | Passwort darf nie in den Browser; Basic Auth serverseitig |
+| Kundenzuordnung Kontakte | Database | API | Nur lokal vorhanden, AutoDNS kennt "Kunde" nicht |
+| Bestell-Zustand (Idempotenz) | Database + API | - | Atomarer Statuswechsel in DB schuetzt vor Doppelbestellung |
+| Job-Status-Nachverfolgung | API (Abruf beim Oeffnen + Cron) | Browser (Polling der eigenen API) | Browser spricht nie mit AutoDNS |
+| Filter/Gruppierung nach Kunde | Browser | API | Kleine Datenmenge, Zusammenfuehren im API-Dienst |
+| Berechtigung | API (ModuleGuard) | Browser (`useCanManageModule`, nur Anzeige) | Bindend ist nur die API |
+
+## AutoDNS JSON API (verifiziert)
+
+Quelle der Schemas: OpenAPI 2.0 `https://raw.githubusercontent.com/InterNetX/domainrobot-api/master/src/domainrobot.json` (Info version `v1`, host `api.autodns.com`, basePath `/v1`), gelesen und ausgewertet am 2026-10-08. [VERIFIED: openapi domainrobot.json]
+
+### Basis, Authentisierung, Limits
+| Punkt | Wert | Quelle |
+|---|---|---|
+| Live-URL | `https://api.autodns.com/v1` | [CITED: help.internetx.com/x/cQbj "Interface addresses"] |
+| Demo-URL | `https://api.demo.autodns.com/v1` | [CITED: help.internetx.com/x/cQbj; java-domainrobot-sdk README] |
+| Erreichbarkeit | Beide Hosts antworten von hier mit HTTP 401 ohne Zugangsdaten | [VERIFIED: curl-Probe 2026-10-08, ohne Zugangsdaten] |
+| Auth | HTTP Basic (Benutzer/Passwort) + Header `X-Domainrobot-Context: `; Alternative Session (`X-Domainrobot-SessionId`), von InterNetX nicht empfohlen ausser bei Zwangs-Timeout | [CITED: help.internetx.com Suchergebnis "Login and Authentication"; js-sdk `DomainRobotService.js` setzt Basic + Context-Header] |
+| User-Agent | Pflicht ("mandatory") - eigenen setzen, z. B. `Tessera/` | [CITED: help.internetx.com/x/cQbj] |
+| Rate-Limit | "Only 3 requests per second and IP" - Aufrufe serialisieren, Listen seitenweise, kein paralleles Fan-out | [CITED: help.internetx.com/x/cQbj] |
+| Verbindungstest | `GET /hello` ("performs no operation, used only for testing the connection and authentication credentials") | [CITED: help.internetx.com/x/cQbj; OpenAPI `/hello`] |
+| Live-Context | Standard `4` | [CITED: wisecp-Doku und help.internetx.com (Plugin-Seite): "default context for AutoDNS is 4"] |
+| Demo-Context | UNKLAR: Drittanbieter nennt `1` fuer Demo, die offiziellen SDK-Beispiele nutzen die Demo-URL mit `"4"` | [ASSUMED] - Kontext deshalb je Umgebung als freie Ganzzahl speichern, nicht hartkodieren |
+| 2FA | OpenAPI kennt Header `X-Domainrobot-2FA-Token`; Hilfeseite sagt 2FA nur fuer XML | [ASSUMED] Widerspruch - dedizierten API-Benutzer OHNE 2FA empfehlen |
+| `X-Domainrobot-Demo` (boolean Header) | in OpenAPI vorhanden, Wirkung undokumentiert | NICHT verwenden; Umgebung nur ueber die Basis-URL waehlen |
+
+Fehlerhafte Anmeldung: Live liefert 401 mit `{"messages":[{"code":"EF00202","text":"User does not exist or password incorrect.","status":"ERROR",...}],"status":{"code":null,"text":null,"type":"ERROR"},"stid":"..."}`; Demo ohne Basic-Header liefert 401 `EF1321001 "The authsession could not be found."`. [VERIFIED: curl-Probe 2026-10-08] Wichtig: wiederholte falsche Anmeldungen koennen den Benutzer sperren [ASSUMED] - Verbindungstest nie in Schleife/Retry.
+
+### Antwort-Huelle
+```json
+{ "status": {"code":"S0301","text":"...","type":"SUCCESS"},
+ "stid": "20180915-app1",
+ "object": {"type":"contact","value":"100101","summary":1},
+ "messages": [ {"code":"...","text":"...","status":"ERROR","objects":[...]} ],
+ "data": [ { } ] }
+```
+- `status.type` Enum: `SUCCESS`, `ERROR`, `NOTIFY`, `NOTICE`, `NICCOM_NOTIFY`. [VERIFIED: OpenAPI StatusType]
+- Code-Praefixe: `S` Erfolg, `E` Fehler, `N` Benachrichtigung ("accepted, further processing required"). [CITED: help.internetx.com/x/cQbj]
+- Die Hilfeseite zeigt das Feld als `resultCode`, OpenAPI und die reale 401-Antwort nutzen `code`. Parser muss `status.code ?? status.resultCode` lesen. [VERIFIED: Probe + OpenAPI; Abweichung CITED]
+- `object.summary` = Gesamtzahl bei Listen (Paging). [VERIFIED: OpenAPI ResponseObject "amount of objects found in list tasks"]
+- HTTP-Status ist nicht verlaesslich fuer Fachfehler: IMMER `status.type === 'ERROR'` bzw. `messages[].status` pruefen, auch bei HTTP 200. [ASSUMED]
+- Fehlertexte an den Client nur gekuerzt und ohne Header/Zugangsdaten durchreichen (`messages[].text`).
+
+### Routen und minimale Nutzlasten
+| Zweck | Route | Nutzlast / Hinweis |
+|---|---|---|
+| Kontakt anlegen | `POST /contact` | `{"type":"PERSON","fname":"Max","lname":"Muster","address":["Musterstr. 1"],"pcode":"12345","city":"Berlin","country":"DE","email":"x@example.com","phone":"+49 30 123456"}`; `type` Enum `PERSON`, `ORG`, `ROLE`; `ORG` braucht `organization`; `address` ist ein ARRAY; `alias` optional (wird generiert); Antwort `data[0].id` (Ganzzahl). Pflichtfelder sind in der OpenAPI nicht als `required` markiert -> obige Menge ist [ASSUMED], gegen Demo pruefen. `extensions` (z. B. `general.gender`, `it.entityType`) nur TLD-abhaengig, fuer `.de` nicht noetig [ASSUMED]. |
+| Kontakte listen | `POST /contact/_search` | Body `Query`: `{"filters":[{"key":"lname","value":"%test%","operator":"LIKE"}],"view":{"limit":100,"offset":0},"orders":[{"key":"lname","type":"ASC"}]}`; leere `filters` = alle. Filterbare Schluessel: country, pcode, city, type, title, lname, alias, state, id, email, fname, address, created, phone, organization, comment, updated ... Operatoren: EQUAL, NOT_EQUAL, LIKE, ILIKE, GREATER, LESS, IN, IS_NULL ... [VERIFIED: OpenAPI + js-sdk ContactList.js] |
+| Kontakt lesen/aendern | `GET/PUT /contact/{id}` | Spaeter; `DELETE /contact/{id}` NICHT in Etappe 1 anbieten |
+| Verfuegbarkeit | `POST /domainstudio` | Es gibt keine Route `/domain/_check` (alle `/domain*`-Pfade der OpenAPI durchgesehen). Body `DomainEnvelopeSearchRequest`: `{"searchToken":"example","currency":"EUR","checkPortfolio":true,"sources":{"initial":{"tlds":["de"],"services":["WHOIS","PRICE"]}}}`; alternativ `sources.custom.domains:["example.de"]`. Antwort `data[]` = `DomainEnvelope` mit `domain`, `services.whois.data.status` (Enum `FREE`, `ASSIGNED`, `MARKET`, `PREMIUM`, `INVALID`, `ERROR`, `TIMEOUT`, `RESERVED`, `PREMIUM_CLAIM`, `CLAIM`, `OFFER`) und `services.price.data.prices[]` (`amount`, `currency`, `period`). [VERIFIED: OpenAPI Schemas]. Die genaue Kombination `initial.tlds` + `searchToken` bzw. `custom.domains` liefert ein Ergebnis fuer genau die gewuenschte Domain: [ASSUMED], gegen Demo pruefen. Nur `status === 'FREE'` zaehlt als bestellbar; alles andere (auch ERROR/TIMEOUT) = nicht bestellbar. |
+| Domain bestellen | `POST /domain` | "The operation is asynchronous and creates a job." Body (Domain): `{"name":"beispiel.de","period":{"unit":"YEAR","period":1},"ownerc":{"id":123},"adminc":{"id":123},"techc":{"id":123},"zonec":{"id":123},"nameServers":[{"name":"ns1.example.com"},{"name":"ns2.example.com"}]}`. Contacts als Objekte mit `id` (SDK-Beispiel reicht ganze Kontakt-Objekte; nur `{id}` genuegt vermutlich: [ASSUMED]). Query-Parameter `ignoreWhois`, `nsCheck`, `replyTo` - `ignoreWhois` NIE setzen. Antwort `JsonResponseDataJob`: `data[0]` = Job. Wo genau die Job-Id steht (`data[0].id`, ausserdem vermutlich `object.type="job"`/`object.value`): [ASSUMED], gegen Demo pruefen. `confirmOrder` (Nutzungsbedingungen mancher TLDs) und `period` je nach TLD: [VERIFIED: Feldbeschreibung OpenAPI]. Zusatz `nameServerEntries` nur fuer `.de` und schliesst `nameServers` aus. |
+| Job lesen | `GET /job/{id}` | Antwort `ObjectJob`: `data[0].job.status` (Enum `RUNNING`, `SUCCESS`, `FAILED`, `CANCELED`, `SUPPORT`, `DEFERRED`, `NOT_SET`, `WAIT`), `.job.subStatus`, `.job.action`, `.object` (Domain-Name), `.job.events[]`. Terminal: `SUCCESS`, `FAILED`, `CANCELED`; `SUPPORT` = Eingriff durch InterNetX noetig (als "Rueckfrage noetig" anzeigen); `WAIT`/`DEFERRED`/`RUNNING` = offen. [VERIFIED: OpenAPI Enum; Terminal-Deutung ASSUMED] |
+| Jobs suchen | `POST /job/_search` | Query mit Schluesseln `id`, `status`, `object`, `type`, `action`, `created` -> Abgleich nach Timeout (siehe Pitfall 1). |
+| Domains listen | `POST /domain/_search?keys[]=expire&keys[]=ownerc` | Body `Query` wie bei Kontakten (`view.limit/offset`). Zusatzfelder per wiederholtem Query-Parameter `keys[]` (Format `?keys[]=a&keys[]=b`): zulaessig u. a. `expire`, `ownerc`, `adminc`, `techc`, `zonec`, `nserver`, `autorenew`, `cancelationStatus`, `authinfo`, `certificate`. Statusfelder: `registryStatus` (`ACTIVE`, `HOLD`, `LOCK`, `PENDING` ...), `cancelationStatus`, `action`. [VERIFIED: OpenAPI + js-sdk DomainService.list + php-sdk DomainList.php] |
+| Domain lesen | `GET /domain/{name}` | Detail/Abgleich nach Timeout. |
+
+Spaeter (Architektur offenhalten, nicht bauen): Transfer `POST /domain/_transfer`, Kuendigung `POST /domain/{name}/cancelation`, Zonen `GET/PUT /zone/{name}/{virtualNameServer}`, Auftrag bestaetigen/abbrechen `PUT /job/{id}/_confirm` / `_cancel`, Poll-Nachrichten `GET /poll`. [VERIFIED: OpenAPI paths] Deshalb den AutoDNS-Client als eigene Klasse mit allgemeiner `request(method, path, body, keys)`-Methode plus duenner Fachschicht bauen.
+
+### .de-Besonderheiten (kurz)
+- Seit 25.05.2018 erhebt DENIC nur noch den Domaininhaber als personenbezogene Kontaktdaten; Admin-C/Tech-C/Zone-C entfallen bei DENIC, zusaetzlich zwei neutrale Adressen (General Request/Abuse). AutoDNS-Objekte kennen weiter `ownerc/adminc/techc/zonec`; sicher ist, den Inhaber vollstaendig mit Anschrift zu fuehren und fuer die uebrigen drei Rollen einen Kontakt zu referenzieren. [CITED: denic.de Pressemitteilung 25.05.2018 laut Suchergebnis; Abbildung auf AutoDNS-Pflichtfelder ASSUMED] - Empfehlung: alle vier IDs im Formular verlangen, Voreinstellung = Inhaber bzw. eigener Firmenkontakt als Technik-/Zonen-Kontakt.
+- `.de` verlangt 2 bis 6 funktionierende Nameserver; DENIC prueft sie bei der Registrierung (Nast-Check), unerreichbare/nicht eingerichtete Nameserver lassen den Auftrag scheitern. Parameter `nsCheck` existiert. [CITED: namecheap/ans.co.uk Suchergebnis; AutoDNS-Verhalten ASSUMED] - Auftrag `FAILED` mit `messages` verstaendlich anzeigen.
+- Regeln pro TLD unterscheiden sich stark (`extensions`); Etappe 1 auf Kern-TLDs (.de/.com/.net/.org) beschraenken oder bei fehlenden Pflichtangaben die API-Fehlermeldung durchreichen.
+
+## Codebase-Integration (verifiziert durch Lesen der Dateien)
+
+### Backend: Dateien als Vorlage kopieren
+| Zweck | Vorlage | Hinweis |
+|---|---|---|
+| Modul + Seed beim Start | `apps/api/src/nextcloud-status/nextcloud-status.module.ts`, `nextcloud-status.seed.ts` | `seedModule({slug, name, version, category, description:{de,en}, isSystem:true})`; `onModuleInit` mit try/catch. Kategorie: `'infrastructure'` (existiert, `proxmox`/`nextcloud-status` nutzen sie) oder `'domain-tools'` (existiert, `domaincheck` nutzt sie). [VERIFIED: nextcloud-status.seed.ts; module-categories.service.spec.ts Zeilen 17-20] Empfehlung: `'domain-tools'` zu Domaincheck, Kategorien sind laut 261003-387 durch Admins umbenennbar. |
+| Controller | `nextcloud-status.controller.ts` | `@Controller('modules/')` + `@UseModule('')` Klasse; `requireTenantId(req)` aus `req.tenantId`; Schreib-/Pruef-/Einstellungsrouten `@ModuleManage('')`; Lese-Routen ohne. |
+| Singleton-Einstellungen + Tab | `handelsware-datev.controller.ts`, `handelsware-datev.service.ts`, Web `modules/handelsware-datev/page.tsx` + `components/SettingsTab.tsx` | Tab "Einstellungen" nur bei `useCanManageModule`. |
+| Verschluesselung | `apps/api/src/crypto/crypto.service.ts` (`CryptoModule`) | `encrypt(plain)` -> `iv:authTag:ciphertext` (AES-256-GCM, Schluessel `TESSERA_ENCRYPTION_KEY`, 64 Hex). Entschluesseln-Fehler NICHT schlucken (kein stiller Wechsel auf "ohne Passwort"), wie `ldap-config.service.ts` `decryptBindPassword`. Passwort-Feld im API-Response als `'********'` bzw. `hasPassword: boolean`; leeres Feld beim Speichern = unveraendert (LDAP-Muster `dto.bindPassword \|\| config.bindPassword`). |
+| HTTP-Client | `apps/api/src/proxmox/proxmox-client.service.ts` | `undiciFetch` (NICHT globales `fetch`), `AbortController`-Timeout, Fehlerdetail gekuerzt (500 Zeichen), Fehlerklassifikation. Fuer AutoDNS: festes Basis-URL-Mapping DEMO/LIVE, kein Eintrag frei waehlbar (kein SSRF), 15-20 s Timeout, Basic-Header aus entschluesselten Daten. |
+| Hintergrunddienst (Auftragsstatus) | `nextcloud-status-scheduler.service.ts` | `OnApplicationBootstrap`, `SchedulerRegistry` + `require('cron').CronJob`-Umgehung, Ueberlappungsschutz `running`; Systemkontext-Lesen nur ueber `forSystem()` (+ `system_read_policy`). Fuer Etappe 1 genuegt: Status beim Oeffnen/"Aktualisieren" abfragen, optional minuetlicher Cron nur fuer offene Auftraege. |
+| DTOs | `nextcloud-status/dto/nextcloud-instance.dto.ts` | `class-validator`; globale `ValidationPipe` ist in `main.ts` aktiv. |
+| DB-Zugriff | `forTenant(this.prisma, tenantId)` aus `prisma/prisma-tenant.extension.ts` | Jeder neue Zugriff in `rls-access-inventory.spec.ts` eintragen, wenn dort Klassifikation verlangt wird. |
+| Migration | `apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql` | Handgeschrieben mit Kopfkommentar; je Tabelle `tenantId`, `ENABLE`+`FORCE ROW LEVEL SECURITY`, `tenant_isolation_policy USING ("tenantId" = current_tenant_id())`; sonst faellt `rls-coverage.spec.ts`. Naechster Zeitstempel > `20261003120000`. |
+| Registrierungspunkte | `apps/api/src/app.module.ts` (Importliste), `module-manage-handlers.spec.ts` (Pflicht-Test: jede Schreibroute verlangt Manage, Leseroute nicht) | |
+
+### Frontend: Registrierungspunkte (alle noetig, sonst erscheint das Modul nicht korrekt)
+- `apps/web/src/lib/module-loader.ts`: Eintrag `'': { component: dynamic(() => import('@/app/(portal)/modules//page'), { ssr: false }) }`.
+- `apps/web/src/lib/module-identity.ts`: Symbol in `ICONS` (z. B. `domaincheck: 'globe'` existiert; neuer Slug braucht eigenen Eintrag; fehlt er, gilt 'tile').
+- `apps/web/src/lib/stores/nav-store.ts`: `MODULE_TITLE_KEYS[''] = '.title'`.
+- `apps/web/src/app/(portal)/modules//layout.tsx`: `` und Eintrag in `module-layouts.test.tsx`.
+- Seite: `PageHeader` aus `@/components/layout/page-header`, `TabBar` aus `@/components/accounting/tab-bar` (Handelsware-Muster: Tabs Kontakte / Domains / Registrieren / Einstellungen), `useCanManageModule(slug)`. `SettingsSection`/`ControlCenterNav` (`components/control-center/`) gehoeren zum Administrationsbereich (`/admin/...`); fuer ein Modul reicht der Einstellungs-Tab nach Handelsware-Muster. [VERIFIED: ControlCenterNav listet nur /settings und /admin Seiten]
+- API-Client: `apps/web/src/lib/-api.ts` nach `nextcloud-status-api.ts` (`credentials: 'include'`, `NEXT_PUBLIC_API_URL`).
+- Texte in `apps/web/src/messages/de.json` UND `en.json` (Paritaets-Tests); deutsche Texte mit echten Umlauten, `umlaut-guard.spec.ts` laeuft ueber de.json - neue Woerter ggf. in `umlaut-dictionary.ts`.
+- Kein Dashboard-Widget in Etappe 1 (`WIDGET_MODULE_SLUGS` unveraendert).
+
+## Datenmodell-Empfehlung
+
+Prinzip: AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains; lokal liegt nur, was AutoDNS nicht kennt oder was Geld-/Idempotenz-Schutz braucht.
+
+| Tabelle (alle mit `tenantId`, RLS) | Inhalt | Begruendung |
+|---|---|---|
+| `DomainsConfig` (Singleton je tenantId) | `environment` (`DEMO`/`LIVE`, Standard `DEMO`), je Umgebung `demoUser`, `demoEncryptedPassword`, `demoContext Int?`, `liveUser`, `liveEncryptedPassword`, `liveContext Int? (Standard 4)`, `defaultNameServers` (Text-Array/Json) | Zwei getrennte Zugangssaetze: Umschalter kann nie versehentlich Live-Zugang in Demo tippen und umgekehrt; Wechsel verliert nichts. Passwort-Spalten `iv:authTag:ciphertext`. |
+| `DomainsCustomer` | `id`, `name` (je Mandant eindeutig), `isOwnCompany Boolean` | Kunden gibt es im System noch nicht (Nextcloud nutzt nur Freitext `customerName`). Eigene kleine Tabelle erlaubt Gruppieren/Filtern ohne Tippfehler-Duplikate; die eigene Firma ist ein Eintrag mit Markierung. Kein Firmenname als Vorgabe. |
+| `DomainsContactAssignment` | `environment`, `autodnsContactId Int`, `customerId` (FK `DomainsCustomer`, `onDelete: Restrict`) ; `@@unique([tenantId, environment, autodnsContactId])` | AutoDNS-Kontakt-IDs von Demo und Live kollidieren zwangslaeufig, daher Umgebung im Schluessel. Kontakte selbst werden NICHT gespiegelt. |
+| `DomainsOrder` | `id`, `environment`, `domainName`, `status` (`DRAFT`, `SUBMITTING`, `SUBMITTED`, `SUCCESS`, `FAILED`, `UNKNOWN`, `CANCELED`), `jobId Int?`, `payload Json` (Kontakt-IDs, Nameserver, Laufzeit, angezeigter Preis/Waehrung), `createdByUserId`, `confirmedByUserId`, `confirmedAt`, `errorText`, `lastCheckedAt` ; Teil-Unique auf offene Bestellungen je (`tenantId`,`environment`,`domainName`) | Idempotenz, Nachverfolgbarkeit, Audit "wer hat Geld ausgegeben". |
+| Kein lokaler Domain-Cache in Etappe 1 | Domainliste wird live seitenweise gelesen (`view.limit`), `ownerc` per `keys[]` mitgeholt; Kunde der Domain = Kunde des Inhaber-Kontakts (aus `DomainsContactAssignment`) | Immer aktuell, keine Synchronisationsfehler. Bei vielen Hundert Domains oder AutoDNS-Ausfall spaeter Snapshot-Tabelle + Cron nachruesten. Kurzer In-Memory-Zwischenspeicher (z. B. 60 s) pro (tenant, environment) schuetzt das 3-Aufrufe-pro-Sekunde-Limit. |
+
+"Kunde" einer Domain, die der Inhaber-Kontakt nicht zuordenbar macht (Kontakt ohne Zuordnung): als "Nicht zugeordnet" anzeigen und im Filter anbieten. Spaetere Abweichung (Domain gehoert anderem Kunden als ihr Inhaber) ist eine eigene Zuordnungstabelle in Etappe 2 - Architektur laesst das zu.
+
+## Architektur-Muster fuer die Registrierung (Geldsicherheit)
+
+```
+Browser --1 Pruefen(name,tld)--> API --> POST /domainstudio (WHOIS+PRICE) --> Ergebnis FREE?
+Browser --2 Entwurf(Kontakte,NS,Laufzeit)--> API: legt DomainsOrder DRAFT an, liefert Zusammenfassung + orderId
+Browser --3 "Verbindlich registrieren" (orderId, Haken gesetzt)--> API:
+ UPDATE ... SET status='SUBMITTING' WHERE id=? AND status='DRAFT' AND environment= (Zaehler == 1 ?)
+ nein -> 409 "Bereits abgeschickt"
+ ja -> POST /domain (EINMAL, KEIN Retry) -> Job-Id speichern, status='SUBMITTED'
+ Netzwerkfehler/Timeout/unlesbare Antwort -> status='UNKNOWN' (nie zurueck auf DRAFT)
+Browser/Cron --4 Aktualisieren--> GET /job/{id} -> SUCCESS/FAILED/... ; bei UNKNOWN: POST /job/_search bzw. GET /domain/{name} zum Abgleich
+```
+
+## Don't Hand-Roll
+| Problem | Nicht bauen | Stattdessen | Warum |
+|---|---|---|---|
+| Verschluesselung | eigenes Krypto | `CryptoService` | AES-256-GCM, einheitlicher Schluessel |
+| Moduleberechtigung | eigene Rollenpruefung | `@UseModule` + `@ModuleManage` | Gruppen-/Direktfreigaben, Admin-Kurzschluss |
+| Mandantenschutz | WHERE tenantId von Hand | `forTenant()` + RLS-Migration | RLS-Tests erzwingen es |
+| HTTP/Timeout | neuer Client-Stil | `undici` wie Proxmox | bewaehrt, testbar |
+| Domainnamen-Pruefung | Regex-Eigenbau | Pruefung per Muster `^[a-z0-9-]+\.[a-z.]{2,}$` nur als Vorfilter; Gueltigkeit entscheidet DomainStudio-Status `INVALID` | IDN/TLD-Sonderfaelle |
+
+## Common Pitfalls
+1. **Timeout bei `POST /domain` ist unbekannter Ausgang.** Nie automatisch wiederholen. Status `UNKNOWN`; Abgleich per `GET /domain/{name}` (existiert -> bestellt) oder `POST /job/_search`; dem Benutzer "Ergebnis ungeklaert, bitte pruefen" zeigen. Kein globaler Retry-Wrapper fuer POST.
+2. **Doppelklick / zwei Browser-Tabs / Wiederholen nach Fehler.** Schutz nur serverseitig durch atomaren Compare-and-Set in der DB (`updateMany ... where status='DRAFT'`, Zaehler pruefen); Button-Sperre im Browser ist nur Komfort. Eine neue Bestellung derselben Domain erst, wenn die alte `FAILED`/`CANCELED` ist. AutoDNS bietet keinen dokumentierten Idempotenz-Schluessel; Header `X-Domainrobot-Ctid` existiert (js-sdk `Headers.js`), eine serverseitige Dublettenerkennung darauf ist [ASSUMED] - nur zur Korrelation im Log verwenden.
+3. **Demo/Live verwechselt.** Umgebung ist Teil jeder Bestellung und jeder Zuordnung; beim Bestaetigen muss `order.environment === config.environment` gelten (sonst 409). Deutlicher, dauerhafter Banner "DEMO-System" bzw. "LIVE - kostenpflichtig" in Modulkopf und Bestaetigungsdialog; Live-Registrierung verlangt ausdrueckliches Haeckchen "verbindlich registrieren (kostenpflichtig)". Umgebungswechsel nur mit Verwalten und mit Bestaetigungsdialog; Standard fuer neue Installation: DEMO. Basis-URL ist Aufzaehlung, nie Freitext.
+4. **Geheimnisse.** Passwort nie in Responses, Logs oder Fehlertexten (Basic-Header, Request-Body-Logging aus; `request-log.ts` protokolliert nur Methode/Pfad/Status). Fehlermeldungen gekuerzt; AutoDNS-`messages[].text` ist unkritisch, aber ohne `stid`-Zwang anzeigen. Entschluesselungsfehler lautstark loggen, nicht als "kein Passwort" werten.
+5. **NestJS-Routenreihenfolge.** `contacts/import`, `contacts/availability`, `registrations/preview`, `domains/check`, `connection-test` stehen VOR `contacts/:id`, `registrations/:id`; im Controller-Spec die Deklarationsreihenfolge pruefen (Vorbild `handelsware-datev.controller.spec.ts`). Jede neue Verwalten-Route in `module-manage-handlers.spec.ts` eintragen.
+6. **Rate-Limit 3 Anfragen/s/IP.** Einen Limiter (Warteschlange mit 350 ms Abstand je Prozess) im AutoDNS-Client, Listen in Seiten zu je 100-250; die Domainliste nicht pro Zeile mit `GET /domain/{name}` anreichern.
+7. **HTTP 200 mit Fachfehler.** `status.type==='ERROR'` immer auswerten (auch `messages[]`), sonst gilt eine fehlgeschlagene Bestellung als erfolgreich.
+8. **Kontakt-IDs ueber Umgebungen.** Siehe Datenmodell; Zuordnungen nie ohne `environment` auswerten.
+9. **Kontakte aendern kann Domains beeinflussen** (`confirmOwnerConsent` bei Inhaberwechsel gTLDs). Etappe 1: Kontakte nur anlegen/lesen, nicht aendern.
+10. **Nameserver-Check .de.** Standard-Nameserver in den Einstellungen muessen vorab bei AutoDNS/Anbieter eingerichtet sein, sonst scheitert `.de` mit `FAILED`.
+
+## Validation Architecture
+| Property | Value |
+|---|---|
+| Framework | Vitest 3.2.6 (apps/api), 4.1.9 (apps/web) [VERIFIED: CLAUDE.md Stack-Tabelle] |
+| API-Mock | AutoDNS-Client hinter Schnittstelle (`AutodnsClient`), im Test durch Fake ersetzt; HTTP-Schicht mit `undici` `MockAgent` pruefen: Header (`Authorization`, `X-Domainrobot-Context`, `User-Agent`), URL je Umgebung, Timeout |
+| Quick run | `pnpm --filter api exec vitest run src/autodns-domains` |
+| Pflicht-Tests | Controller-Reihenfolge; `module-manage-handlers.spec.ts` erweitern; Passwort nie in Response (`'********'`/`hasPassword`); Bestellung: zweiter Bestaetigungsaufruf -> 409 und `POST /domain` genau einmal; Timeout -> `UNKNOWN` ohne Retry; Umgebungswechsel zwischen DRAFT und Bestaetigung -> 409; HTTP 200 + `status.type=ERROR` -> Fehler; `rls-coverage.spec.ts` und RLS-Inventar; de/en-Paritaet der Texte |
+| Echtprobe | Erst nach Eintrag von Demo-Zugangsdaten durch den User: Verbindungstest, Kontakt anlegen, Verfuegbarkeit, Bestellung, Job-Antwortform (schliesst die [ASSUMED]-Punkte) |
+
+## Security Domain
+| ASVS | Gilt | Kontrolle |
+|---|---|---|
+| V4 Zugriffskontrolle | ja | `@UseModule` Klasse, `@ModuleManage` auf Registrieren/Kontakt anlegen/Einstellungen/Verbindungstest; `tenantId` nur aus `req.tenantId` |
+| V5 Eingabevalidierung | ja | `class-validator`-DTOs; Domainname normalisieren (klein, ohne Protokoll/Pfad); Kontakt-/Job-Ids als Integer |
+| V6 Kryptografie | ja | `CryptoService`, nichts Eigenes |
+| V9 Kommunikation | ja | nur `https`, feste Hosts (kein SSRF), Zertifikatspruefung an |
+| V7 Protokollierung | ja | Bestellungen mit Benutzer-Id und Zeitpunkt in `DomainsOrder` (Audit) |
+
+## Assumptions Log
+| # | Annahme | Abschnitt | Risiko wenn falsch |
+|---|---|---|---|
+| A1 | Demo-Context ist unklar (1 laut Drittanbieter, 4 in SDK-Beispielen) | Basis | Verbindungstest Demo scheitert; deshalb frei eintragbar |
+| A2 | Pflichtfelder Kontakt (type, Name/Organisation, address[], pcode, city, country, email, phone) | Routen | Demo-Fehler beim Anlegen; Formular nachbessern |
+| A3 | `POST /domainstudio` mit `initial.tlds`+`searchToken` bzw. `custom.domains` liefert genau die gewuenschte Domain mit WHOIS-Status | Routen | Verfuegbarkeitspruefung anders aufbauen |
+| A4 | Job-Id in Antwort von `POST /domain` unter `data[0].id` | Routen | Statusnachverfolgung muss Antwortform nachziehen; Fallback `POST /job/_search` |
+| A5 | `{ "id": n }` genuegt als Kontaktverweis in `ownerc/adminc/techc/zonec` | Routen | ganzes Kontakt-Objekt aus `GET /contact/{id}` mitsenden |
+| A6 | Dedizierter API-Benutzer ohne 2FA; 2FA-Token-Header fuer JSON unsicher | Basis | Anmeldung scheitert bei 2FA-Benutzer |
+| A7 | HTTP 200 mit Fachfehler moeglich; 429 bei Rate-Limit | Pitfalls | schlimmstenfalls zu vorsichtige Auswertung |
+| A8 | DENIC/AutoDNS-.de-Pflichten fuer adminc/techc/zonec und Nameserver-Pruefung | .de | Bestellung scheitert mit klarer API-Meldung |
+| A9 | Falsche Anmeldungen koennen den Benutzer sperren | Basis | Verbindungstest nur einmal je Klick, nie Schleife |
+
+## Open Questions
+1. **Existiert beim User ein dedizierter AutoDNS-API-Benutzer (ohne 2FA) und welcher Context gilt fuer Live/Demo?** Klaeren beim Eintragen der Demo-Zugangsdaten; Einstellungsmaske nimmt beides auf.
+2. **Preisanzeige in der Zusammenfassung:** `PRICE`-Dienst liefert Betrag/Waehrung; falls er fehlt oder `FAILED` ist, Bestellung nur mit Hinweis "Preis nicht ermittelbar" zulassen, nicht stillschweigend ohne Preis.
+3. **Taeglicher Abgleich der Auftraege:** Entscheidung Etappe 1 ohne Cron (Abfrage beim Oeffnen/Aktualisieren) oder mit minuetlichem Cron nur fuer offene Auftraege. Empfehlung: Cron nachruesten, sobald die Demo-Antwortform bekannt ist.
+
+## Sources
+### Primary (HIGH)
+- OpenAPI 2.0 `https://raw.githubusercontent.com/InterNetX/domainrobot-api/master/src/domainrobot.json` - Pfade, Schemas, Enums
+- `https://help.internetx.com/x/cQbj` (JSON API Basics) - URLs live/demo, Limits, `/hello`, Statuscodes S/E/N
+- InterNetX SDK-Beispiele: `js-domainrobot-sdk` (`examples/contact|domain|domainstudio`, `src/services/DomainService.js`, `src/lib/Headers.js`), `php-domainrobot-sdk` (`example/domain/DomainList.php`), `java-domainrobot-sdk` README (Demo-URL)
+- Curl-Proben ohne Zugangsdaten gegen beide Hosts am 2026-10-08
+- Codebase: `apps/api/src/nextcloud-status/*`, `apps/api/src/crypto/crypto.service.ts`, `apps/api/src/ldap/ldap-config.service.ts`, `apps/api/src/module-registry/module.guard.ts`, `apps/api/src/handelsware-datev/*`, `apps/api/prisma/migrations/20261002130000_handelsware_datev`, `apps/web/src/lib/module-loader.ts|module-identity.ts|stores/nav-store.ts`
+
+### Secondary/Tertiary (MEDIUM/LOW)
+- docs.wisecp.com/en/internetx.md (Demo-Context 1, Drittanbieter)
+- DENIC Pressemitteilung 25.05.2018 und Registrar-Wissensdatenbanken (.de-Regeln, ueber Websuche, nicht im Volltext geprueft)
+
+## Metadata
+**Confidence:** Standard-Stack HIGH (nichts Neues); AutoDNS-Routen/Schemas HIGH; Antwortformen der Bestell-/Job-Routen MEDIUM; .de-Regeln LOW-MEDIUM
+**Research date:** 2026-10-08, gueltig ca. 30 Tage (OpenAPI aendert sich selten, aber vor Implementierung gegen Demo gegenpruefen)
diff --git a/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-REVIEW.md b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-REVIEW.md
new file mode 100644
index 0000000..5454e2c
--- /dev/null
+++ b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-REVIEW.md
@@ -0,0 +1,181 @@
+---
+phase: 261008-dts-modul-domains-autodns-anbindung-kontakte
+reviewed: 2026-10-08T00:00:00Z
+depth: quick
+files_reviewed: 37
+files_reviewed_list:
+ - apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql
+ - apps/api/prisma/schema.prisma
+ - apps/api/src/app.module.ts
+ - apps/api/src/domains/autodns-client.ts
+ - apps/api/src/domains/autodns-parse.ts
+ - apps/api/src/domains/domain-name.ts
+ - apps/api/src/domains/domains-cache.ts
+ - apps/api/src/domains/domains-directory.service.ts
+ - apps/api/src/domains/domains-orders.service.ts
+ - apps/api/src/domains/domains-settings.service.ts
+ - apps/api/src/domains/domains.controller.ts
+ - apps/api/src/domains/domains.module.ts
+ - apps/api/src/domains/domains.seed.ts
+ - apps/api/src/domains/domains.types.ts
+ - apps/api/src/domains/dto/domains-contact.dto.ts
+ - apps/api/src/domains/dto/domains-customer.dto.ts
+ - apps/api/src/domains/dto/domains-order.dto.ts
+ - apps/api/src/domains/dto/domains-settings.dto.ts
+ - apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/layout.tsx
+ - apps/web/src/app/(portal)/modules/domains/page.tsx
+ - apps/web/src/components/domains/group-by-customer.ts
+ - apps/web/src/components/domains/order-status.ts
+ - apps/web/src/lib/domains-api.ts
+ - apps/web/src/lib/module-identity.ts
+ - apps/web/src/lib/module-loader.ts
+ - apps/web/src/lib/stores/nav-store.ts
+ - apps/web/src/components/modules/module-tile.tsx
+findings:
+ critical: 1
+ warning: 6
+ info: 4
+ total: 11
+status: issues_found
+---
+
+# Quick 261008-dts: Code Review Report
+
+**Reviewed:** 2026-10-08
+**Depth:** quick (focus area 1, money safety, traced in full)
+**Files Reviewed:** 37
+**Status:** issues_found
+
+## Summary
+
+The core money-safety design holds up. The DRAFT -> SUBMITTING claim is an atomic `updateMany` that includes the environment in its `where`, and it happens before any network call. `autodnsRequest` has no retry and there is exactly one `POST /domain`. Timeouts and network errors end in UNKNOWN. The web client has no automatic retry and locks the confirm button after an error. Concurrent confirms, a second tab and a double click cannot produce two POSTs.
+
+Secrets, tenant isolation, permission levels and route order are clean:
+- **Secrets:** The password never leaves the settings service. Views only carry `hasPassword`. No log line or error text contains headers or credentials.
+- **Tenant isolation:** Every Prisma `where` names `tenantId`, RLS is on for all four tables, and the cache key includes tenant, environment and config version.
+- **Permissions:** Writes are `@ModuleManage`, reads use the class-level `@UseModule`, and there is no `@Roles`.
+- **Route order:** `orders/refresh` comes before `orders/:id/*`.
+
+One real hole remains in the "never guess" rule. A non-2xx answer that carries an envelope is treated as "AutoDNS rejected the order", even for 5xx (CR-01). Several smaller gaps around the discard and reconcile path make that hole easier to reach.
+
+## Fix Status (2026-10-08)
+
+| ID | Status | Commit | Note |
+|----|--------|--------|------|
+| CR-01 | fixed | cc1c83a | 5xx, 408, 425 (with or without envelope) now classify as `http`; only 4xx with envelope, 401/403 and 2xx+ERROR are definitive. Submit: `http`, timeouts, network, tls and 429 all end in UNKNOWN (429 origin is not proven to be AutoDNS pre-processing). Reconcile treats non-`business` failures as inconclusive. Tests: client spec table, order spec for 408/425/429/500/502/503/504 on submit, reconcile and job search. Requires human verification (logic change). |
+| WR-01 | fixed | cc1c83a | `cancelOrder` re-runs the reconcile at discard time; only an explicit not-found discards. Found -> 409 `orderFound`, inconclusive -> 409 `checkFirst`, other system -> 409 `environmentChanged`. Requires human verification. |
+| WR-02 | fixed | cc1c83a | Summary carries `version` (draft `updatedAt`); submit DTO requires it, claim `where` includes `updatedAt`, mismatch -> 409 `orderChanged` (German message shown by the register dialog). Requires human verification. |
+| WR-03 | fixed | cc1c83a | Jobs for the domain without readable date, jobs without readable object and a full result page (10) without match are inconclusive: `lastCheckedAt` stays empty. Note: the `object` filter key is still unverified against the live API. |
+| WR-04 | fixed | 268d6d5 | `@ValidateIf(v !== undefined)` instead of `@IsOptional()`; context numbers stay nullable. DTO spec added. |
+| WR-05 | fixed | 9ecf191 | `prices[0]` fallback removed; no verifiable one-year entry -> no price. |
+| WR-06 | fixed | 9eada2e, e1dd996 | `ModuleGuard` stores `request.moduleAccessLevel`; `?refresh` is ignored unless MANAGE. Refresh buttons hidden for non-managers. |
+| IN-01 | skipped | - | Out of scope (needs grace-period design for `openKey` on SUCCESS). |
+| IN-02 | skipped | - | Out of scope (needs a migration with a partial unique index). |
+| IN-03 | fixed | cc1c83a | Post-POST write wrapped: logs order id and job number, falls back to UNKNOWN (never DRAFT/FAILED), returns an UNKNOWN view instead of 500; if that fails too the row stays SUBMITTING and becomes UNKNOWN after 2 minutes. |
+| IN-04 | skipped | - | Out of scope (needs a migration). |
+
+## Critical Issues
+
+### CR-01: HTTP 5xx with an AutoDNS envelope is classified as definitive rejection, so the order is FAILED and the domain key is freed (double-registration path)
+
+**File:** `apps/api/src/domains/autodns-client.ts:150-155`, used at `apps/api/src/domains/domains-orders.service.ts:546-556` and `:718`
+**Issue:** `failureKindForStatus` returns `'business'` for every non-2xx status other than 401, 403 and 429 whenever the body looks like an envelope. The envelope test is loose: `'messages' in parsed` is enough. `autodns-client.spec.ts:187` pins `500 + envelope -> business` as intended.
+
+`outcomeOfSubmit` maps `business` to `FAILED` with `openKey: null`. A 500, 502, 503, 504 or 408 with a JSON envelope therefore marks the order as not placed. That is the case the class header says must never be guessed. A gateway or backend that times out after the job was created, but still answers with an AutoDNS-style error envelope, will:
+
+1. save the order as FAILED and free `openKey`, and
+2. let the user (or a second user) create and submit a new order for the same domain, which is a second `POST /domain` and a second charge.
+
+The same classification weakens `reconcile`. Line 718 reads `!domain.ok && domain.kind !== 'business'` as "AutoDNS said: domain does not exist". A 500 with an envelope is then taken as "not found" and the order can move toward discardable.
+
+**Fix:** Treat only unambiguous rejections as FAILED. That means HTTP 4xx other than 408 and 425 with an envelope, plus 2xx with `status.type === 'ERROR'`. Everything with `httpStatus >= 500` becomes UNKNOWN. In `parseAutodnsEnvelope`, return `'http'` for a non-2xx status that is 408, 425 or 5xx, regardless of the envelope:
+
+```ts
+function failureKindForStatus(httpStatus: number, hasEnvelope: boolean): AutodnsFailureKind {
+ if (httpStatus === 401) return 'auth';
+ if (httpStatus === 403) return 'forbidden';
+ if (httpStatus === 429) return 'rate-limit';
+ if (httpStatus >= 500 || httpStatus === 408 || httpStatus === 425) return 'http';
+ return hasEnvelope ? 'business' : 'http';
+}
+```
+
+In `outcomeOfSubmit`, also keep `rate-limit` out of the definitive-FAILED group unless it is known to come from AutoDNS. Update `autodns-client.spec.ts:187` (`[500, 'business']` becomes `[500, 'http']`) and add an order-service test: submit answered with `500 + envelope` must end in UNKNOWN with `openKey` kept.
+
+## Warnings
+
+### WR-01: "Discard" of an UNKNOWN order relies on a check of arbitrary age
+
+**File:** `apps/api/src/domains/domains-orders.service.ts:754-767`, `apps/web/src/components/domains/order-status.ts:53-55`
+**Issue:** After one reconcile that found nothing, `lastCheckedAt` is set. From then on the order is discardable forever. The web client also stops polling it, because `isOpen` is false once `lastCheckedAt` is set. Registrar jobs can be delayed, so a check that was true ten minutes ago may no longer be true. A later discard frees `openKey`, and a re-order then runs a second `POST /domain` while the first can still complete.
+**Fix:** In `cancelOrder`, require a recent check. For example, reject with `checkFirst` when `lastCheckedAt` is older than 5 minutes. Better, run `reconcile` inside `cancelOrder` and discard only if it still finds nothing.
+
+### WR-02: Registration is claimed with a payload the user did not confirm (lost update on a shared DRAFT)
+
+**File:** `apps/api/src/domains/domains-orders.service.ts:386-393` and `:466-488`
+**Issue:** `createOrder` for an existing DRAFT overwrites `payload` (contacts, name servers) with `updateMany`. `submitOrder` reads the payload first and claims afterwards, with no version check. If manager B re-runs "Show summary" for the same domain after manager A has seen the summary, A confirms and registers with B's owner contact and name servers. The same happens if B's update lands between A's read and A's claim.
+**Fix:** Return `order.updatedAt` or a payload hash in the summary. The client sends it back on submit, and the claim becomes `where: { id, tenantId, status: 'DRAFT', environment, updatedAt: before.updatedAt }`. Alternatively refuse to overwrite a DRAFT that another user created.
+
+### WR-03: Job matching in `reconcile` can silently miss an existing job and then allow discard
+
+**File:** `apps/api/src/domains/domains-orders.service.ts:727-742`, `apps/api/src/domains/autodns-parse.ts:287-306`
+**Issue:** Candidates need `j.object === row.domainName && j.created && created >= since`. If AutoDNS returns a matching job with no readable `created` (the parser also falls back to `started` and `added`), or with the object in another form, the job is dropped. The result is "nothing found", `lastCheckedAt` is set, and the order becomes discardable although a job exists. The `filters` key `object` is also unverified against the live API.
+**Fix:** Fail towards caution. If a job for the domain exists but cannot be dated, treat it as a match, or leave `lastCheckedAt` unset and do not make the order discardable.
+
+### WR-04: `@IsOptional()` lets `null` through, which causes 500 instead of 400
+
+**File:** `apps/api/src/domains/dto/domains-settings.dto.ts:25-69`, `apps/api/src/domains/domains-settings.service.ts:194-203`
+**Issue:** `@IsOptional()` skips validation for `null` as well as `undefined`. Sending `{"demoUser": null}`, `{"defaultNameServers": null}` or `{"environment": null}` passes validation. The service only checks `!== undefined` and then calls `.trim()` or `.map()` on `null`, which is a TypeError, or writes `null` into a non-null column. This needs a manager account, but it is a bug and not a validation response.
+**Fix:** Use `@ValidateIf((_o, v) => v !== undefined)` in place of `@IsOptional()` for these fields. Alternatively check `!= null` in the service.
+
+### WR-05: Price shown for one year can actually be another period's price
+
+**File:** `apps/api/src/domains/autodns-parse.ts:195-196`
+**Issue:** `price = parsePriceEntry(oneYear) ?? parsePriceEntry(prices[0])`. If no entry matches one year, the first entry is used and then displayed as the price for 1 year in the confirmation of a binding order. The fallback could be a multi-year or transfer price.
+**Fix:** Drop the `prices[0]` fallback and return `null`. The UI already says "price unknown" for `null`.
+
+### WR-06: Any USE user can force full AutoDNS re-reads with `?refresh=1`
+
+**File:** `apps/api/src/domains/domains.controller.ts:105-129`, `apps/api/src/domains/domains-directory.service.ts:160-197`
+**Issue:** `GET contacts?refresh=1` and `GET domains?refresh=1` skip the cache for read-only users. Each call is up to 20 sequential POSTs on the shared 3 requests/second limiter, in addition to any refresh cycle. A read-only user can starve order submission and reconcile, and since the limiter is shared per process, that affects the whole instance.
+**Fix:** Honour `refresh` only for managers. Add `@ModuleManage` on a separate refresh route, or ignore the flag without MANAGE. A minimum interval per tenant (for example 10 seconds) also helps.
+
+## Info
+
+### IN-01: SUCCESS keeps `openKey` for good
+
+**File:** `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql` (header comment), `apps/api/src/domains/domains-orders.service.ts:362`
+**Issue:** A registered domain that is later deleted or expires can never be ordered again through Tessera in that environment. The user sees `orderOpen` for good.
+**Fix:** Clear `openKey` on SUCCESS after a grace period (for example 24 hours), or let `createOrder` ignore SUCCESS rows older than that.
+
+### IN-02: "Own company" is not enforced in the database
+
+**File:** `apps/api/src/domains/domains-directory.service.ts:286-311`
+**Issue:** Two concurrent `createCustomer` calls with `isOwnCompany: true` can both succeed, leaving two own companies. The register tab then picks the first one.
+**Fix:** Add a partial unique index: `CREATE UNIQUE INDEX ... ON "DomainsCustomer"("tenantId") WHERE "isOwnCompany"`.
+
+### IN-03: Failure of the final status write is not handled
+
+**File:** `apps/api/src/domains/domains-orders.service.ts:522-533`
+**Issue:** If the database write after `POST /domain` throws, the user gets a 500 although the order may have been placed. The state is still safe (SUBMITTING becomes UNKNOWN after 2 minutes). The error is neither logged with the order id nor turned into a clear UNKNOWN answer.
+**Fix:** Wrap the write, log the order id, and return an UNKNOWN view or a dedicated error code. The web client already locks the button.
+
+### IN-04: Redundant index
+
+**File:** `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql` (`DomainsConfig_tenantId_idx`)
+**Issue:** `DomainsConfig_tenantId_key` already covers `tenantId`, so the extra index is redundant.
+**Fix:** Remove `@@index([tenantId])` on `DomainsConfig` in a later migration.
+
+---
+
+_Reviewed: 2026-10-08_
+_Reviewer: Claude (gsd-code-reviewer)_
+_Depth: quick (money-safety path traced in full)_
diff --git a/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md
new file mode 100644
index 0000000..9bb564a
--- /dev/null
+++ b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md
@@ -0,0 +1,172 @@
+---
+phase: quick-261008-dts
+plan: 01
+subsystem: domains
+tags: [autodns, domains, nestjs, nextjs, prisma, rls, money-safety]
+status: complete
+requires:
+ - module-registry (ModuleGuard, UseModule, ModuleManage)
+ - crypto (CryptoService, global)
+ - prisma tenant extension (forTenant)
+provides:
+ - Modul Domains (Slug domains) mit AutoDNS-Anbindung Demo/Live
+ - Kunden, Kontakt-Zuordnung, Domainliste, Verfügbarkeit, Registrierung, Aufträge
+affects:
+ - docs/mandantentrennung-zugriffsklassifikation.md
+ - CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
+tech-stack:
+ added: []
+ patterns:
+ - atomarer Anspruch (updateMany where status DRAFT, count === 1) vor dem Netzaufruf
+ - ein Client für alle AutoDNS-Aufrufe (feste Hosts, Takt 350 ms, kein Retry)
+ - Pull-Abgleich offener Aufträge statt Cron
+key-files:
+ created:
+ - apps/api/src/domains/autodns-client.ts
+ - apps/api/src/domains/autodns-parse.ts
+ - apps/api/src/domains/domain-name.ts
+ - apps/api/src/domains/domains-settings.service.ts
+ - apps/api/src/domains/domains-directory.service.ts
+ - apps/api/src/domains/domains-orders.service.ts
+ - apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql
+ - apps/web/src/app/(portal)/modules/domains/page.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
+ - apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx
+ modified:
+ - apps/api/src/domains/domains.controller.ts
+ - CHANGELOG.md
+decisions:
+ - "Aufträge des nicht aktiven Systems werden beim Abgleich nicht angefasst (Zugangsdaten gehören zum aktiven System); nur der SUBMITTING-zu-UNKNOWN-Übergang gilt für alle."
+ - "Beim Abschicken wird der Entwurf VOR dem Anspruch gelesen, damit genau der bestätigte Inhalt hinausgeht; der Zugang wird vor dem Anspruch geladen, damit ein fehlender Zugang nie einen Auftrag in SUBMITTING stehen lässt."
+ - "HTTP-Antwort ohne Umschlag (Gateway 502/504) zählt als UNKNOWN, nicht als FAILED; FAILED nur bei ausdrücklicher AutoDNS-Ablehnung (auth, forbidden, rate-limit, business)."
+ - "UNKNOWN wird beim Abschicken ohne lastCheckedAt gespeichert; erst ein Abgleich mit Ergebnis setzt es und schaltet das Verwerfen frei. Ein Fehler beim Nachsehen setzt es nicht."
+ - "Auftragssuche beim Abgleich nimmt nur Aufträge mit lesbarer Anlagezeit ab 5 Minuten vor der Bestätigung; ohne Zeit zählt ein Auftrag nicht (lieber UNKNOWN als ein falscher Treffer)."
+metrics:
+ tasks: 3
+ completed: 2026-10-08
+actuals:
+ tokens: 109600
+ tasks: 3
+ commits: 3
+plan_head_before: 6b0f840c1cf1ad09d593e9f607b5230541fdfb2b
+plan_head_after: d332246bdead2b5ec87156dd610945687c672016
+---
+
+# Phase quick-261008-dts Plan 01: Modul Domains (AutoDNS) Summary
+
+Neues Modul „Domains“ mit verschlüsselter Demo-/Live-Anbindung an AutoDNS, Kunden- und Kontaktzuordnung, Domainliste und einer Registrierung, die pro Auftrag höchstens einmal an AutoDNS geht und bei unklarem Ausgang auf „Ergebnis ungeklärt“ stehen bleibt, statt zu raten.
+
+## Commits
+
+| Aufgabe | Commit | Inhalt |
+|---|---|---|
+| 1 (Tracer) | 1f1c984 | Migration (4 Tabellen mit Zeilenschutz), AutoDNS-Client, Einstellungen, Verbindungstest, Modulseite mit Reiter Einstellungen, Registrierungen (Loader, Icon, Navigationstitel) |
+| 2 | 450218f | Kunden, Kontakte live aus AutoDNS (Seiten, 60-s-Zwischenspeicher), Zuordnung, Kontakt anlegen, Domainliste, Reiter Domains/Kontakte/Kunden |
+| 3 | d332246 | Verfügbarkeit, Entwurf, verbindliches Abschicken mit atomarem Anspruch, Auftragsverfolgung und Abgleich, Reiter Registrieren/Aufträge, Changelog, Anwender- und Administrationshandbuch, Mandantenschutz-Inventar |
+
+Nichts gepusht. Docs-Artefakte (PLAN, RESEARCH, SUMMARY, STATE) sind nicht committet.
+
+## Aufgabe 3 im Detail (Geldsicherheit)
+
+- **Anspruch vor dem Netz:** `submitOrder` führt `updateMany` mit `id, tenantId, status: 'DRAFT', environment: ` aus. Nur bei `count === 1` geht genau ein `POST /domain` (ohne Query, ohne Wiederholung) hinaus. Wer `count !== 1` bekommt, lädt die Zeile neu und erhält 404, 409 `environmentChanged` oder 409 `alreadySubmitted`.
+- **Ausgang:** Auftragsnummer vorhanden gleich SUBMITTED. Ausdrückliche AutoDNS-Ablehnung (auth, forbidden, rate-limit, business, auch HTTP 200 mit `status.type` ERROR) gleich FAILED mit `openKey` null. Zeitablauf, Netz, TLS, unlesbare oder umschlaglose Antwort gleich UNKNOWN, nie zurück auf DRAFT.
+- **Eindeutigkeit:** `openKey` (Domain) mit `@@unique([tenantId, environment, openKey])`; FAILED und CANCELED geben ihn frei, SUCCESS behält ihn bewusst.
+- **Abgleich:** Pull beim Öffnen des Reiters „Aufträge“, bei „Aktualisieren“ und alle 30 s bei offenen Aufträgen und sichtbarer Seite; höchstens 20 Aufträge je Lauf, am längsten ungeprüfte zuerst; ein Fehler bei einem Auftrag hält die anderen nicht auf. SUBMITTING älter als 120 s wird UNKNOWN. UNKNOWN: erst `GET /domain/{name}`, dann `POST /job/_search`.
+- **Verwerfen:** Entwurf jederzeit; UNKNOWN erst nach einem Abgleich mit Ergebnis (`lastCheckedAt` gesetzt), im Browser mit zusätzlicher Warnung.
+- **Browser:** `useRef`-Sperre gegen Doppelklick; nach einem Fehler beim Abschicken bleibt der Knopf gesperrt, bis „Zurück“ gewählt wird (der Auftrag könnte angekommen sein).
+
+## Messwerte der Prüfkette (Aufgabe 3)
+
+| Segment | Ergebnis |
+|---|---|
+| `pnpm --filter @tessera/api test` | 133 Dateien, 2423 Tests grün |
+| `pnpm --filter @tessera/web test` | 132 Dateien, 1455 Tests grün |
+| `tsc --noEmit` api und web | grün |
+| `biome check` (alle angefassten Dateien) | grün |
+| rls-coverage, rls-access-inventory | 35 Tests grün (domains: 0/30/0, Paare 104) |
+| Rollen-Decorator im Controller | keiner |
+| de/en-Schlüsselgleichheit `domains.*`, Wortprüfung (Mandant, Lizenz) | 207 Schlüssel, gleich, ohne Treffer |
+| CHANGELOG-/Handbuch-Greps | ok |
+| Neuaufbau `docker compose up -d --build api web` | api, web, db laufen; „Domains module seeded in registry“; Routen `orders/refresh` vor `orders/:id/submit`; keine ausstehenden Migrationen; `GET customers` und `GET orders` geben `[]`; `POST availability` ohne Zugang gibt 409 `notConfigured` |
+| Gegenprobe Doppelbestellung | Anspruchsprüfung testweise ausgebaut: Test „zwei gleichzeitige Bestätigungen“ und Test „Systemwechsel zwischen Lesen und Anspruch“ schlugen fehl; danach zurückgebaut |
+
+## Abweichungen vom Plan
+
+### Automatisch behoben und ergänzt
+
+**1. [Regel 2 - Fehlende Absicherung] Kontakt-Prüfung beim Entwurf**
+- **Ort:** `createOrder`
+- **Problem:** Ohne Prüfung könnte ein Entwurf Kontaktnummern enthalten, die bei AutoDNS nicht (mehr) existieren.
+- **Lösung:** Alle vier Kontaktnummern werden gegen die Kontaktliste geprüft (`findContactsByIds`), sonst 400 `contactNotFound`. Grenze: bei mehr als 2000 Kontakten kann ein vorhandener Kontakt außerhalb der gelesenen Liste fälschlich abgelehnt werden; die Meldung rät zum Neu-Einlesen.
+
+**2. [Regel 3 - Blockierend] Zeitlimit im Test einstellbar**
+- `DomainsSettingsService.transport` bekam das Feld `timeoutMs` (nur für Tests), damit der Zeitablauf-Fall mit 20 ms getestet werden kann; im Betrieb bleibt es leer (20 s). `HOSTNAME_PATTERN` wird exportiert und im Auftragsdienst wiederverwendet.
+
+**3. [Regel 1 - Fehler im eigenen Entwurf] Zusätzliche Absicherungen im Abgleich**
+- Ein Fehler beim Nachsehen (Anmeldung, Zeitablauf, Gateway) setzt `lastCheckedAt` nicht, damit „Verwerfen“ nie auf einer Prüfung beruht, die nichts geprüft hat. Nur eine ausdrückliche AutoDNS-Antwort „gibt es nicht“ (Fehlerart business) zählt als Ergebnis.
+- Alle Schreibzugriffe des Abgleichs sind an den erwarteten Zustand gebunden (`status` in der `where`-Klausel), damit zwei gleichzeitige Abgleiche sich nicht überschreiben.
+
+**4. [Hinweis] Reiter Aufträge für alle, auch ohne Einrichtung**
+- Der Reiter ist immer sichtbar (Liste kommt aus der Datenbank); der Abgleich überspringt ihn still, wenn das aktive System nicht eingerichtet ist.
+
+**5. [Aufräumen] Versehentliche Fremdformatierung rückgängig gemacht**
+- Ein Formatierungslauf hatte `module-manage-handlers.spec.ts` in unbeteiligten Zeilen umgebrochen; die Datei wurde zurückgesetzt und nur um die sechs Handlernamen ergänzt.
+
+### Nicht behoben (vorbestehend)
+
+Keine fehlschlagenden Tests. `biome organizeImports` meldet in `module-manage-handlers.spec.ts` eine bereits vor dieser Arbeit vorhandene Importreihenfolge; bewusst nicht angefasst.
+
+## Threat-Status
+
+| ID | Stand |
+|---|---|
+| T-dts-01 Passwort | umgesetzt (Aufgabe 1); Antworten tragen nur `hasPassword` |
+| T-dts-02 Rechte | alle Schreib- und Verwalten-Wege tragen `@ModuleManage('domains')`, kein Rollen-Decorator; Controller- und Handler-Spec grün |
+| T-dts-03 Doppelbestellung | umgesetzt und mit Gegenprobe belegt (siehe oben) |
+| T-dts-04 SSRF | feste Hosts, `redirect: 'error'`, Pfadprüfung |
+| T-dts-05 Organisationstrennung | `forTenant` überall, RLS-Gates grün |
+| T-dts-06 Abfragegrenze | Takt 350 ms, höchstens 20 Aufträge je Abgleich, Verbindungstest ein Aufruf |
+| T-dts-07 Demo/Live | System in Anspruch und Zuordnung, Wechsel nur mit Bestätigung |
+| T-dts-08 Routen-Reihenfolge | `orders/refresh` vor `orders/:id/...`, Spec prüft |
+| T-dts-09 Eingaben | `normalizeDomainName`, Validatoren, `ParseUUIDPipe` |
+| T-dts-10 Fehlertexte | nur `messages[].text`, gekürzt; AutoDNS 401/403 als 502 |
+
+## Bekannte Stubs
+
+Keine. Die Anzeige „Preis nicht ermittelbar“ ist gewollt (AutoDNS nennt keinen Preis).
+
+## Prüfung gegen AutoDNS-Demo
+
+Voraussetzung: Demo-Zugangsdaten eintragen (Domains, Einstellungen, Zugang Demo-System, Benutzername, Passwort, Kontext). System bleibt auf „Demo-System“. Jeder Punkt schließt eine offene Annahme aus der Recherche.
+
+| Nr. | Annahme | Prüfung in der Oberfläche | Wenn es abweicht |
+|---|---|---|---|
+| 1 | A1: Welcher Demo-Kontext funktioniert (1 oder 4) | Einstellungen, Zugang Demo-System: Kontext 4 eintragen, speichern, „Verbindung testen“; bei Fehler 1 versuchen. Je Versuch genau einmal klicken (Sperrgefahr) | Wert merken und in die Handbücher übernehmen |
+| 2 | A6/A9: Anmeldung ohne Zwei-Faktor, Test gelingt | „Verbindung testen“ zeigt „Verbindung erfolgreich“ | bei Anmeldefehler Benutzer ohne 2FA verwenden |
+| 3 | A2: Kontaktfelder und Telefonformat | Reiter Kontakte, „Neuer Kontakt“: erst Person, dann Organisation anlegen (Telefon +49 30 123456). Meldung „Der Kontakt wurde bei AutoDNS angelegt“, Kontakt erscheint nach „Aus AutoDNS neu einlesen“ | Fehlermeldung von AutoDNS lesen (steht im Formular); Pflichtfelder oder Telefonformat anpassen |
+| 4 | A3: Verfügbarkeitsprüfung liefert genau die gefragte Domain mit WHOIS-Status und Preis | Reiter Registrieren: eine sicher freie Domain (zufälliger Name) und eine belegte (zum Beispiel denic.de) prüfen. Frei zeigt „… ist frei“ mit Preis; belegt zeigt „nicht frei“ mit Status. Außerdem eine Umlautdomain prüfen | Steht immer „nicht frei“ mit Status „NO_RESULT“, liefert DomainStudio den Namen anders (Suchwort, Endung) und `parseDomainStudio` oder die Anfrage ist anzupassen. Fehlt nur der Preis, erscheint „Preis nicht ermittelbar“: Preisform prüfen |
+| 5 | A4/A5: Registrierung liefert Auftragsnummer, `{ id }` genügt als Kontaktverweis | Zusammenfassung erstellen, Häkchen setzen, „Jetzt verbindlich registrieren“. Erwartet: „wird bearbeitet“, Reiter Aufträge zeigt „In Bearbeitung“ | „Fehlgeschlagen“ mit Text von AutoDNS: Text lesen (Kontaktverweis, Nameserver, Pflichtfelder). Steht ein Auftrag ohne Nummer auf „In Bearbeitung“ und ändert sich nie, ist die Fundstelle der Nummer anders (`extractJobFromSubmit`) |
+| 6 | Auftrag erreicht SUCCESS, Tessera folgt | Reiter Aufträge, „Aktualisieren“ (oder 30 s warten): Stand wechselt auf „Registriert“ | bleibt er „In Bearbeitung“, in AutoDNS den Auftrag ansehen; „Rückfrage nötig“ heißt Status SUPPORT |
+| 7 | D-J: Domainliste mit Inhaber, Ablaufdatum, `registryStatus` | Reiter Domains, „Aus AutoDNS neu laden“: die neue Domain erscheint mit Inhaber, Ablaufdatum und Status (Aktiv, In Bearbeitung …). Kunde nach Zuordnung des Inhaber-Kontakts | Status als Rohwert angezeigt: `registryStatus` hat andere Werte, Beschriftungen nachziehen. Ablaufdatum „–“: Feld `expire` fehlt in der Antwort |
+| 8 | A8: `.de` mit Standard-Nameservern besteht die Nameserver-Prüfung | Standard-Nameserver in den Einstellungen eintragen (bei AutoDNS eingerichtete), eine freie `.de`-Domain registrieren | „Fehlgeschlagen“ mit Nameserver-Meldung: Nameserver einrichten oder korrigieren |
+| 9 | Neu in Aufgabe 3: Auftragssuche (`POST /job/_search`, Filter `object`, Anlagezeit) und `GET /domain/{name}` für nicht Gefundenes | Schwer künstlich auszulösen. Wenn ein Auftrag je auf „Ergebnis ungeklärt“ steht: „Aktualisieren“ klicken. Erwartet: Tessera findet die Domain (Registriert), einen Auftrag (In Bearbeitung) oder setzt die Prüfung (dann ist „Verwerfen“ möglich) | Bleibt „Verwerfen“ trotz Abgleich gesperrt, liefert `GET /domain/{name}` für Unbekanntes keinen business-Fehler; Antwortform prüfen |
+| 10 | Neu in Aufgabe 3: Umlautdomain wird als Punycode gesucht und bestellt | Umlautdomain prüfen; Anzeige zeigt die Umlautform | DomainStudio erwartet ggf. die Unicode-Form im Suchwort |
+| 11 | D-J: Wert für „Kündigung vorgemerkt“ | Eine Domain mit vorgemerkter Kündigung in der Liste ansehen (falls vorhanden) | Werte von `cancelationStatus` prüfen (NONE und NOT_SET gelten als „keine“) |
+
+## Browser-Prüfschritte für den Orchestrator (dunkler Modus bevorzugt)
+
+Lokal: `http://localhost:3000`, Anmeldung `admin` / `admin123`. Playwright-Prüfung mit Theme-Knopf auf dunkel.
+
+1. **Aktivieren:** Marktplatz, Modul „Domains“ aktivieren. Erwartet: Modul in der Seitenleiste (Gruppe Domains), Erde-Symbol.
+2. **Freigabe Benutzen gegen Verwalten:** Mit einem normalen Benutzer mit „Benutzen“ anmelden: Reiter Domains, Kontakte, Kunden, Aufträge sichtbar; kein Registrieren, kein Einstellungen, kein „Neuer Kontakt“, keine Zuordnung, keine Kunden-Knöpfe. Mit Admin oder „Verwalten“: zusätzlich Registrieren und Einstellungen.
+3. **Einstellungen:** Ohne Zugang zeigt die Kopfzeile „AutoDNS nicht eingerichtet“ mit Hinweis und Knopf „Zu den Einstellungen“. Passwort eintragen, speichern, Seite neu laden: Passwortfeld leer mit Platzhalter „Gespeichert – leer lassen …“. Live-Kontext ist mit 4 vorbelegt. Auswahl „Live-System“: Bestätigungsfeld erscheint; „Abbrechen“ stellt Demo zurück; erst „Live-System verwenden“ speichert, Kopfzeile zeigt dann „Live-System – Registrierungen kosten Geld“. Wieder auf Demo zurückstellen. Nameserver (zwei) eintragen und speichern.
+4. **Kontakte:** Liste, Suche, Filter „Nicht zugeordnet“, „Nach Kunde gruppieren“; Kontakte ankreuzen, Kunden wählen, „Zuordnen“; „Zuordnung entfernen“; „Neuer Kontakt“ mit ungültiger E-Mail und mit Telefon ohne „+“ (Fehler sichtbar), dann gültig speichern.
+5. **Kunden:** Kunden anlegen, einen als „Eigene Firma“ markieren (Kennzeichnung erscheint, bei einem zweiten wandert sie); Kunden mit Kontakten löschen: Meldung „Diesem Kunden sind noch Kontakte zugeordnet …“.
+6. **Domains:** Liste, Gruppen pro Kunde mit „Nicht zugeordnet“ zuletzt, Kundenfilter blendet andere Gruppen aus, Datum als TT.MM.JJJJ.
+7. **Registrieren:** Ungültiger Name zeigt Meldung; freie und belegte Domain; Kontaktauswahl gruppiert nach Kunde, Voreinstellung Admin gleich Inhaber, Technik/Zone gleich eigene Firma; Nameserver hinzufügen/entfernen (2 bis 6); „Zusammenfassung anzeigen“ zeigt System-Kennzeichnung, Preis oder „Preis nicht ermittelbar“; „Jetzt verbindlich registrieren“ bleibt gesperrt bis zum Häkchen; **Doppelklick auf den Knopf** darf nur einen Auftrag erzeugen (Reiter Aufträge zeigt eine Zeile, Server-Log zeigt ein einziges „Registrierung abgeschickt“). Vorsicht: Mit Demo-Zugang ohne echte Daten antwortet AutoDNS mit einem Fehler, die Zeile steht dann auf „Fehlgeschlagen“ oder „Ergebnis ungeklärt“; beides ist ein gültiger Ausgang.
+8. **Aufträge:** Reiter zeigt Zeilen mit System (Demo/Live), Stand, Bestätigt von/am, Fehlertext; „Aktualisieren“; „Verwerfen“ nur für Verwalter bei Entwurf und geprüftem unklarem Auftrag, mit Rückfrage.
+
+## Self-Check: PASSED
+
+- Dateien vorhanden: domain-name.ts, domains-orders.service.ts, dto/domains-order.dto.ts, RegisterTab.tsx, OrdersTab.tsx, order-status.ts (alle von `git status` als hinzugefügt bestätigt).
+- Commits vorhanden: 1f1c984, 450218f, d332246 liegen in der Historie von HEAD (`git rev-list --count 6b0f840..HEAD` gleich 3).
diff --git a/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-VERIFICATION.md b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-VERIFICATION.md
new file mode 100644
index 0000000..6ca445d
--- /dev/null
+++ b/.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-VERIFICATION.md
@@ -0,0 +1,131 @@
+---
+phase: quick-261008-dts
+verified: 2026-10-08T11:25:00Z
+status: human_needed
+score: 7/7 must-haves verified
+covered_files: [".planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-PLAN.md",".planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md","CHANGELOG.md","apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql","apps/api/prisma/schema.prisma","apps/api/src/app.module.ts","apps/api/src/domains/autodns-client.spec.ts","apps/api/src/domains/autodns-client.ts","apps/api/src/domains/autodns-parse.spec.ts","apps/api/src/domains/autodns-parse.ts","apps/api/src/domains/domain-name.spec.ts","apps/api/src/domains/domain-name.ts","apps/api/src/domains/domains-cache.ts","apps/api/src/domains/domains-directory.service.spec.ts","apps/api/src/domains/domains-directory.service.ts","apps/api/src/domains/domains-orders.service.spec.ts","apps/api/src/domains/domains-orders.service.ts","apps/api/src/domains/domains-settings.service.spec.ts","apps/api/src/domains/domains-settings.service.ts","apps/api/src/domains/domains.controller.spec.ts","apps/api/src/domains/domains.controller.ts","apps/api/src/domains/domains.module.ts","apps/api/src/domains/domains.seed.ts","apps/api/src/domains/domains.types.ts","apps/api/src/domains/dto/domains-contact.dto.ts","apps/api/src/domains/dto/domains-customer.dto.ts","apps/api/src/domains/dto/domains-order.dto.ts","apps/api/src/domains/dto/domains-settings.dto.ts","apps/api/src/module-registry/module-manage-handlers.spec.ts","apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx","apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx","apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx","apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx","apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx","apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx","apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx","apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx","apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx","apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx","apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx","apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx","apps/web/src/app/(portal)/modules/domains/layout.tsx","apps/web/src/app/(portal)/modules/domains/page.tsx","apps/web/src/app/(portal)/modules/module-layouts.test.tsx","apps/web/src/components/domains/group-by-customer.test.ts","apps/web/src/components/domains/group-by-customer.ts","apps/web/src/components/domains/order-status.test.ts","apps/web/src/components/domains/order-status.ts","apps/web/src/components/domains/ui-classes.ts","apps/web/src/components/modules/module-tile.tsx","apps/web/src/lib/domains-api.ts","apps/web/src/lib/module-identity.ts","apps/web/src/lib/module-loader.ts","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","docs/anleitung-administration.md","docs/anleitung-anwender.md","docs/mandantentrennung-zugriffsklassifikation.md"]
+covered_digest: "v3:sha256:cbd9180b81679f6e773368c9acac6050b8ea89f07a2ceb0a6717989336115fdf"
+behavior_unverified: 0
+overrides_applied: 0
+human_verification:
+ - test: "Demo-Kontext und Verbindungstest (A1, A2/A6/A9): Demo-Zugangsdaten eintragen, Kontext 4 (sonst 1), je Versuch genau einmal auf 'Verbindung testen' klicken"
+ expected: "'Verbindung erfolgreich. AutoDNS hat die Anmeldung bestätigt.' Welcher Demo-Kontext funktioniert, wird in die Handbücher übernommen"
+ why_human: "Braucht das echte AutoDNS-Demo-System und echte Zugangsdaten; Tests laufen nur gegen einen nachgebauten Aufruf"
+ - test: "Kontakte anlegen (A2): Person und Organisation mit Telefon +49 30 123456 anlegen, danach 'Aus AutoDNS neu einlesen'"
+ expected: "Meldung 'Der Kontakt wurde bei AutoDNS angelegt', Kontakt erscheint in der Liste. Feldsatz und Telefonformat werden von AutoDNS angenommen"
+ why_human: "Die Form des POST /contact-Bodys ist nur gegen die echte API belegbar"
+ - test: "Verfügbarkeit (A3, Umlaut): freie, belegte (z. B. denic.de) und Umlautdomain prüfen"
+ expected: "Frei mit Preis, belegt mit WHOIS-Status, Umlautdomain als Punycode gesucht; ein fehlender Preis zeigt 'Preis nicht ermittelbar'"
+ why_human: "Antwortform von POST /domainstudio (WHOIS-Status, Preisfundstelle) ist nur gegen das echte System prüfbar"
+ - test: "Registrierung im Demo-System (A4, A5, A8): Zusammenfassung, Häkchen, 'Jetzt verbindlich registrieren' für eine freie .de-Domain mit Standard-Nameservern"
+ expected: "Auftrag 'In Bearbeitung' mit Auftragsnummer, Kontaktverweis { id } wird akzeptiert, Nameserver-Prüfung besteht. Danach 'Aktualisieren' bis 'Registriert' (Job SUCCESS); Doppelklick erzeugt genau einen Auftrag"
+ why_human: "POST /domain ist asynchron; Job-Fundstelle, Kontaktverweis und Nameserver-Prüfung sind nur am Demo-System belegbar"
+ - test: "Domainliste nach Registrierung (D-J) und Abgleich unklarer Aufträge"
+ expected: "Domain erscheint mit Inhaber, Ablaufdatum und Status (registryStatus vorhanden); GET /domain/{name} liefert für Unbekanntes einen business-Fehler, damit 'Verwerfen' nach einem Abgleich freigeschaltet wird"
+ why_human: "registryStatus-Werte, cancelationStatus-Werte und die Antwort auf unbekannte Domains sind Annahmen über die echte API"
+ - test: "Rechte im Browser (optional, dunkler Modus): mit einem Benutzer nur mit 'Benutzen' anmelden"
+ expected: "Reiter Domains, Kontakte, Kunden, Aufträge sichtbar; kein Registrieren, kein Einstellungen, keine Schreibknöpfe; die API antwortet bei Schreibrouten 403"
+ why_human: "Die API-Metadaten und Oberflächen-Tests sind grün; ein echter Benutzen-Benutzer wurde in dieser Prüfung nicht angelegt (kein Eingriff in lokale Benutzer)"
+---
+
+# Quick-Task 261008-dts: Modul Domains (AutoDNS) Verification Report
+
+**Task Goal:** Neues Modul "Domains" mit Anbindung an AutoDNS/InterNetX JSON-API: Einstellungen (verschlüsselter Zugang, Demo/Live, Kontext, Standard-Nameserver, Verbindungstest), Kontakte (lesen, anlegen, Kunden zuordnen, filtern/gruppieren), Domain registrieren (Verfügbarkeit, Kontakte, Nameserver, Zusammenfassung + ausdrückliche Bestätigung, keine Doppelbestellung, Job-Status), Domainliste. Ansehen mit "Benutzen", Ändern mit "Verwalten" oder Admin. Tests mit gemockter API. UI Deutsch, siezen, keine Mandant-Begriffe. CHANGELOG + Anleitungen.
+**Verified:** 2026-10-08
+**Status:** human_needed
+**Re-verification:** No, initial verification
+
+Alle sieben Wahrheiten sind im Code belegt. Es gibt keine Lücken. Offen sind nur Prüfungen, die das echte AutoDNS-Demo-System brauchen (gemäß Auftrag human_needed, keine Lücke).
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | Reiter je Recht; API antwortet auf Schreib-/Verwalten-Routen mit 403 für Benutzen | VERIFIED | `domains.controller.ts`: Klasse `@UseModule('domains')`; 12 Handler mit `@ModuleManage('domains')` (GET/PUT settings, connection-test, POST customers, PUT/DELETE customers/:id, POST contacts, contacts/assign, availability, orders, orders/:id/submit, orders/:id/cancel); GET status/customers/contacts/domains/orders und POST orders/refresh nur Klasse. Kein `@Roles(` (grep leer). `domains.controller.spec.ts` und `module-manage-handlers.spec.ts` grün (Metadaten, Deklarationsreihenfolge). `page.tsx`: `useCanManageModule('domains') === true` steuert Registrieren/Einstellungen; Reihenfolge Domains, Kontakte, Kunden, Registrieren, Aufträge, Einstellungen. Live-Stack: Admin erhält 200 auf alle Benutzen-Routen. |
+| 2 | Zugang je System verschlüsselt, Demo/Live mit Bestätigung, ein GET /hello | VERIFIED | Migration/Schema: getrennte Spalten `demo*`/`live*`. `domains-settings.service.ts`: `crypto.encrypt` beim Speichern, leeres Passwort behält Wert, Antwort nur `hasPassword`, Entschlüsselungsfehler wirft `InternalServerErrorException`. Live-Stack: `GET /settings` ohne Passwortfeld; `PUT {environment:LIVE}` ohne `confirmLive` gibt 400 `confirmLiveRequired`. `testConnection` genau ein `autodnsRequest(GET /hello)`; Spec prüft literalen Basic-Header `YXBpLXVzZXI6Z2VoZWlt`, URL, Kontext-Header. Neuer Stand startet auf DEMO. Verbindungstest ohne Zugang: `{ok:false,kind:'not-configured'}` (live bestätigt). |
+| 3 | Kontakte: live aus AutoDNS, "Nicht zugeordnet", neu einlesen, anlegen, zuordnen, eigene Firma, Filter/Gruppierung | VERIFIED | `domains-directory.service.ts`: `fetchAll` seitenweise (100, max 2000, sequentiell), 60-s-`TtlCache`, Zuordnung je aktivem System, `createContact`, `assignContacts`, `isOwnCompany` löst Markierung bei anderen. Web: `ContactsTab`, `ContactForm`, `CustomersTab`, `group-by-customer.ts`. 27 Directory-Tests, ContactForm-/Gruppierungs-/Seitentests grün. Live: `GET contacts` ohne Zugang gibt 409 `notConfigured`. Echte AutoDNS-Antwort siehe Human-Punkte. |
+| 4 | Domainliste mit Kunde (über Inhaber-Kontakt), Inhaber, Ablauf, Status | VERIFIED | `listDomains` nutzt `POST /domain/_search?keys[]=expire&keys[]=ownerc`, Inhabername und Kunde über Zuordnung des aktiven Systems, `cancelationPending`. `DomainsTab.tsx` mit Filter/Gruppierung; Seitentest prüft Gruppen, "Nicht zugeordnet" zuletzt, dd.mm.yyyy. |
+| 5 | Registrieren: DomainStudio FREE, vier Kontakte, 2-6 Nameserver, Zusammenfassung, ausdrückliche Bestätigung, höchstens ein POST /domain, 409 bei Zweitbestätigung/Systemwechsel, UNKNOWN statt Raten | VERIFIED | `domains-orders.service.ts` `submitOrder` (Zeilen 456-534): Zugang vorab, Lesen, dann `updateMany where {id, tenantId, status:'DRAFT', environment}`; bei `count !== 1` kein Netzaufruf, 404/409 `environmentChanged`/`alreadySubmitted`; genau ein `callRaw('POST','/domain')` ohne Query und ohne Schleife; Ausgang: Job → SUBMITTED, auth/forbidden/rate-limit/business → FAILED (openKey null), alles andere (Zeitablauf, Netz, TLS, umschlaglos) → UNKNOWN, nie zurück auf DRAFT. `createOrder` prüft Verfügbarkeit serverseitig neu (`notAvailable` 409), Nameserver 2-6, Kontakte gegen Liste. Migration: `@@unique(tenantId, environment, openKey)`. Verhaltensbelegt: Test "zwei gleichzeitige Bestätigungen" (Spec 519-545, `Promise.allSettled`, Lese-Barriere, In-Memory-`updateMany` wertet where aus) endet mit einem Erfolg, einem `alreadySubmitted`, genau einem POST; Systemwechsel-Tests ohne Fetch. `RegisterTab.tsx`: `useRef`-Sperre, Häkchen Pflicht. Alle 66 Tests grün. |
+| 6 | Auftragsstatus folgt dem Job; unklarer Auftrag erst nach Abgleich verwerfbar | VERIFIED | `refreshOpenOrders` (max 20, älteste Prüfung zuerst, SUBMITTING älter 120 s → UNKNOWN), `refreshFromJob` (SUCCESS/FAILED/CANCELED/Rest bleibt SUBMITTED mit jobStatus), `reconcile` (GET /domain/{name}, dann POST /job/_search; ein Fehler beim Nachsehen setzt `lastCheckedAt` nicht), `cancelOrder` (UNKNOWN ohne `lastCheckedAt` → 409 `checkFirst`). `OrdersTab`: Abgleich beim Öffnen, 30-s-Intervall nur bei offenen Aufträgen. Live: `POST orders/refresh` 201. Reale Job-Antwortform: Human-Punkt. |
+| 7 | Ein Client, feste Hosts, 350 ms, 20 s, kein Retry, status.type ERROR = Fehler, AutoDNS-Login als 502 | VERIFIED | `autodns-client.ts`: `AUTODNS_BASE_URLS` Konstante, `redirect:'error'`, `AutodnsRateLimiter(350)`, `AUTODNS_TIMEOUT_MS=20_000`, 5-MiB-Deckel, kein Retry, Pfadprüfung, `status.type==='ERROR'` → business. `domains.types.ts` `autodnsFailureToHttp`: auth/forbidden → `BadGatewayException` `autodnsAuth` (nie 401/403), Zeit/Netz/TLS → 504. Alle Aufrufer (Settings, Directory, Orders) gehen durch `autodnsRequest`. 313-Zeilen-Spec mit literalen Werten grün. |
+
+**Score:** 7/7 Wahrheiten verifiziert (0 present, behavior-unverified; die Verhaltens-Wahrheit 5 ist durch einen benannten, bestehenden Test belegt)
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+|----------|----------|--------|---------|
+| `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql` | 4 Tabellen, 2 Enums, RLS | VERIFIED | Vier Tabellen mit ENABLE + FORCE + `tenant_isolation_policy`; `migrate status` "up to date", `migrate diff --exit-code` "No difference" |
+| `apps/api/src/domains/autodns-client.ts` | Client, Parser, Limiter | VERIFIED | Exporte `AUTODNS_BASE_URLS`, `autodnsRequest`, `parseAutodnsEnvelope`, `AutodnsRateLimiter` vorhanden |
+| `domains-settings.service.ts` | verschlüsselt, maskiert, Test | VERIFIED | 323 Zeilen, `crypto.encrypt(`, `decryptPassword` laut |
+| `domains-directory.service.ts` | Kunden, Listen, Zuordnung | VERIFIED | 585 Zeilen, Rest siehe Wahrheit 3/4 |
+| `domains-orders.service.ts` | Anspruch, Abgleich | VERIFIED | 782 Zeilen, Anspruch mit `status: 'DRAFT'` |
+| `.../modules/domains/page.tsx` | Badge, 6 Reiter | VERIFIED | `useCanManageModule('domains')` steuert Reiter |
+| `.../components/RegisterTab.tsx` | Verfügbarkeit bis Bestätigung | VERIFIED | `submittingRef`, Checkbox, gesperrter Knopf |
+
+### Key Link Verification
+
+| From | To | Via | Status | Details |
+|------|----|-----|--------|---------|
+| `submitOrder` | `domainsOrder.updateMany` DRAFT, dann `POST /domain` | `claim.count !== 1` vor dem Netz | WIRED | Zeilen 480-511 |
+| Settings-Dienst | `CryptoService` | `crypto.encrypt(` je System | WIRED | Zeilen 200-201 |
+| `listDomains` | Zuordnung des aktiven Systems | Inhaber-Kontakt-Id → Kunde | WIRED | `loadAssignments(tenantId, environment)` |
+| Controller | `ModuleGuard` | `@UseModule('domains')` + `@ModuleManage('domains')` | WIRED | 12 Verwalten-Handler, kein Rollen-Decorator |
+| `page.tsx` | `useCanManageModule('domains')` | Reiter/Schreibknöpfe | WIRED | Zeile 36 |
+| Modul-Registrierung | Seed, Loader, Icon, Navtitel, Layout-Test, `app.module.ts` | | WIRED | Log "Domains module seeded in registry"; Routen `orders/refresh` vor `orders/:id/submit` |
+
+### Data-Flow Trace (Level 4)
+
+| Artifact | Variable | Source | Real Data | Status |
+|----------|----------|--------|-----------|--------|
+| `DomainsTab` | Domainliste | `GET /modules/domains/domains` → `POST /domain/_search` (AutoDNS live) | Ja (gegen Mock getestet) | FLOWING |
+| `ContactsTab` | Kontakte | `POST /contact/_search` + lokale Zuordnung | Ja | FLOWING |
+| `OrdersTab` | Aufträge | `DomainsOrder` aus der Datenbank, Abgleich mit AutoDNS-Job | Ja | FLOWING |
+| `EnvironmentBadge` | System/Status | `GET status` (live: DEMO, nicht eingerichtet) | Ja | FLOWING |
+
+### Behavioral Spot-Checks
+
+| Behavior | Command | Result | Status |
+|----------|---------|--------|--------|
+| Domains-, RLS- und Rechte-Specs | `vitest run src/domains rls-coverage rls-access-inventory module-manage-handlers` | 10 Dateien, 267 Tests grün | PASS |
+| Vollständige API-Suite | `pnpm --filter @tessera/api test` | 133 Dateien, 2423 Tests grün | PASS |
+| Vollständige Web-Suite | `pnpm --filter @tessera/web test` | 132 Dateien, 1455 Tests grün | PASS |
+| Web Domains/Meldungen/Layouts | `vitest run modules/domains components/domains module-layouts src/messages` | 10 Dateien, 98 Tests grün | PASS |
+| Typprüfung | `tsc --noEmit` api und web | beide ohne Ausgabe | PASS |
+| Lint | `biome lint` auf Domains-Dateien | 42 Dateien, keine Funde | PASS |
+| Migration ohne Abweichung | `prisma migrate status` / `migrate diff --exit-code` | "up to date" / "No difference" | PASS |
+| Live-Stack (Admin) | curl status/settings/customers/orders; connection-test; PUT LIVE ohne confirmLive | 200 / maskiert / 200 `[]` / `not-configured` / 400 `confirmLiveRequired` | PASS |
+| Wortlaut | de/en `domains.*`-Schlüssel, Suche Mandant/Tenant/Lizenz in Texten, CHANGELOG, Handbücher | 207 = 207, gleiche Schlüssel, keine Treffer | PASS |
+
+### Probe Execution
+
+Step 7c: übersprungen, der Plan deklariert keine Probe-Skripte.
+
+### Requirements Coverage
+
+| Requirement | Source Plan | Description | Status | Evidence |
+|-------------|-------------|-------------|--------|----------|
+| QUICK-261008-dts (L-01 bis L-12) | 261008-dts-PLAN.md | Modul Domains | SATISFIED im Code | Wahrheiten 1-7; CHANGELOG-Eintrag unter "Unveröffentlicht / Neu"; `### Domains` in `anleitung-anwender.md` und Inhaltsverzeichnis; `### Domains: AutoDNS anbinden` in `anleitung-administration.md`; keine Transfer-/Kündigungs-/Zonenfunktion (L-07), generischer `autodnsRequest` vorhanden. Gegenprobe gegen echtes System: Human-Punkte |
+
+### Anti-Patterns Found
+
+| File | Line | Pattern | Severity | Impact |
+|------|------|---------|----------|--------|
+| (alle geänderten Dateien) | | TBD/FIXME/XXX/TODO/HACK | keiner | Suche ohne Treffer |
+| `domains.controller.ts` `POST connection-test` | | antwortet mit HTTP 201 statt 200 (Nest-Standard für POST) | Info | Plan sagt "immer 200 mit { ok, kind, message }". Der Inhalt stimmt, der Browser wertet jedes 2xx gleich. Kein Eingriff nötig |
+| `autodns-client.ts` | 150-155 | HTTP-Fehler mit lesbarer Hülle zählt als `business`, also als ausdrückliche Ablehnung (FAILED) | Info | Ein Gateway, das JSON mit `messages` liefert, könnte fälschlich als FAILED statt UNKNOWN gelten. Gateway-Seiten ohne Hülle gehen korrekt auf UNKNOWN. Geringes, hinnehmbares Restrisiko |
+
+### Human Verification Required
+
+Die Punkte stehen oben im Frontmatter (`human_verification`). Sie decken die elf Annahmen aus der SUMMARY-Checkliste ab, die nur das echte AutoDNS-Demo-System schließen kann: Demo-Kontext (A1), Anmeldung ohne 2FA, Kontaktfelder und Telefonformat (A2), DomainStudio-Antwort und Preis (A3), Job-Rückgabe und Kontaktverweis (A4/A5), Job bis SUCCESS, `registryStatus`/`cancelationStatus`, Nameserver-Prüfung für .de (A8), Auftragssuche und `GET /domain/{name}` für Unbekanntes sowie Umlautdomains. Dazu kommt eine optionale Browser-Prüfung mit einem reinen "Benutzen"-Benutzer.
+
+### Gaps Summary
+
+Keine Lücken. Der Code erreicht das Ziel: Migration ohne Abweichung, Client, drei Dienste, Controller mit durchgängigem `@ModuleManage` und ohne Rollen-Decorator, sechs Reiter mit Rechtesteuerung, Doppelbestell-Schutz durch atomaren Anspruch (durch einen benannten Nebenläufigkeits-Test belegt), Tests mit gemockter API, deutsche Texte ohne verbotene Begriffe, CHANGELOG und beide Handbücher. Der lokale Stack läuft mit dem Modul. Offen sind nur die Annahmen über die echte AutoDNS-API, die laut Auftrag als human_needed zählen.
+
+---
+
+_Verified: 2026-10-08_
+_Verifier: Claude (gsd-verifier)_