Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
86 KiB
phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | quick_id | description | date | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261008-dts | 01 | execute | 1 | 261008-dts | Neues Modul Domains: AutoDNS-Anbindung (Demo/Live), Kontakte mit Kundenzuordnung, Domainliste, Registrierung mit Schutz vor Doppelbestellungen | 2026-10-08 |
|
true |
|
|
|
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, Demohttps://api.demo.autodns.com/v1; HTTP Basic auth plus headerX-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', categorydomain-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 ModuleIconIdearth(lucide "earth" glyph: circle cx 12 cy 12 r 10 plus the pathsM21.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: enumsAutodnsEnvironment { DEMO LIVE }andDomainOrderStatus { DRAFT SUBMITTING SUBMITTED SUCCESS FAILED UNKNOWN CANCELED }; tablesDomainsConfig(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),undiciFetchwith injectablefetchImpl, 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 asstatus.code ?? status.resultCode, failure when HTTP is not 2xx ORstatus.type === 'ERROR'OR anymessages[].status === 'ERROR'; error texts only frommessages[].text(each cut to 200 chars, at most 5) — never headers, never the password. HeaderX-Domainrobot-Demois 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 onlyhasPassword; 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: truein the request (400 codeconfirmLiveRequiredotherwise). 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 502autodnsErrorwith the joined message texts; timeout/network 504autodnsUnavailable; not configured 409notConfigured. 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 → 409customerInUse. No company name or nameserver is preset anywhere. - D-I Lists: page size 100, at most 2000 entries per list with
truncated: truebeyond, in-memory cache of 60 s keyed by tenant + environment + config version (DomainsConfig.updatedAt), invalidated by contact creation and by a successful submit;?refresh=1bypasses it. - D-J Domain list:
POST /domain/_searchwithkeys[]=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 fromregistryStatusmapped to German labels with the raw value as fallback, plus "Kündigung vorgemerkt" whencancelationStatusis set. - D-K Availability: input normalised (trim, lowercase, strip
http(s)://, path and trailing dot), converted withdomainToASCIIfromnode:url(umlaut domains become punycode), pre-filtered with an anchored hostname pattern;POST /domainstudiowithsearchToken= first label andsources.initial={ tlds: [rest], services: ['WHOIS', 'PRICE'] }, currency EUR; only the envelope whosedomainequals the requested name counts; only WHOIS statusFREEis orderable (everything else including ERROR/TIMEOUT is not); price = the 1-year entry (else the first),nullwhen missing → UI shows "Preis nicht ermittelbar" in the summary. - D-L Orders: one open order per (tenant, environment, domain) enforced by the nullable column
openKeywith@@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 → 409orderOpen. - D-M Submit protocol:
updateMany where { id, tenantId, status: DRAFT, environment: <active> }→ data{ status: SUBMITTING, confirmedAt, confirmedByUserId, confirmedByUsername }; count 0 → load the row → 404 when missing, 409environmentChangedwhen its environment differs from the active one, else 409alreadySubmitted. Count 1 → exactly onePOST /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/refreshchecks up to 20 open orders (oldestlastCheckedAtfirst) 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 noforSystemcall and nosystem_read_policy);refreshOrderis 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; elsePOST /job/_searchfiltered byobject= domain, newest job created afterconfirmedAtminus 5 min → SUBMITTED with that job; else stays UNKNOWN withlastCheckedAtset. - 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:idroute.
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Discovered facts the executor can rely on (verified during planning on 2026-10-08):
- Templates:
apps/api/src/nextcloud-status/{nextcloud-status.controller.ts, nextcloud-status.module.ts, nextcloud-status.seed.ts, nextcloud-status-fetch.ts}(controller withrequireTenantId, class@UseModule, handler@ModuleManage, seed viaseedModule, injectedfetchImpl),apps/api/src/handelsware-datev/{handelsware-datev.controller.ts, handelsware-datev.service.ts}(singleton config viaforTenant(...).<model>.findUnique({ where: { tenantId } })+upsert, errors as{ code, message }objects),apps/api/src/proxmox/proxmox-client.service.ts(undiciFetchinstead of global fetch, AbortController timeout, certificate error codes, short error details). CryptoService(apps/api/src/crypto/crypto.service.ts) is provided by the GLOBALCryptoModule— inject it, do not import a module.encrypt(plain)→iv:authTag:ciphertext;decryptthrows on bad input. LDAP precedent for keep-if-empty and masking:apps/api/src/ldap/ldap-config.service.tsarounddecryptBindPasswordand the update path.PrismaServiceis global;forTenantfromapps/api/src/prisma/prisma-tenant.extension.tswraps every model op in a one-element transaction that sets the tenant —updateManythrough 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 useinclude:or relationselect:in this module (rls inventory).- Global
ValidationPipe({ whitelist: true, transform: true })inapps/api/src/main.ts;class-validator0.15,class-transformer,undici7.28.0 are already dependencies — no new packages. - Guard:
ModuleGuardneeds the module activated for the tenant (also for admins); admins and MANAGE grants pass@ModuleManage.apps/api/src/module-registry/module-manage-handlers.spec.tshas helpersexpectManage(controller, name, slug)and the USE-level pattern (see theNextcloudStatusControllerblocks). - RLS gates:
apps/api/src/prisma/rls-coverage.spec.tsneeds ENABLE + FORCE +tenant_isolation_policyfor each new table in the migration;apps/api/src/prisma/rls-access-inventory.spec.tscompares every (file, model) Prisma access against the Fundstellentabelle indocs/mandantentrennung-zugriffsklassifikation.md(also maintain the Bereichszeile, the Summenzeile and the Paarzählung paragraph — follow thehandelsware-datevandmodule-categoriesrows, recount with the Gate-Schleifefor d in apps/api/src/*/, never copy numbers). Planned pairs:domains-settings.service.ts/domainsConfig(Task 1),domains-directory.service.ts/domainsCustomerand/domainsContactAssignment(Task 2),domains-orders.service.ts/domainsOrder(Task 3), allmuss-mandantengebunden/gebunden. NoforSystemanywhere in this module. - Migration convention: hand-written SQL with a German header comment (model
apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql; enum precedent20261002140000_module_grant_levelusesCREATE 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), thenDATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera"forpnpm --filter @tessera/api exec prisma migrate deploy|status|diff. The api container also runs migrate deploy on start. - Web registration points:
apps/web/src/lib/module-loader.ts(dynamic page import, ssr false),apps/web/src/lib/module-identity.ts(ModuleIconIdunion + 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 withModuleAccessGate) and the sidebar route/modules/<category>/domainsvia 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; itscc-sectionstyles are global inapps/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 formbg-status-warn/12 text-status-warn-fg(literal class strings only). No shared confirm-dialog component exists — build the confirmation inline likeCloudForm.tsxin nextcloud-status. - i18n: new top-level namespace
domainsinapps/web/src/messages/de.jsonanden.json(free, verified).apps/web/src/messages/umlaut-guard.spec.tsrejects ae/oe/ue/ss tokens in de.json unless listed inUMLAUT_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 thedomainskeys with a node check. - Local stack is running (api, db, web, mailhog);
admin/admin123logs in athttp://localhost:3001/auth/login(200 on 2026-10-08);GET /modules/catalogreturns{ id, slug, isActiveForTenant, ... };POST /modules/<id>/activateactivates as admin;GET /healthanswers{"status":"ok"}. Rebuild withdocker compose up -d --build api(plainupdoes 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 /hellotests login;POST /contact/_searchandPOST /domain/_searchtake{ filters, view: { limit, offset }, orders }and report the total inobject.summary;POST /contactanswersdata[0].id;POST /domainis asynchronous and answers a job (data[0].id, fallbackobject.valuewhenobject.type === 'job');GET /job/{id}status enum RUNNING, SUCCESS, FAILED, CANCELED, SUPPORT, DEFERRED, NOT_SET, WAIT (readdata[0].job.status ?? data[0].status); DomainStudio WHOIS status atdata[i].services.whois.data.status, price entries atdata[i].services.price.data.prices[](readamount/currencydirectly or underprice). Wrong login: HTTP 401 withmessages[0].codeEF00202. - 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, passwordgeheim→Authorization: Basic YXBpLXVzZXI6Z2VoZWlt. - Commits: German subject, conventional prefix
feat(domains):, body ends withCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>. Never push (the user bundles pushes). PLAN/SUMMARY/STATE are committed by the orchestrator, 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 <behavior> 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 <behavior>), 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 <behavior>.
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 <behavior> (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 <behavior>.
Tracer run. Biome-lint the touched files (pnpm exec biome lint <files> 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 <verify> 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"
<fails_when>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</fails_when>
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.
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 <behavior>; 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 <behavior> 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 <behavior> (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 <behavior>; "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 <behavior>: 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)"
<fails_when>a domains/rls/manage spec or web test fails, a tsc run fails, or the controller carries a role decorator</fails_when>
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.
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 <behavior>, 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 <behavior> — 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 <behavior> 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 <behavior> (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"
<fails_when>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</fails_when>
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.
<threat_model>
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 |
| </threat_model> |
| 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) |
<success_criteria>
- 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. </success_criteria>