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

19 KiB

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):

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):

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):

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):

@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):

@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):

@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):

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:

// 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):

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

// 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:

// 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):

'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):

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):

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):

'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):

{importError && (
  <p className="mb-3 text-sm text-destructive">{importError}</p>
)}

Loading button pattern (from CsvImportButton.tsx lines 192-208):

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

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

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

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/

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