Files
tessera-ctl/.planning/phases/09-cert-manager-module/09-PATTERNS.md
T

560 lines
19 KiB
Markdown

# Phase 9: Cert Manager Module - Pattern Map
**Mapped:** 2026-07-01
**Files analyzed:** 11 new/modified files
**Analogs found:** 10 / 11
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/cert-manager/cert-manager.module.ts` | module | request-response | `apps/api/src/domaincheck/domaincheck.module.ts` | exact |
| `apps/api/src/cert-manager/cert-manager.seed.ts` | utility | — | `apps/api/src/domaincheck/domaincheck.seed.ts` | exact |
| `apps/api/src/cert-manager/cert-manager.controller.ts` | controller | file-I/O | `apps/api/src/dkv/dkv.controller.ts` | role-match |
| `apps/api/src/cert-manager/cert-manager.service.ts` | service | transform | `apps/api/src/domaincheck/domaincheck.service.ts` | role-match |
| `apps/api/src/cert-manager/dto/parse-cert.dto.ts` | dto | — | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | role-match |
| `apps/api/src/app.module.ts` (modify) | config | — | current file | exact |
| `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` | component | request-response | `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` | exact |
| `apps/web/src/app/(portal)/modules/cert-manager/actions.ts` | utility | request-response | `apps/web/src/app/(portal)/modules/domaincheck/actions.ts` | exact |
| `apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx` | component | file-I/O | `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx` | role-match |
| `apps/web/src/messages/de.json` (modify) | config | — | existing `domaincheck` namespace | exact |
| `apps/web/src/messages/en.json` (modify) | config | — | existing `domaincheck` namespace | exact |
---
## Pattern Assignments
### `apps/api/src/cert-manager/cert-manager.module.ts` (module, OnModuleInit)
**Analog:** `apps/api/src/domaincheck/domaincheck.module.ts`
**Full pattern** (lines 1-38):
```typescript
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { CertManagerController } from './cert-manager.controller';
import { seedCertManagerModule } from './cert-manager.seed';
import { CertManagerService } from './cert-manager.service';
@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<void> {
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);
}
}
}
```
---
### `apps/api/src/cert-manager/cert-manager.seed.ts` (utility, seed)
**Analog:** `apps/api/src/domaincheck/domaincheck.seed.ts`
**Full pattern** (lines 1-25):
```typescript
import { ModuleRegistryService } from '../module-registry/module-registry.service';
export async function seedCertManagerModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
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,
});
}
```
**Critical note:** `isSystem: true` registers the module in the `Module` table but does NOT auto-activate it per tenant. The `TenantModuleActivation` record must be created manually via the marketplace UI before any API endpoint responds (otherwise `ModuleGuard` returns 403).
---
### `apps/api/src/cert-manager/cert-manager.controller.ts` (controller, file-I/O)
**Analog:** `apps/api/src/dkv/dkv.controller.ts`
**Imports pattern** (lines 1-17 of dkv.controller.ts):
```typescript
import {
BadRequestException,
Body,
Controller,
Post,
Req,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { UseModule } from '../module-registry/module.guard';
import { CertManagerService } from './cert-manager.service';
```
**Module guard pattern** (from `apps/api/src/domaincheck/domaincheck.controller.ts` lines 6-8):
```typescript
@Controller('modules/cert-manager')
@UseModule('cert-manager')
export class CertManagerController {
constructor(private readonly certManagerService: CertManagerService) {}
```
**Single file upload pattern** (from `apps/api/src/dkv/dkv.controller.ts` lines 210-229):
```typescript
@Post('parse')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
}))
async parseCert(
@Req() req: any,
@UploadedFile() file: any,
@Body('password') password?: string,
@Body('pemText') pemText?: string,
) {
if (!file && !pemText) {
throw new BadRequestException('No file or PEM text provided');
}
return this.certManagerService.parseCert({ file, pemText, password });
}
```
**Multi-file upload pattern for merge** (FilesInterceptor — plural):
```typescript
@Post('merge')
@UseInterceptors(FilesInterceptor('files', 20, {
limits: { fileSize: 5 * 1024 * 1024 },
}))
async mergeCerts(
@UploadedFiles() files: any[],
@Body('outputFormat') outputFormat: string,
@Body('password') password?: string,
) {
if (!files || files.length < 2) {
throw new BadRequestException('At least 2 files required for merge');
}
return this.certManagerService.mergeCerts({ files, outputFormat, password });
}
```
**Tenant extraction helper** (from `apps/api/src/dkv/dkv.controller.ts` lines 233-240):
```typescript
private _requireTenant(req: any): string {
const tenantId = req.tenantId as string | undefined;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
return tenantId;
}
```
**Binary download pattern** (from `apps/api/src/dkv/dkv.controller.ts` lines 143-160) — NOTE: cert-manager uses JSON base64 response instead of `res.send(buffer)` because endpoints are POST-only and cannot use GET anchors for authenticated downloads:
```typescript
// cert-manager variant: return base64 JSON (not res.send)
return {
filename: 'certificate.pem',
content: Buffer.from(pemString, 'utf-8').toString('base64'),
mimeType: 'application/x-pem-file',
};
```
---
### `apps/api/src/cert-manager/cert-manager.service.ts` (service, transform)
**Analog:** `apps/api/src/domaincheck/domaincheck.service.ts`
**Service structure pattern** (lines 1-16 of domaincheck.service.ts):
```typescript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as forge from 'node-forge';
@Injectable()
export class CertManagerService {
private readonly logger = new Logger(CertManagerService.name);
async parseCert(input: { file?: any; pemText?: string; password?: string }) {
try {
// ... node-forge operations
} catch (error) {
this.logger.warn(`parseCert failed: ${error}`);
throw new BadRequestException(
'Invalid certificate. Check the file format or password.',
);
}
}
}
```
**Error handling pattern:** All node-forge operations throw synchronously on malformed input. Wrap every service method in `try/catch` → `throw new BadRequestException(...)`. Never log the `password` parameter.
**Binary encoding rule:** For DER/PFX/P7B buffers, use `buffer.toString('binary')` (never `'utf-8'`) when passing to `forge.util.createBuffer()`.
---
### `apps/api/src/cert-manager/dto/parse-cert.dto.ts` (dto)
**Analog:** `apps/api/src/domaincheck/dto/check-domain.dto.ts`
```typescript
// parse-cert.dto.ts — for JSON body (text paste path only)
export class ParseCertDto {
pemText!: string;
password?: string;
}
// merge-certs.dto.ts — body fields alongside FilesInterceptor
export class MergeCertsDto {
outputFormat!: 'pem' | 'pfx';
password?: string;
}
// convert-cert.dto.ts — body fields alongside FileInterceptor
export class ConvertCertDto {
targetFormat!: 'pem' | 'der' | 'pfx' | 'p7b';
password?: string;
}
```
---
### `apps/api/src/app.module.ts` (modify — add CertManagerModule)
**Analog:** Current file, lines 14-41
**Pattern:** Add import + add to imports array, following domaincheck:
```typescript
// Add to imports at top:
import { CertManagerModule } from './cert-manager/cert-manager.module';
// Add to @Module({ imports: [...] }) array (after DomaincheckModule):
CertManagerModule,
```
---
### `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` (component, request-response)
**Analog:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx`
**Header + layout pattern** (lines 1-64 of domaincheck/page.tsx):
```typescript
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
export default function CertManagerPage() {
const t = useTranslations('certManager');
const [activeTab, setActiveTab] = useState<'inspect' | 'split' | 'merge' | 'convert'>('inspect');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('description')}</p>
</div>
{/* Shared Input Card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
{/* DropZone + OR divider + Textarea + conditional Password field */}
</div>
{/* Tab Navigation */}
<div className="border-b border-border flex gap-6">
{(['inspect', 'split', 'merge', 'convert'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`tabs.${tab}`)}
</button>
))}
</div>
{/* Tab Content Card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
{error && <p className="text-sm text-destructive mb-4">{error}</p>}
{/* Tab-specific content */}
</div>
</div>
);
}
```
**Layout differences from domaincheck:** Use `max-w-4xl` (not `max-w-2xl`) per UI-SPEC. Tab navigation is manually rendered (no shadcn Tabs component — UI-SPEC confirms no shadcn).
---
### `apps/web/src/app/(portal)/modules/cert-manager/actions.ts` (utility, request-response)
**Analog:** `apps/web/src/app/(portal)/modules/domaincheck/actions.ts`
**Full pattern** (lines 1-33 of domaincheck/actions.ts):
```typescript
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// JSON body endpoint (PEM text paste)
export async function parseCertAction(pemText: string, password?: string) {
const response = await fetch(`${API_URL}/modules/cert-manager/parse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pemText, password }),
credentials: 'include',
});
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new Error(`${response.status} ${errorBody}`.trim());
}
return response.json();
}
// Multipart endpoint (file upload)
export async function parseCertFileAction(file: File, password?: string) {
const form = new FormData();
form.append('file', file);
if (password) form.append('password', password);
const response = await fetch(`${API_URL}/modules/cert-manager/parse`, {
method: 'POST',
// Do NOT set Content-Type — fetch sets multipart/form-data + boundary
body: form,
credentials: 'include',
});
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new Error(`${response.status} ${errorBody}`.trim());
}
return response.json();
}
// Multi-file upload (merge)
export async function mergeCertsAction(files: File[], outputFormat: string, password?: string) {
const form = new FormData();
files.forEach(file => form.append('files', file)); // same field name, multiple values
form.append('outputFormat', outputFormat);
if (password) form.append('password', password);
const response = await fetch(`${API_URL}/modules/cert-manager/merge`, {
method: 'POST',
body: form,
credentials: 'include',
});
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new Error(`${response.status} ${errorBody}`.trim());
}
return response.json(); // { filename, content (base64), mimeType }
}
```
**Base64 download helper** (no analog exists — new pattern):
```typescript
export 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);
}
```
---
### `apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx` (component, file-I/O)
**Analog:** `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx`
**Hidden file input + click-to-browse pattern** (lines 92-109 of CsvImportButton.tsx):
```typescript
'use client';
import { useRef, useState } from 'react';
export function DropZone({ onFile, accept }: { onFile: (file: File) => void; accept: string }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
return (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files[0];
if (file) { setCurrentFile(file); onFile(file); }
}}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragOver ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) { setCurrentFile(file); onFile(file); }
e.target.value = ''; // allow re-selecting same file
}}
/>
{currentFile ? (
<p className="text-sm text-foreground">{currentFile.name}</p>
) : (
<p className="text-sm text-muted-foreground">Datei hierher ziehen oder klicken</p>
)}
</div>
);
}
```
**Error display pattern** (from CsvImportButton.tsx lines 178-180):
```typescript
{importError && (
<p className="mb-3 text-sm text-destructive">{importError}</p>
)}
```
**Loading button pattern** (from CsvImportButton.tsx lines 192-208):
```typescript
<button
type="button"
onClick={handleAction}
disabled={isLoading}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? t('certManager.actions.processing') : t('certManager.actions.inspect')}
</button>
```
---
### `apps/web/src/messages/de.json` and `en.json` (modify)
**Analog:** Existing `domaincheck` namespace at line 337 in de.json
**Pattern:** Add `certManager` namespace at the same level as `domaincheck`. The full namespace content is specified in `09-RESEARCH.md` under "i18n Namespace Structure". No structural changes — append only.
---
## Shared Patterns
### Module Guard (`@UseModule`)
**Source:** `apps/api/src/module-registry/module.guard.ts` lines 75-80
**Apply to:** `cert-manager.controller.ts` — decorate the controller class (not individual handlers)
```typescript
import { UseModule } from '../module-registry/module.guard';
@Controller('modules/cert-manager')
@UseModule('cert-manager') // applies ModuleGuard to ALL routes in this controller
export class CertManagerController { ... }
```
### Error Handling (NestJS)
**Source:** `apps/api/src/dkv/dkv.controller.ts` lines 152-158 + domaincheck.service.ts lines 80-88
**Apply to:** `cert-manager.controller.ts` and `cert-manager.service.ts`
```typescript
// Controller: re-throw known exceptions, let unknown bubble
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error;
}
throw error;
// Service: wrap node-forge ops in try/catch → BadRequestException
try {
const cert = forge.pki.certificateFromPem(pemString);
} catch (_err) {
throw new BadRequestException('Invalid certificate format');
}
```
### Client State + Loading Pattern
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` lines 20-36
**Apply to:** `cert-manager/page.tsx`
```typescript
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAction = async () => {
setIsLoading(true);
setError(null);
try {
const result = await someAction();
setResult(result);
} catch (err) {
setError(err instanceof Error ? err.message : t('certManager.error.generic'));
} finally {
setIsLoading(false);
}
};
```
### i18n String Pattern
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` line 2, 20
**Apply to:** All frontend files in `cert-manager/`
```typescript
import { useTranslations } from 'next-intl';
const t = useTranslations('certManager');
// Usage: t('title'), t('tabs.inspect'), t('actions.processing')
```
### Fetch with Auth (Multipart)
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/actions.ts` lines 18-23
**Apply to:** All fetch calls in `cert-manager/actions.ts`
- JSON body: set `Content-Type: application/json`
- Multipart (FormData): do NOT set `Content-Type` — let fetch set boundary automatically
- Always include `credentials: 'include'` for cookie auth
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/api/src/cert-manager/cert-manager.service.ts` (node-forge internals) | service | transform | No crypto/binary-processing service exists in codebase — node-forge API patterns must follow RESEARCH.md Pattern 5 |
---
## Metadata
**Analog search scope:** `apps/api/src/domaincheck/`, `apps/api/src/dkv/`, `apps/api/src/module-registry/`, `apps/web/src/app/(portal)/modules/domaincheck/`, `apps/web/src/app/(portal)/modules/dkv-fleet/`
**Files scanned:** 10
**Pattern extraction date:** 2026-07-01