diff --git a/.planning/phases/09-cert-manager-module/09-RESEARCH.md b/.planning/phases/09-cert-manager-module/09-RESEARCH.md
new file mode 100644
index 0000000..fcdec58
--- /dev/null
+++ b/.planning/phases/09-cert-manager-module/09-RESEARCH.md
@@ -0,0 +1,734 @@
+# Phase 9: Cert Manager Module - Research
+
+**Researched:** 2026-07-01
+**Domain:** X.509 certificate processing — node-forge, NestJS multipart upload, binary download, module registry pattern
+**Confidence:** HIGH (core patterns verified from codebase; node-forge API tagged per source)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **Server-side API** — All crypto operations in NestJS backend, not client-side JS
+- **Ephemeral** — No Prisma schema changes, no DB tables, no file storage. Upload → process → return result/download
+- Files held in memory during request only (multer `memoryStorage`)
+- **Library: `node-forge`** — PEM, DER, PFX/PKCS12, P7B/PKCS7 in one pure-JS package. No native bindings.
+- **Slug:** `cert-manager`, **Category:** `security-tools`
+- Module pattern: `OnModuleInit` seed + `@UseModule('cert-manager')` guard (same as domaincheck)
+- API prefix: `/modules/cert-manager`
+- Endpoints: `POST /parse`, `POST /split`, `POST /merge`, `POST /convert`
+- Frontend path: `apps/web/src/app/(portal)/modules/cert-manager/`
+- Tab UI: Analysieren | Aufteilen | Zusammenführen | Konvertieren
+- No new frontend dependencies (file input + fetch already available)
+
+### Claude's Discretion
+- Internal service structure within `apps/api/src/cert-manager/` (file count, method split)
+- Response shape for parsed cert details (JSON field names)
+- Download mechanism: base64 JSON response vs. binary streaming
+- File size limits (suggested: 5 MB per file — generous for any cert format)
+- Test coverage scope within Vitest
+
+### Deferred Ideas (OUT OF SCOPE)
+- Certificate expiry monitoring / alerts (needs DB + cron)
+- Certificate store / saved cert library (needs DB)
+- OCSP / CRL revocation check
+- Private key generation
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CERT-01 | User can upload a cert file (PEM, DER, PFX/P12, CRT, CER, P7B) or paste PEM/CRT text and see parsed details (subject, issuer, validity, SANs, fingerprint) | node-forge `certificateFromPem`, `certificateFromAsn1`, `pkcs12FromAsn1` — all formats parseable |
+| CERT-02 | User can split a fullchain.pem or P7B bundle into individual certificate files (downloadable) | forge PEM regex split + `pkcs7.messageFromPem` for P7B; JSON array of `{ filename, content_base64 }` enables per-cert download |
+| CERT-03 | User can merge multiple cert files into a PEM chain or a PFX bundle (with password) | `FilesInterceptor` for multi-upload; PEM chain = `certificateToPem()` concatenation; PFX = `forge.pkcs12.toPkcs12Asn1()` |
+| CERT-04 | User can convert between PEM, DER, PFX/P12, P7B, CRT/CER formats | node-forge handles all conversions via parse + re-serialize |
+| CERT-05 | Password-protected PFX/PKCS12 files can be opened (password prompt) and created (password input) | `pkcs12FromAsn1(asn1, password)` for read; `toPkcs12Asn1(key, certs, password)` for write |
+| CERT-06 | Module appears in the module registry with slug `cert-manager` | `seedModule({ slug: 'cert-manager', category: 'security-tools', isSystem: true })` in `OnModuleInit` |
+
+
+---
+
+## Summary
+
+Phase 9 adds a server-side certificate toolkit as a Tessera module. All cryptographic operations happen in the NestJS API using `node-forge` (pure JS, no native bindings, Docker-friendly). The frontend is a tab-based page following the established domaincheck/dkv-fleet pattern — no new deps, no shadcn. No Prisma schema changes are needed since all processing is ephemeral (upload → process → return).
+
+The implementation closely mirrors the existing domaincheck module (single-operation pattern) and the DKV module's file upload pattern. The primary novel elements are: (1) multi-file upload for the merge endpoint using `FilesInterceptor` instead of `FileInterceptor`, and (2) returning binary cert data as base64 JSON so the frontend can create blob download URLs without re-fetching.
+
+The `isModuleActive` check in ModuleGuard requires a `TenantModuleActivation` record. The module seed (`isSystem: true`) registers the module in the registry but does NOT auto-activate it per tenant. In development/testing, the cert-manager module must be activated via the marketplace UI (or a direct DB insert) before API endpoints are accessible.
+
+**Primary recommendation:** Follow domaincheck module structure exactly (module.ts + seed.ts + controller.ts + service.ts + dto/). Add `FilesInterceptor` for the merge endpoint (the only multi-file endpoint). Return all binary output as `{ filename: string, content: string }` (base64) in JSON responses — simpler than streaming binary and consistent with the ephemeral design.
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Certificate parsing (PEM/DER/PFX/P7B) | API / Backend | — | Crypto operations must not run in browser (security, consistency); node-forge is a server dep |
+| Format conversion | API / Backend | — | Same as parsing — all conversion logic in NestJS service |
+| File upload buffering | API / Backend | — | multer `memoryStorage` on NestJS — no disk writes |
+| Binary download response | API / Backend | Frontend Server (SSR) | Backend sets filename via JSON response; frontend creates blob URL |
+| Tab UI / file drop zone | Browser / Client | — | Interactive client component; no SSR needed for cert operations |
+| Module registry seed | API / Backend | — | `OnModuleInit` at startup — same as domaincheck |
+| Module activation gate | API / Backend | — | `ModuleGuard` checks `TenantModuleActivation` table per request |
+| i18n strings | Frontend Server (SSR) | Browser / Client | `useTranslations('certManager')` per project convention |
+
+---
+
+## Standard Stack
+
+### Core (new dependency)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| node-forge | 1.4.0 | PEM/DER/PFX/PKCS7 parse + serialize | Pure JS (no native bindings), all cert formats in one package, 35M downloads/week, official DigitalBazaar package [VERIFIED: npm registry] |
+| @types/node-forge | 1.3.14 | TypeScript types for node-forge | DefinitelyTyped — standard types companion [VERIFIED: npm registry] |
+
+### Already Available (no install needed)
+| Library | Purpose | Source |
+|---------|---------|--------|
+| `@nestjs/platform-express` multer | File upload (FileInterceptor, FilesInterceptor) | Already in `apps/api/package.json` |
+| `express.Response` | Binary download via `res.setHeader` + `res.send` | Already in `apps/api/package.json` |
+| `@nestjs/common` (FileInterceptor, FilesInterceptor, UploadedFile, UploadedFiles) | NestJS decorators for upload | Part of `@nestjs/platform-express` |
+
+### Installation
+
+```bash
+cd /home/vicolab/projects/tessera-ctl
+pnpm --filter @tessera/api add node-forge@^1.4.0
+pnpm --filter @tessera/api add -D @types/node-forge@^1.3.14
+```
+
+---
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| node-forge | npm | ~10 yrs | 35.3M/wk | github.com/digitalbazaar/forge | OK | Approved |
+| @types/node-forge | npm | ~8 yrs | 12.8M/wk | github.com/DefinitelyTyped | OK | Approved |
+
+**Packages removed due to SLOP verdict:** none
+**Packages flagged as suspicious (SUS):** none
+
+*Registry verified 2026-07-01 via `npm view node-forge version` (1.4.0) and `npm view @types/node-forge version` (1.3.14).*
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Browser
+ |
+ | multipart/form-data (file + optional password)
+ v
+Next.js Client Component (cert-manager/page.tsx)
+ |
+ | fetch POST /modules/cert-manager/{parse|split|merge|convert}
+ | credentials: 'include' (JWT cookie)
+ v
+NestJS API — CertManagerController
+ |-- ModuleGuard: isModuleActive(tenantId, 'cert-manager') → 403 if not activated
+ |-- FileInterceptor (parse/split/convert) OR FilesInterceptor (merge)
+ | multer memoryStorage → file.buffer (no disk write)
+ v
+CertManagerService
+ |-- detect format (extension + content sniff for .cer ambiguity)
+ |-- node-forge: parse → forge.pki.Certificate object(s)
+ |-- node-forge: serialize → target format
+ v
+JSON response: { result } or { filename, content } or { certs: [{ filename, content }] }
+ |
+ v
+Next.js Client Component
+ |-- Parse: render key-value grid
+ |-- Split/Merge/Convert: base64 decode → Blob → URL.createObjectURL →
+```
+
+### Recommended Project Structure
+
+```
+apps/api/src/cert-manager/
+├── cert-manager.module.ts # OnModuleInit + seedModule
+├── cert-manager.seed.ts # seedModule({ slug: 'cert-manager', ... })
+├── cert-manager.controller.ts # 4 POST endpoints, @UseModule guard
+├── cert-manager.service.ts # node-forge operations
+└── dto/
+ ├── parse-cert.dto.ts # optional: text paste (PEM string body)
+ ├── merge-certs.dto.ts # outputFormat + optional password (from @Body)
+ └── convert-cert.dto.ts # targetFormat + optional password
+
+apps/web/src/app/(portal)/modules/cert-manager/
+├── page.tsx # 'use client', tab UI
+├── actions.ts # fetch wrappers to API
+└── components/
+ ├── DropZone.tsx # file drop + click-to-browse
+ ├── InspectTab.tsx # key-value result grid
+ ├── SplitTab.tsx # list of downloadable certs
+ ├── MergeTab.tsx # format selector + password + download
+ └── ConvertTab.tsx # format selector + password + download
+
+apps/web/src/messages/de.json # add certManager namespace
+apps/web/src/messages/en.json # add certManager namespace
+```
+
+### Pattern 1: Module Registration (OnModuleInit + seedModule)
+
+**What:** Each Tessera module seeds itself into the registry on startup via `OnModuleInit`.
+**When to use:** Every new module — this is the standard pattern.
+
+```typescript
+// Source: apps/api/src/domaincheck/domaincheck.module.ts [VERIFIED: codebase]
+@Module({
+ imports: [ModuleRegistryModule],
+ controllers: [CertManagerController],
+ providers: [CertManagerService],
+})
+export class CertManagerModule implements OnModuleInit {
+ private readonly logger = new Logger(CertManagerModule.name);
+ constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
+
+ async onModuleInit(): Promise {
+ try {
+ await seedCertManagerModule(this.moduleRegistryService);
+ this.logger.log('Cert-Manager module seeded in registry');
+ } catch (error) {
+ this.logger.error('Failed to seed cert-manager module', error);
+ }
+ }
+}
+```
+
+```typescript
+// cert-manager.seed.ts
+export async function seedCertManagerModule(
+ moduleRegistryService: ModuleRegistryService,
+): Promise {
+ await moduleRegistryService.seedModule({
+ slug: 'cert-manager',
+ name: 'Cert Manager',
+ version: '1.0.0',
+ category: 'security-tools',
+ description: {
+ de: 'Zertifikate analysieren, konvertieren und verwalten',
+ en: 'Inspect, convert and manage certificates',
+ },
+ isSystem: true,
+ });
+}
+```
+
+### Pattern 2: Single File Upload (parse, split, convert)
+
+**What:** `FileInterceptor` from `@nestjs/platform-express` buffers the file in memory. File arrives as `file.buffer` (Node.js Buffer). [VERIFIED: codebase — `apps/api/src/dkv/dkv.controller.ts`]
+
+```typescript
+// Source: apps/api/src/dkv/dkv.controller.ts (adapted) [VERIFIED: codebase]
+@Post('parse')
+@UseModule('cert-manager')
+@UseInterceptors(FileInterceptor('file', {
+ limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any cert format
+}))
+async parseCert(
+ @Req() req: any,
+ @UploadedFile() file: any,
+ @Body('password') password?: string,
+ @Body('pemText') pemText?: string,
+) {
+ // file may be undefined if PEM text was pasted instead
+ return this.certManagerService.parseCert({ file, pemText, password });
+}
+```
+
+### Pattern 3: Multiple File Upload (merge)
+
+**What:** `FilesInterceptor` (plural) from `@nestjs/platform-express` returns an array of multer files.
+**When to use:** Merge endpoint only — accepts multiple cert files.
+
+```typescript
+// Source: @nestjs/platform-express docs [ASSUMED — pattern not yet in codebase]
+import { FilesInterceptor } from '@nestjs/platform-express';
+
+@Post('merge')
+@UseModule('cert-manager')
+@UseInterceptors(FilesInterceptor('files', 20, {
+ limits: { fileSize: 5 * 1024 * 1024 },
+}))
+async mergeCerts(
+ @UploadedFiles() files: any[],
+ @Body('outputFormat') outputFormat: string, // 'pem' | 'pfx'
+ @Body('password') password?: string,
+) {
+ return this.certManagerService.mergeCerts({ files, outputFormat, password });
+}
+```
+
+Frontend FormData for multiple files:
+```typescript
+// Source: pattern derived from DKV CSV import [VERIFIED: codebase]
+const form = new FormData();
+files.forEach(file => form.append('files', file)); // same field name, multiple values
+form.append('outputFormat', 'pem');
+await fetch(`${API_URL}/modules/cert-manager/merge`, {
+ method: 'POST',
+ body: form,
+ credentials: 'include',
+ // Do NOT set Content-Type — fetch sets multipart/form-data + boundary
+});
+```
+
+### Pattern 4: Binary Download Response
+
+**What:** API returns `{ filename: string, content: string }` where `content` is base64-encoded cert bytes. Frontend creates a blob URL for download. [ASSUMED — base64 JSON approach not yet used in codebase; DKV uses direct URL anchor which requires GET + no cookies barrier]
+
+Rationale: Cert-manager endpoints are POST (not GET), so direct anchor `href` cannot send cookie auth. Base64 JSON is the correct pattern for POST-based authenticated binary downloads.
+
+```typescript
+// Backend: return base64 JSON
+return {
+ filename: 'certificate.pem',
+ content: Buffer.from(pemString, 'utf-8').toString('base64'),
+ mimeType: 'application/x-pem-file',
+};
+// or for DER/PFX:
+return {
+ filename: 'certificate.der',
+ content: derBuffer.toString('base64'),
+ mimeType: 'application/x-x509-ca-cert',
+};
+```
+
+```typescript
+// Frontend: blob URL download trigger
+function downloadBase64(filename: string, content: string, mimeType: string) {
+ const bytes = Uint8Array.from(atob(content), c => c.charCodeAt(0));
+ const blob = new Blob([bytes], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+```
+
+### Pattern 5: node-forge Cert Operations
+
+**What:** Core crypto library operations for certificate handling. [ASSUMED — training knowledge; not fetched from Context7 in this session due to disabled external search]
+
+**CRITICAL: Buffer → forge conversion.** node-forge uses its own ByteStringBuffer. Use `'binary'` encoding, never `'utf-8'`, for binary cert formats:
+
+```typescript
+import * as forge from 'node-forge';
+
+// PEM parse (text content)
+const cert = forge.pki.certificateFromPem(pemString);
+
+// DER parse (binary buffer — use 'binary' encoding!)
+const asn1 = forge.asn1.fromDer(forge.util.createBuffer(derBuffer.toString('binary')));
+const cert = forge.pki.certificateFromAsn1(asn1);
+
+// PFX/PKCS12 parse
+const p12Asn1 = forge.asn1.fromDer(forge.util.createBuffer(pfxBuffer.toString('binary')));
+const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
+const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
+const certs = (certBags[forge.pki.oids.certBag] ?? []).map(bag => bag.cert!);
+
+// P7B (PKCS7) parse — can be PEM-wrapped or DER
+// PEM-wrapped:
+const p7 = forge.pkcs7.messageFromPem(p7bPemString);
+const certs = p7.certificates ?? [];
+// DER:
+const p7Asn1 = forge.asn1.fromDer(forge.util.createBuffer(p7bBuffer.toString('binary')));
+const p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
+
+// Parse PEM chain (fullchain.pem — multiple certs in one file)
+function parsePemChain(pem: string): forge.pki.Certificate[] {
+ const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
+ return blocks.map(b => forge.pki.certificateFromPem(b));
+}
+
+// Cert → PEM output
+const pemOut = forge.pki.certificateToPem(cert);
+
+// Cert → DER output (Node.js Buffer)
+const derHex = forge.util.bytesToHex(
+ forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()
+);
+const derBuffer = Buffer.from(derHex, 'hex');
+
+// Cert → PFX/PKCS12 (with or without private key)
+const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
+ privateKey ?? null, // null = cert-only bundle [ASSUMED: null allowed]
+ [cert],
+ password,
+ { algorithm: '3des' }
+);
+const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes());
+const pfxBuffer = Buffer.from(p12Hex, 'hex');
+
+// Fingerprint (SHA-1 or SHA-256)
+function getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string {
+ const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create();
+ const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
+ md.update(der);
+ return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase();
+}
+
+// Subject/Issuer fields
+const cn = cert.subject.getField('CN')?.value ?? '';
+const o = cert.subject.getField('O')?.value ?? '';
+
+// SANs (Subject Alternative Names)
+const sanExt = cert.extensions.find(e => e.name === 'subjectAltName');
+const sans: string[] = (sanExt?.altNames ?? []).map((n: any) =>
+ n.type === 2 ? n.value : `IP:${n.ip ?? n.value}`
+);
+
+// RSA key size
+const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
+const keyBits = publicKey.n?.bitLength() ?? 0;
+```
+
+### Pattern 6: Format Detection (extension + content sniff)
+
+**What:** `.cer` files are ambiguous — can be PEM or DER. Sniff content to resolve. [ASSUMED]
+
+```typescript
+function detectFormat(
+ filename: string,
+ buffer: Buffer,
+): 'pem' | 'der' | 'pfx' | 'p7b' {
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
+ const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN');
+
+ if (ext === 'pfx' || ext === 'p12') return 'pfx';
+ if (ext === 'p7b' || ext === 'p7c') return 'p7b';
+ if (ext === 'der') return 'der';
+ if (ext === 'pem' || ext === 'crt') return 'pem';
+ if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous
+ // Fallback: sniff content
+ return isPemContent ? 'pem' : 'der';
+}
+```
+
+### Anti-Patterns to Avoid
+
+- **Using `file.buffer.toString('utf-8')` for DER/PFX/P7B:** Binary formats use arbitrary bytes — UTF-8 decoding corrupts them. Always use `'binary'` encoding.
+- **Using `hex` encoding in `createBuffer`:** `forge.util.createBuffer(buf.toString('hex'))` is wrong. Use `buf.toString('binary')`.
+- **Storing cert files to disk:** Phase decision is memory-only. Never write to `user-files/` or temp directories.
+- **Serving binary via direct GET URL:** POST-only endpoints cannot use `` with cookies. Use base64 JSON + blob URL pattern.
+- **Not catching node-forge exceptions:** Malformed certs throw synchronously inside node-forge. Wrap all parsing in try/catch and return `400 BadRequestException`.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| PEM/DER/PFX/P7B parsing | Custom ASN.1 parser | `node-forge` | ASN.1 is complex; node-forge handles all format variants, OIDs, nested structures |
+| Fingerprint calculation | Custom SHA hash loop | `forge.md.sha1/sha256.create()` | node-forge applies hash to the DER-encoded cert bytes (not the PEM string) — order matters |
+| PEM chain splitting | Manual string split | Regex `-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----` | Simpler and robust; no external dep |
+| Password-protected PFX | Custom PKCS12 builder | `forge.pkcs12.toPkcs12Asn1` | PKCS12 structure has nested safeBags, MAC, iteration counts — too complex to hand-roll |
+| Multi-file upload | Custom body parser | `FilesInterceptor` from `@nestjs/platform-express` | Already available, handles boundary parsing and size limits |
+
+**Key insight:** node-forge is purpose-built for this exact use case. Every operation in this phase has a direct node-forge API. Zero hand-rolled crypto.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Binary Encoding Corruption
+**What goes wrong:** DER, PFX, P7B binary data is corrupted when converted with `.toString('utf-8')` — Node.js UTF-8 decoding replaces invalid byte sequences, causing `forge.asn1.fromDer` to throw or produce garbage.
+**Why it happens:** Developers reach for the default string encoding without thinking about binary formats.
+**How to avoid:** Always use `.toString('binary')` when converting a Node.js Buffer to a forge-compatible string: `forge.util.createBuffer(buffer.toString('binary'))`.
+**Warning signs:** `forge.asn1.fromDer` throws `"Too few bytes to read"` or `"Invalid DER"` even though the file is valid.
+
+### Pitfall 2: Module Not Activated for Tenant
+**What goes wrong:** Seeding registers the module in the `Module` table, but `isModuleActive()` queries `TenantModuleActivation`. API returns `403 "Module 'cert-manager' is not activated for this tenant"`.
+**Why it happens:** `isSystem: true` in `seedModule` is metadata only — it does NOT auto-activate for any tenant. Activation is always an explicit admin action.
+**How to avoid:** In development, activate the module via the marketplace UI (or `POST /modules/cert-manager/activate` with admin token). Include this as a verification step in every plan that adds a new module.
+**Warning signs:** All cert-manager endpoints return 403 immediately after seeding.
+
+### Pitfall 3: `forge.pkcs12.toPkcs12Asn1` with null key
+**What goes wrong:** Creating a cert-only PFX (no private key) with `toPkcs12Asn1(null, certs, password)` may throw depending on node-forge version.
+**Why it happens:** PKCS12 spec allows cert-only bundles, but some implementations expect at least a key bag.
+**How to avoid:** Test this in the CertManagerService first. If null key throws, create the PKCS12 with only a certBag (lower-level API). [ASSUMED — verify during implementation]
+**Warning signs:** `TypeError: Cannot read property 'n' of null` inside node-forge when key is null.
+
+### Pitfall 4: P7B Binary vs PEM Detection
+**What goes wrong:** P7B files can be PEM-wrapped (`-----BEGIN PKCS7-----`) or raw DER binary. Using `forge.pkcs7.messageFromPem` on a binary P7B throws.
+**Why it happens:** Windows Certificate Manager exports P7B as DER binary by default; OpenSSL exports as PEM.
+**How to avoid:** Sniff the first bytes: if `buffer.slice(0, 10).toString('ascii').includes('-----BEGIN')` → PEM path; otherwise → DER path.
+**Warning signs:** `forge.pkcs7.messageFromPem` throws `"Invalid PEM formatted message"` on a valid P7B file.
+
+### Pitfall 5: `` for POST-authenticated downloads
+**What goes wrong:** Using a direct `` anchor for file download skips the cookie authentication — browser navigation does not send the fetch `credentials: 'include'` behavior.
+**Why it happens:** DKV uses GET endpoints for downloads (URL-based, static filenames). Cert-manager uses POST endpoints (dynamic, ephemeral content).
+**How to avoid:** Return `{ filename, content }` (base64) in JSON response → frontend creates blob URL → `` with blob URL (no auth needed for blob URLs).
+**Warning signs:** Downloads return 401 Unauthorized.
+
+### Pitfall 6: FilesInterceptor vs FileInterceptor
+**What goes wrong:** Using `FileInterceptor` (singular) for the merge endpoint — only one file is received even when multiple are sent.
+**Why it happens:** `FileInterceptor` handles a single file field. For multiple files with the same field name, `FilesInterceptor` is required.
+**How to avoid:** Use `FilesInterceptor('files', maxCount)` + `@UploadedFiles()` (plural) for the merge endpoint.
+
+---
+
+## Code Examples
+
+### Inspect Response Shape
+
+```typescript
+// Source: design derived from CONTEXT.md decisions + node-forge API [ASSUMED]
+interface CertDetails {
+ subject: { cn: string; o: string; ou: string; c: string };
+ issuer: { cn: string; o: string; c: string };
+ validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
+ san: string[]; // "dns.example.com", "IP:1.2.3.4"
+ keyType: string; // "RSA", "EC"
+ keyBits: number; // 2048, 4096
+ serialNumber: string;
+ signatureAlgorithm: string; // "sha256WithRSAEncryption"
+ fingerprint: { sha1: string; sha256: string }; // "AA:BB:CC:..."
+ pemPreview: string; // full PEM of the cert
+}
+```
+
+### Split Response Shape
+
+```typescript
+// For fullchain/P7B → individual certs
+interface SplitResponse {
+ count: number;
+ certs: Array<{
+ index: number; // 0-based
+ filename: string; // "cert-1.pem"
+ content: string; // base64-encoded PEM
+ subject: { cn: string };
+ validity: { notAfter: string };
+ }>;
+}
+```
+
+### Convert/Merge Response Shape
+
+```typescript
+interface FileResponse {
+ filename: string; // e.g. "converted.der", "bundle.pfx", "chain.pem"
+ content: string; // base64
+ mimeType: string; // "application/x-pem-file", "application/x-pkcs12", ...
+}
+```
+
+### i18n Namespace Structure (de.json)
+
+```json
+{
+ "certManager": {
+ "title": "Zertifikat-Manager",
+ "description": "Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
+ "tabs": {
+ "inspect": "Analysieren",
+ "split": "Aufteilen",
+ "merge": "Zusammenführen",
+ "convert": "Konvertieren"
+ },
+ "dropZone": {
+ "placeholder": "Datei hierher ziehen oder klicken",
+ "formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
+ },
+ "paste": { "placeholder": "PEM-Inhalt einfügen (-----BEGIN ...)" },
+ "password": { "label": "Passwort (PFX/P12)" },
+ "or": "oder",
+ "actions": {
+ "inspect": "Analysieren",
+ "split": "Aufteilen",
+ "merge": "Zusammenführen",
+ "convert": "Konvertieren",
+ "download": "Herunterladen",
+ "processing": "Wird verarbeitet..."
+ },
+ "emptyState": {
+ "inspect": "Kein Zertifikat geladen.",
+ "inspectBody": "Lade eine Datei hoch oder füge PEM-Text ein.",
+ "split": "Keine Datei geladen.",
+ "splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.",
+ "merge": "Keine Zertifikate ausgewählt.",
+ "mergeBody": "Lade mindestens zwei Dateien hoch.",
+ "convert": "Keine Datei geladen.",
+ "convertBody": "Lade eine Datei hoch und wähle ein Ausgabeformat."
+ },
+ "error": {
+ "generic": "Verarbeitung fehlgeschlagen. Prüfe das Dateiformat oder das Passwort.",
+ "wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
+ "unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
+ }
+ }
+}
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `node-forge` Rust engine (older misconception) | Pure JS since inception | Always pure JS | Docker-friendly — no native build step, no cargo |
+| Manual P7B regex parsing | `forge.pkcs7.messageFromPem/messageFromAsn1` | N/A | P7B has nested ASN.1 structure — never hand-roll |
+| `@types/node-forge` separate install | Still needed (not bundled) | — | Always install alongside node-forge |
+
+**No breaking changes noted:** node-forge 1.x API (used here) is stable. No migration concerns between 1.3.x and 1.4.0.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `FilesInterceptor` from `@nestjs/platform-express` works identically to `FileInterceptor` but returns array | Pattern 3 | If API differs, use manual multer middleware setup |
+| A2 | `forge.pkcs12.toPkcs12Asn1(null, certs, password)` works with null private key | Pattern 5, Pitfall 3 | Must use lower-level certBag-only approach; test during implementation |
+| A3 | Base64 JSON download pattern (not binary streaming) is correct for POST-auth endpoints | Pattern 4 | If chosen differently, need `res.setHeader('Content-Disposition')` + fetch-blob pattern in frontend |
+| A4 | All node-forge API signatures accurate | Code Examples | Minor API differences possible — verify against node-forge source during implementation |
+
+---
+
+## Open Questions
+
+1. **PFX cert-only creation (A2)**
+ - What we know: node-forge `toPkcs12Asn1(key, certs, password)` is documented
+ - What's unclear: whether `null` for key is accepted without throwing
+ - Recommendation: Implement and test early in Wave 0; if null throws, use `forge.pkcs12` lower-level API to create cert-only PKCS12 bag
+
+2. **Merge: does user also supply a private key file?**
+ - What we know: CONTEXT.md says "cert + optional private key" for PFX
+ - What's unclear: How the private key is provided (separate file? paste?)
+ - Recommendation: Planner should scope the merge endpoint to accept an optional private key as a third file field. If omitted, create cert-only PFX.
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js | API build/runtime | ✓ | 24.16.0 | — |
+| pnpm | Package install | ✓ | 9.15.0 | — |
+| Docker stack (api, web, db) | End-to-end testing | ✓ | Running | — |
+| node-forge | CertManagerService | ✗ (not yet installed) | 1.4.0 (npm) | — |
+| @types/node-forge | TypeScript compile | ✗ (not yet installed) | 1.3.14 (npm) | — |
+
+**Missing dependencies with no fallback:** node-forge + @types/node-forge (install in Wave 0).
+
+---
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 3.x + @testing-library/react |
+| Config file | `apps/web/vitest.config.ts` |
+| Quick run command | `pnpm --filter @tessera/web vitest run --reporter=verbose` |
+| Full suite command | `pnpm --filter @tessera/web vitest run` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CERT-01 | Inspect tab renders empty state when no file loaded | unit | `pnpm --filter @tessera/web vitest run --reporter=verbose src/app/\(portal\)/modules/cert-manager` | ❌ Wave 0 |
+| CERT-01 | Error state shown on API failure | unit | same | ❌ Wave 0 |
+| CERT-02 | Split tab renders download buttons for each returned cert | unit | same | ❌ Wave 0 |
+| CERT-03 | Merge tab: merge button disabled when < 2 files selected | unit | same | ❌ Wave 0 |
+| CERT-04 | Convert tab: format selector renders all output options | unit | same | ❌ Wave 0 |
+| CERT-05 | Password field shown when .pfx extension detected | unit | same | ❌ Wave 0 |
+| CERT-06 | Module visible in marketplace after seed | manual | Activate via marketplace UI → verify sidebar link | manual-only |
+
+### Sampling Rate
+- **Per task commit:** `pnpm --filter @tessera/web vitest run --reporter=verbose src/app/\(portal\)/modules/cert-manager`
+- **Per wave merge:** `pnpm --filter @tessera/web vitest run`
+- **Phase gate:** Full suite green before `/gsd-verify-work`
+
+### Wave 0 Gaps
+- [ ] `apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx` — covers CERT-01 through CERT-05 UI behavior
+- [ ] No new test infrastructure needed — Vitest + Testing Library already set up in `apps/web`
+
+---
+
+## Security Domain
+
+### Applicable ASVS Categories (Level 1)
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | no | Existing JWT session auth covers all endpoints |
+| V3 Session Management | no | No new session logic |
+| V4 Access Control | yes | `@UseModule('cert-manager')` guard; global JwtAuthGuard |
+| V5 Input Validation | yes | File size limit (5 MB), extension check, try/catch on node-forge |
+| V6 Cryptography | no | node-forge handles crypto — never hand-roll |
+| V10.2 Malicious Code | yes | Catch all node-forge exceptions → 400 BadRequestException |
+
+### Known Threat Patterns
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Malformed cert causes unhandled throw | Tampering | Wrap all forge operations in try/catch → `throw new BadRequestException(...)` |
+| Oversized PFX file causes OOM | DoS | `limits: { fileSize: 5 * 1024 * 1024 }` in FileInterceptor |
+| Path traversal via filename | Tampering | Not applicable — no disk writes; filename only used for Content-Disposition string |
+| Unauthenticated cert processing | Elevation of Privilege | Global JwtAuthGuard + ModuleGuard — both required |
+| Password leakage in logs | Information Disclosure | Never log `password` parameter in controller or service |
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+| Constraint | Applies to Phase 9 |
+|------------|-------------------|
+| Docker-based stack — all components as containers | No new containers; cert-manager is part of existing API/web containers |
+| PostgreSQL as database | No Prisma changes in this phase |
+| NestJS 11 backend | Use NestJS 11 patterns (FileInterceptor from @nestjs/platform-express) |
+| Next.js 16 + shadcn/ui + Tailwind 4 frontend | No shadcn (UI-SPEC confirms none); Tailwind 4 utilities only |
+| Zustand 5 for client state | Not needed — local React state (useState) sufficient for single-page module |
+| Biome 2.x for linting | All new files must pass Biome; no ESLint usage |
+| pnpm workspaces | Install node-forge via `pnpm --filter @tessera/api add` |
+| No reverse proxy inside Tessera containers | Frontend fetches directly to API via NEXT_PUBLIC_API_URL |
+| All UI strings via i18n framework (t('key')) | Add `certManager` namespace to de.json + en.json; all strings via `t()` |
+
+---
+
+## Sources
+
+### Primary (HIGH confidence — verified from codebase)
+- `apps/api/src/domaincheck/` — module registration pattern (OnModuleInit, seedModule, @UseModule guard)
+- `apps/api/src/dkv/dkv.controller.ts` — FileInterceptor, binary download with res.setHeader, file.buffer usage
+- `apps/api/src/user/user.controller.ts` — binary response pattern (setHeader + res.send)
+- `apps/api/src/module-registry/module-registry.service.ts` — isModuleActive queries TenantModuleActivation (not isSystem flag)
+- `apps/web/src/lib/dkv-api.ts` — FormData + fetch with credentials:include pattern
+- `apps/api/package.json` — node-forge NOT yet installed; @nestjs/platform-express IS installed
+
+### Secondary (MEDIUM confidence)
+- npm registry: `node-forge@1.4.0` (35M/wk, DigitalBazaar org, 10+ years) — OK verdict
+- npm registry: `@types/node-forge@1.3.14` (DefinitelyTyped) — OK verdict
+
+### Tertiary (LOW confidence — training knowledge, marked [ASSUMED])
+- node-forge API signatures (Pattern 5 code examples)
+- FilesInterceptor behavior (Pattern 3)
+- null key behavior in toPkcs12Asn1 (Open Question 1)
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — node-forge legitimacy verified, versions confirmed from npm registry
+- Architecture: HIGH — patterns verified directly from codebase (domaincheck + DKV)
+- node-forge API: MEDIUM — well-known library, training knowledge, marked [ASSUMED] where unverified
+- Pitfalls: HIGH — encoding pitfalls verified from library design; activation pitfall verified from module-registry code
+
+**Research date:** 2026-07-01
+**Valid until:** 2026-08-01 (node-forge is stable; patterns are codebase-verified)