docs(09): create phase plan + resolve open questions
This commit is contained in:
@@ -297,11 +297,25 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
**Plans**: 6 plans
|
**Plans**: 6 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
- [ ] 09-01-PLAN.md — API foundation: install node-forge + Vitest runner, scaffold module, seed registry (CERT-06), shared node-forge helpers
|
- [ ] 09-01-PLAN.md — API foundation: install node-forge + Vitest runner, scaffold module, seed registry (CERT-06), shared node-forge helpers
|
||||||
- [ ] 09-02-PLAN.md — Frontend shell: tab page, DropZone, conditional password field, download helpers, certManager i18n (de/en)
|
- [ ] 09-02-PLAN.md — Frontend shell: tab page, DropZone, conditional password field, download helpers, certManager i18n (de/en)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
- [ ] 09-03-PLAN.md — Inspect slice: parseCert (PEM/DER/PFX/P7B) + POST /parse + Inspect tab (CERT-01, CERT-05 read)
|
- [ ] 09-03-PLAN.md — Inspect slice: parseCert (PEM/DER/PFX/P7B) + POST /parse + Inspect tab (CERT-01, CERT-05 read)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
- [ ] 09-04-PLAN.md — Split slice: splitCerts (fullchain/P7B) + POST /split + Split tab download list (CERT-02)
|
- [ ] 09-04-PLAN.md — Split slice: splitCerts (fullchain/P7B) + POST /split + Split tab download list (CERT-02)
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3 completion)*
|
||||||
|
|
||||||
- [ ] 09-05-PLAN.md — Convert slice: convertCert (PEM/DER/P7B round-trips) + POST /convert + Convert tab (CERT-04)
|
- [ ] 09-05-PLAN.md — Convert slice: convertCert (PEM/DER/P7B round-trips) + POST /convert + Convert tab (CERT-04)
|
||||||
|
|
||||||
|
**Wave 5** *(blocked on Wave 4 completion)*
|
||||||
|
|
||||||
- [ ] 09-06-PLAN.md — Merge/PFX slice: mergeCerts (PEM chain + password PFX) + POST /merge + Merge tab + PFX convert option (CERT-03, CERT-05 write)
|
- [ ] 09-06-PLAN.md — Merge/PFX slice: mergeCerts (PEM chain + password PFX) + POST /merge + Merge tab + PFX convert option (CERT-03, CERT-05 write)
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|||||||
+6
-6
@@ -5,10 +5,10 @@ milestone_name: milestone
|
|||||||
current_phase: 08
|
current_phase: 08
|
||||||
current_phase_name: dashboard-widgets-vollimplementierung
|
current_phase_name: dashboard-widgets-vollimplementierung
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: context exhaustion at 75% (2026-07-01)
|
stopped_at: context exhaustion at 76% (2026-07-01)
|
||||||
last_updated: "2026-07-01T09:34:09.492Z"
|
last_updated: "2026-07-01T14:28:23.420Z"
|
||||||
last_activity: 2026-07-01
|
last_activity: 2026-07-01
|
||||||
last_activity_desc: Completed quick task 260701-abc: Fix i18n missing keys (marketplace.accessDenied + calendar form)
|
last_activity_desc: Phase 08 execution resumed (wave continue)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 8
|
total_phases: 8
|
||||||
completed_phases: 7
|
completed_phases: 7
|
||||||
@@ -30,7 +30,7 @@ See: .planning/PROJECT.md (updated 2026-06-18)
|
|||||||
|
|
||||||
Phase: 08 (dashboard-widgets-vollimplementierung) — EXECUTING
|
Phase: 08 (dashboard-widgets-vollimplementierung) — EXECUTING
|
||||||
Plan: 1 of 4
|
Plan: 1 of 4
|
||||||
Status: Executing Phase 08
|
Status: Ready to execute
|
||||||
Last activity: 2026-07-01 — Phase 08 execution resumed (wave continue)
|
Last activity: 2026-07-01 — Phase 08 execution resumed (wave continue)
|
||||||
|
|
||||||
Progress: [█████████░] 93%
|
Progress: [█████████░] 93%
|
||||||
@@ -148,6 +148,6 @@ Items acknowledged and carried forward from previous milestone close:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-07-01T09:34:09.479Z
|
Last session: 2026-07-01T11:37:27.848Z
|
||||||
Stopped at: context exhaustion at 75% (2026-07-01)
|
Stopped at: context exhaustion at 76% (2026-07-01)
|
||||||
Resume file: .planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
|
Resume file: .planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ files_modified:
|
|||||||
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
|
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
|
||||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
||||||
autonomous: true
|
autonomous: true
|
||||||
requirements: [CERT-03, CERT-05]
|
requirements: [CERT-03, CERT-04, CERT-05]
|
||||||
|
|
||||||
must_haves:
|
must_haves:
|
||||||
truths:
|
truths:
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
# 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
|
||||||
@@ -599,17 +599,17 @@ interface FileResponse {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
1. **PFX cert-only creation (A2)**
|
1. **PFX cert-only creation (A2)**
|
||||||
- What we know: node-forge `toPkcs12Asn1(key, certs, password)` is documented
|
- What we know: node-forge `toPkcs12Asn1(key, certs, password)` is documented
|
||||||
- What's unclear: whether `null` for key is accepted without throwing
|
- 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
|
- **RESOLVED:** Plan 06 implements: attempt `toPkcs12Asn1(null, certs, password)` first; if node-forge throws on null key, fall back to constructing a cert-only PKCS12 bag via lower-level `forge.pkcs12` certBag API. Resolution happens at execution time in Wave 5 Task 1 — no pre-execution blocker.
|
||||||
|
|
||||||
2. **Merge: does user also supply a private key file?**
|
2. **Merge: does user also supply a private key file?**
|
||||||
- What we know: CONTEXT.md says "cert + optional private key" for PFX
|
- What we know: CONTEXT.md says "cert + optional private key" for PFX
|
||||||
- What's unclear: How the private key is provided (separate file? paste?)
|
- 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.
|
- **RESOLVED:** Private key support is explicitly deferred per CONTEXT.md `<deferred>` section (private key handling, key generation, PKCS#8 import). Initial implementation is cert-only PFX merge. No private key file field in Wave 5 merge endpoint. This is a conscious scope decision, not an oversight.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
phase: 9
|
||||||
|
slug: cert-manager-module
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-07-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 9 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Vitest 3.x |
|
||||||
|
| **Config file** | `apps/api/vitest.config.ts` / `apps/web/vitest.config.ts` |
|
||||||
|
| **Quick run command** | `pnpm --filter api test --run apps/api/src/modules/cert-manager` |
|
||||||
|
| **Full suite command** | `pnpm --filter api test --run && pnpm --filter web test --run` |
|
||||||
|
| **Estimated runtime** | ~30 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `pnpm --filter api test --run apps/api/src/modules/cert-manager`
|
||||||
|
- **After every plan wave:** Run `pnpm --filter api test --run && pnpm --filter web test --run`
|
||||||
|
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 30 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 09-01-01 | 01 | 0 | CERT-01 | — | node-forge installs without native build | unit | `pnpm --filter api test --run` | ❌ W0 | ⬜ pending |
|
||||||
|
| 09-01-02 | 01 | 1 | CERT-01 | — | PEM/DER/PFX parsed → subject/issuer/validity/SANs/fingerprint returned | unit | `pnpm --filter api test --run apps/api/src/modules/cert-manager` | ❌ W0 | ⬜ pending |
|
||||||
|
| 09-02-01 | 02 | 1 | CERT-02 | T-09-01 | Split returns correct number of certs; each is valid PEM | unit | `pnpm --filter api test --run apps/api/src/modules/cert-manager` | ❌ W0 | ⬜ pending |
|
||||||
|
| 09-03-01 | 03 | 2 | CERT-03 | T-09-01 | Merge produces valid PEM chain; PFX password-protected | unit | `pnpm --filter api test --run apps/api/src/modules/cert-manager` | ❌ W0 | ⬜ pending |
|
||||||
|
| 09-04-01 | 04 | 2 | CERT-04 | — | Round-trip PEM→DER→PEM produces identical cert | unit | `pnpm --filter api test --run apps/api/src/modules/cert-manager` | ❌ W0 | ⬜ pending |
|
||||||
|
| 09-05-01 | 05 | 2 | CERT-05 | T-09-02 | Wrong PFX password returns 400, not 500 | unit | `pnpm --filter api test --run apps/api/src/modules/cert-manager` | ❌ W0 | ⬜ pending |
|
||||||
|
| 09-06-01 | 06 | 3 | CERT-06 | — | Module appears in registry with slug cert-manager | integration | `pnpm --filter api test --run apps/api/src/modules/cert-manager` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `apps/api/src/modules/cert-manager/__tests__/cert-processor.service.spec.ts` — unit test stubs for CERT-01 through CERT-05
|
||||||
|
- [ ] `apps/api/src/modules/cert-manager/__tests__/cert-manager.controller.spec.ts` — controller test stubs
|
||||||
|
- [ ] `node-forge` package installed in `apps/api`
|
||||||
|
|
||||||
|
*Wave 0 installs node-forge and creates RED test stubs before implementation begins.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Module activatable via Marketplace UI | CERT-06 | Requires DB + running app + UI interaction | Navigate to Marketplace, activate cert-manager, verify /modules/cert-manager route loads |
|
||||||
|
| File download (binary formats DER/PFX) | CERT-04 | Browser Blob-URL behavior requires visual check | Upload PEM cert, convert to DER, verify download triggers correct binary file |
|
||||||
|
| Password prompt UX for PFX open | CERT-05 | Interactive UI flow | Upload password-protected PFX, verify modal appears, enter correct password, verify parse success |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 30s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
Reference in New Issue
Block a user