---
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.