docs(09): create cert-manager phase plan (6 plans)
This commit is contained in:
+10
-2
@@ -294,7 +294,15 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
5. Password-protected PFX/PKCS12 files can be opened (password prompt) and created (password input)
|
||||
6. Module appears in the module registry with slug `cert-manager`
|
||||
|
||||
**Plans**: 0/0 plans created
|
||||
**Plans**: 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 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-03-PLAN.md — Inspect slice: parseCert (PEM/DER/PFX/P7B) + POST /parse + Inspect tab (CERT-01, CERT-05 read)
|
||||
- [ ] 09-04-PLAN.md — Split slice: splitCerts (fullchain/P7B) + POST /split + Split tab download list (CERT-02)
|
||||
- [ ] 09-05-PLAN.md — Convert slice: convertCert (PEM/DER/P7B round-trips) + POST /convert + Convert tab (CERT-04)
|
||||
- [ ] 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
|
||||
|
||||
@@ -313,4 +321,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9
|
||||
| 6. Desktop Client & CI/CD | 2/3 | In Progress| |
|
||||
| 7. DKV Fleet Module | 6/6 | Complete | 2026-06-27 |
|
||||
| 8. Dashboard Widgets Vollimplementierung | 4/4 | Complete | 2026-07-01 |
|
||||
| 9. Cert Manager Module | 0/0 | Not started | - |
|
||||
| 9. Cert Manager Module | 0/6 | Not started | - |
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
phase: 09-cert-manager-module
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/api/package.json
|
||||
- apps/api/vitest.config.ts
|
||||
- apps/api/src/cert-manager/cert-manager.module.ts
|
||||
- apps/api/src/cert-manager/cert-manager.seed.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||
- apps/api/src/cert-manager/dto/parse-cert.dto.ts
|
||||
- apps/api/src/cert-manager/dto/merge-certs.dto.ts
|
||||
- apps/api/src/cert-manager/dto/convert-cert.dto.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts
|
||||
- apps/api/src/app.module.ts
|
||||
autonomous: true
|
||||
requirements: [CERT-06]
|
||||
user_setup:
|
||||
- service: marketplace-activation
|
||||
why: "isSystem:true seeds the module in the registry but does NOT auto-activate it per tenant. ModuleGuard returns 403 until an admin activates cert-manager via the Marketplace UI."
|
||||
dashboard_config:
|
||||
- task: "Activate the cert-manager module for the tenant"
|
||||
location: "Tessera Portal -> Marketplace -> Cert Manager -> Aktivieren (after this plan runs and the API is restarted)"
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The API boots and seeds a Module registry row with slug 'cert-manager' on startup"
|
||||
- "The cert-manager Vitest suite runs via `pnpm --filter @tessera/api test`"
|
||||
- "Shared node-forge helpers (format detection, fingerprint, PEM-chain split, buffer conversion) exist and are unit-tested"
|
||||
artifacts:
|
||||
- "apps/api/vitest.config.ts (node environment)"
|
||||
- "apps/api/src/cert-manager/cert-manager.module.ts (OnModuleInit seed)"
|
||||
- "apps/api/src/cert-manager/cert-manager.seed.ts (seedCertManagerModule)"
|
||||
- "apps/api/src/cert-manager/cert-manager.service.ts (shared helpers + operation method stubs)"
|
||||
- "apps/api/src/cert-manager/cert-manager.controller.ts (4 POST endpoints, @UseModule guard)"
|
||||
- "apps/api/src/cert-manager/cert-manager.service.spec.ts (RED/GREEN helper + seed tests)"
|
||||
key_links:
|
||||
- "CertManagerModule registered in app.module.ts imports array"
|
||||
- "seedCertManagerModule -> moduleRegistryService.seedModule({ slug: 'cert-manager' })"
|
||||
- "@Controller('modules/cert-manager') + @UseModule('cert-manager') -> ModuleGuard"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Establish the API foundation for the cert-manager module: install node-forge and a Vitest runner for `@tessera/api`, scaffold the NestJS module following the domaincheck analog exactly, seed the module into the registry (CERT-06), and implement + unit-test the shared node-forge helpers every later slice depends on.
|
||||
|
||||
This is the first vertical slice's enabling half: after this plan the module registers itself at startup so it can be activated in the Marketplace and its endpoints become reachable.
|
||||
|
||||
Purpose: All later feature slices (Inspect, Split, Convert, Merge/PFX) build on this module skeleton, the shared crypto helpers, and the API test harness.
|
||||
Output: Registered cert-manager module + running API test suite + tested shared helpers.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/09-cert-manager-module/09-CONTEXT.md
|
||||
@.planning/phases/09-cert-manager-module/09-RESEARCH.md
|
||||
@.planning/phases/09-cert-manager-module/09-PATTERNS.md
|
||||
@apps/api/src/domaincheck/domaincheck.module.ts
|
||||
@apps/api/src/domaincheck/domaincheck.seed.ts
|
||||
@apps/api/src/domaincheck/domaincheck.controller.ts
|
||||
@apps/api/src/app.module.ts
|
||||
</context>
|
||||
|
||||
<artifacts>
|
||||
## Artifacts this plan produces
|
||||
|
||||
- New file: `apps/api/vitest.config.ts` — Vitest config, `test.environment: 'node'`, `test.include: ['src/**/*.spec.ts']`
|
||||
- New npm scripts in `apps/api/package.json`: `test` (`vitest run`), `test:watch` (`vitest`)
|
||||
- New deps in `apps/api`: `node-forge@^1.4.0`, `@types/node-forge@^1.3.14` (dev), `vitest@^3` (dev), `@vitest/*` as needed
|
||||
- New symbol: `CertManagerModule` (class, implements OnModuleInit)
|
||||
- New symbol: `seedCertManagerModule(moduleRegistryService)` (async function)
|
||||
- New symbol: `CertManagerService` (@Injectable) with methods: `parseCert`, `splitCerts`, `mergeCerts`, `convertCert` (operation stubs) and helpers `detectFormat`, `toForgeBuffer`, `getFingerprint`, `parsePemChain`
|
||||
- New symbol: `CertManagerController` (@Controller('modules/cert-manager'), @UseModule('cert-manager')) with routes `POST parse`, `POST split`, `POST merge`, `POST convert`
|
||||
- New DTO classes: `ParseCertDto`, `MergeCertsDto`, `ConvertCertDto`
|
||||
- New test file: `apps/api/src/cert-manager/cert-manager.service.spec.ts`
|
||||
</artifacts>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install node-forge + Vitest runner for @tessera/api</name>
|
||||
<files>apps/api/package.json, apps/api/vitest.config.ts</files>
|
||||
<read_first>
|
||||
- apps/api/package.json (current scripts + deps — no test runner exists yet)
|
||||
- apps/web/vitest.config.ts (reference Vitest config shape; API uses environment 'node' instead of 'jsdom', no react plugin)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Installation section + Package Legitimacy Audit — node-forge is Approved)
|
||||
</read_first>
|
||||
<action>
|
||||
Install runtime + dev deps in the API workspace: run `pnpm --filter @tessera/api add node-forge@^1.4.0`, then `pnpm --filter @tessera/api add -D @types/node-forge@^1.3.14 vitest@^3`. node-forge is Approved in the Package Legitimacy Audit (npm, 35.3M/wk, github.com/digitalbazaar/forge) — no legitimacy checkpoint required.
|
||||
Create apps/api/vitest.config.ts using `defineConfig` from `vitest/config` with: `test.environment` set to `'node'`, `test.globals` set to `true`, `test.include` set to `['src/**/*.spec.ts']`. Do NOT add jsdom or the react plugin (API is server-only).
|
||||
Add two scripts to apps/api/package.json: `"test": "vitest run"` and `"test:watch": "vitest"`. Do not add watch flags to the `test` script (must exit).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test --run 2>&1 | grep -Eiq 'no test files|passed|Test Files' && echo VITEST_OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `node -e "const p=require('./apps/api/package.json'); process.exit(p.dependencies['node-forge']?0:1)"` exits 0
|
||||
- `node -e "const p=require('./apps/api/package.json'); process.exit(p.devDependencies['@types/node-forge']&&p.devDependencies['vitest']?0:1)"` exits 0
|
||||
- `apps/api/package.json` `scripts.test` equals `vitest run`
|
||||
- `apps/api/vitest.config.ts` exists and contains `environment: 'node'`
|
||||
- `pnpm --filter @tessera/api test --run` exits 0 (0 tests or passing tests, never a runner error)
|
||||
</acceptance_criteria>
|
||||
<done>node-forge + @types/node-forge + vitest installed in @tessera/api; `pnpm --filter @tessera/api test` runs Vitest in a node environment and exits cleanly.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Scaffold cert-manager module + shared node-forge helpers with failing spec</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.module.ts, apps/api/src/cert-manager/cert-manager.seed.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/dto/parse-cert.dto.ts, apps/api/src/cert-manager/dto/merge-certs.dto.ts, apps/api/src/cert-manager/dto/convert-cert.dto.ts, apps/api/src/cert-manager/cert-manager.service.spec.ts, apps/api/src/app.module.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/domaincheck/domaincheck.module.ts (OnModuleInit + seed pattern to copy exactly)
|
||||
- apps/api/src/domaincheck/domaincheck.seed.ts (seedModule call shape)
|
||||
- apps/api/src/domaincheck/domaincheck.service.ts (Injectable + Logger structure)
|
||||
- apps/api/src/domaincheck/dto/check-domain.dto.ts (DTO style)
|
||||
- .planning/phases/09-cert-manager-module/09-PATTERNS.md (Pattern Assignments: full module.ts, seed.ts, service structure, DTO shapes)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 node-forge helpers + Pattern 6 format detection + Pitfall 1 binary encoding)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: seedCertManagerModule calls moduleRegistryService.seedModule once with an object whose slug is 'cert-manager', category is 'security-tools', isSystem is true (CERT-06). Use a mock ModuleRegistryService.
|
||||
- Test: detectFormat('cert.pfx', anyBuffer) returns 'pfx'; detectFormat('cert.p7b', anyBuffer) returns 'p7b'; detectFormat('cert.der', anyBuffer) returns 'der'; detectFormat('cert.pem', pemBuffer) returns 'pem'.
|
||||
- Test: detectFormat('cert.cer', buffer starting with '-----BEGIN') returns 'pem'; detectFormat('cert.cer', binaryBuffer) returns 'der' (ambiguous .cer resolved by content sniff).
|
||||
- Test: getFingerprint(cert, 'sha256') returns an uppercase colon-separated hex string (matches /^[0-9A-F]{2}(:[0-9A-F]{2})+$/) computed over DER bytes, for a self-signed cert generated in beforeAll via forge.pki.rsa.generateKeyPair + forge.pki.createCertificate.
|
||||
- Test: parsePemChain(concatenation of two cert PEMs) returns an array of length 2.
|
||||
</behavior>
|
||||
<action>
|
||||
Create the module directory apps/api/src/cert-manager/ mirroring domaincheck.
|
||||
cert-manager.module.ts: copy domaincheck.module.ts structure — @Module imports [ModuleRegistryModule], controllers [CertManagerController], providers [CertManagerService], implements OnModuleInit, constructor injects ModuleRegistryService, onModuleInit calls seedCertManagerModule and logs success/failure via a Logger named CertManagerModule.
|
||||
cert-manager.seed.ts: export async seedCertManagerModule(moduleRegistryService) calling moduleRegistryService.seedModule with 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 (per PATTERNS seed pattern — implements CERT-06).
|
||||
cert-manager.service.ts: @Injectable with a Logger named CertManagerService. Implement the shared helpers concretely: detectFormat(filename, buffer) per RESEARCH Pattern 6; toForgeBuffer(buffer) returning forge.util.createBuffer(buffer.toString('binary')) (NEVER 'utf-8' — Pitfall 1); getFingerprint(cert, algorithm) per RESEARCH Pattern 5 (hash the DER bytes, uppercase colon-joined hex); parsePemChain(pem) using the BEGIN/END CERTIFICATE regex. Add operation method stubs parseCert, splitCerts, mergeCerts, convertCert that each throw a NestJS NotImplementedException for now (filled by later slices). Never log the password parameter.
|
||||
cert-manager.controller.ts: @Controller('modules/cert-manager') decorated with @UseModule('cert-manager') from ../module-registry/module.guard; constructor injects CertManagerService. Declare the four POST routes (parse, split, merge, convert) delegating to the service; parse/split/convert use FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), merge uses FilesInterceptor('files', 20, { limits: { fileSize: 5*1024*1024 } }) per PATTERNS controller pattern. Reject missing input with BadRequestException. Route bodies may delegate to the (still-stubbed) service methods.
|
||||
dto/parse-cert.dto.ts, dto/merge-certs.dto.ts, dto/convert-cert.dto.ts: per PATTERNS DTO shapes (ParseCertDto { pemText, password? }, MergeCertsDto { outputFormat: 'pem'|'pfx', password? }, ConvertCertDto { targetFormat: 'pem'|'der'|'pfx'|'p7b', password? }).
|
||||
Register the module: add `import { CertManagerModule } from './cert-manager/cert-manager.module';` to apps/api/src/app.module.ts and add `CertManagerModule` to the @Module imports array (after DomaincheckModule).
|
||||
Create cert-manager.service.spec.ts implementing the Behavior tests above. Write the tests FIRST and confirm they fail (RED) against empty helpers, then implement the helpers until they pass (GREEN). Generate the test cert(s) in a beforeAll using node-forge (self-signed), so no key material is committed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` exits 0 with the seed, detectFormat, getFingerprint, and parsePemChain tests passing
|
||||
- `grep -q "slug: 'cert-manager'" apps/api/src/cert-manager/cert-manager.seed.ts`
|
||||
- `grep -q "@UseModule('cert-manager')" apps/api/src/cert-manager/cert-manager.controller.ts`
|
||||
- `grep -q "CertManagerModule" apps/api/src/app.module.ts`
|
||||
- `grep -q "toString('binary')" apps/api/src/cert-manager/cert-manager.service.ts` and no occurrence of `toString('utf-8')` in a forge.util.createBuffer call
|
||||
- `pnpm --filter @tessera/api type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>The cert-manager module is scaffolded per the domaincheck analog, registered in app.module.ts, seeds slug 'cert-manager' (CERT-06), and the shared node-forge helpers are implemented and green under Vitest.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client -> API upload | Untrusted certificate bytes cross into node-forge parsing |
|
||||
| npm registry -> build | Third-party crypto dependency (node-forge) enters the build |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-SC | Tampering | node-forge / @types/node-forge install | high | mitigate | node-forge Approved in Package Legitimacy Audit (npm, 35.3M/wk, DigitalBazaar); pinned `^1.4.0`; no [ASSUMED]/[SUS] packages so no legitimacy checkpoint |
|
||||
| T-09-04 | Elevation of Privilege | CertManagerController routes | high | mitigate | Global JwtAuthGuard + TenantGuard (app.module) plus `@UseModule('cert-manager')` ModuleGuard on the controller — unauthenticated/unactivated requests get 401/403 |
|
||||
| T-09-03 | Denial of Service | FileInterceptor / FilesInterceptor upload | high | mitigate | `limits: { fileSize: 5 * 1024 * 1024 }` on every upload interceptor caps memory per request |
|
||||
| T-09-02 | Information Disclosure | service/controller password param | high | mitigate | `password` is never passed to a logger; service Logger only logs failure category text |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` — seed + helper suite green
|
||||
- `pnpm --filter @tessera/api type-check` — API compiles with new module
|
||||
- Manual (deferred to phase gate): restart API, activate cert-manager in Marketplace, confirm no startup errors and the registry row exists
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- node-forge + Vitest installed in @tessera/api; `pnpm --filter @tessera/api test` runs
|
||||
- cert-manager module registered and seeding slug 'cert-manager' (CERT-06)
|
||||
- Shared helpers implemented and unit-tested; binary encoding uses 'binary' not 'utf-8'
|
||||
- API type-checks clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-cert-manager-module/09-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
phase: 09-cert-manager-module
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
||||
autonomous: true
|
||||
requirements: [CERT-06]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The /modules/cert-manager page renders the title, description and four tabs (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren)"
|
||||
- "The shared input card shows a drag-and-drop DropZone, an OR divider, a PEM textarea, and a conditionally-shown password field"
|
||||
- "Selecting a .pfx/.p12 file OR choosing PFX output reveals the password field; otherwise it is hidden"
|
||||
- "The certManager i18n namespace resolves in both de.json and en.json with no missing keys"
|
||||
artifacts:
|
||||
- "apps/web/src/app/(portal)/modules/cert-manager/page.tsx (tab shell + shared input state)"
|
||||
- "apps/web/src/app/(portal)/modules/cert-manager/actions.ts (API_URL, downloadBase64, postForm helpers)"
|
||||
- "apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx + PasswordField.tsx + 4 tab stubs"
|
||||
- "certManager namespace in de.json and en.json"
|
||||
- "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (shell tests)"
|
||||
key_links:
|
||||
- "page.tsx passes { file, pemText, password } down to the active tab component"
|
||||
- "useTranslations('certManager') resolves keys defined in messages/de.json + en.json"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the frontend shell for the cert-manager module: the tab-based page, shared input card (DropZone + PEM textarea + conditional password field), reusable download/fetch helpers, empty tab-component stubs, and the full `certManager` i18n namespace in German and English.
|
||||
|
||||
MVP framing — this delivers the visible half of the first vertical slice: after this plan a user who activates the module can open `/modules/cert-manager`, see all four tabs and the input card, and read localized copy, even though no operation is wired yet.
|
||||
|
||||
Purpose: Every feature slice (Inspect, Split, Convert, Merge) fills in one tab component and one action against this shell.
|
||||
Output: Rendering cert-manager page + localized strings + shared client helpers.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/09-cert-manager-module/09-CONTEXT.md
|
||||
@.planning/phases/09-cert-manager-module/09-UI-SPEC.md
|
||||
@.planning/phases/09-cert-manager-module/09-PATTERNS.md
|
||||
@apps/web/src/app/(portal)/modules/domaincheck/page.tsx
|
||||
@apps/web/src/app/(portal)/modules/domaincheck/actions.ts
|
||||
@apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx
|
||||
@apps/web/src/messages/de.json
|
||||
</context>
|
||||
|
||||
<artifacts>
|
||||
## Artifacts this plan produces
|
||||
|
||||
- New route page: `CertManagerPage` (default export, 'use client') at `apps/web/src/app/(portal)/modules/cert-manager/page.tsx`
|
||||
- New symbols in `actions.ts`: `API_URL` const, `downloadBase64(filename, content, mimeType)`, `postForm(endpoint, form)` (fetch wrapper, credentials:'include', throws on !ok)
|
||||
- New components: `DropZone` (props: onFile, accept), `PasswordField` (props: value, onChange, show), `InspectTab`, `SplitTab`, `MergeTab`, `ConvertTab` (each props: file, pemText, password — render empty state for now)
|
||||
- New i18n namespace `certManager` added to `apps/web/src/messages/de.json` and `en.json`
|
||||
- New test file: `apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx`
|
||||
</artifacts>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add certManager i18n namespace (de + en)</name>
|
||||
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>
|
||||
- apps/web/src/messages/de.json (locate the existing domaincheck namespace; append certManager as a sibling — do not restructure)
|
||||
- apps/web/src/messages/en.json (same)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (i18n Namespace Structure de.json — canonical key set)
|
||||
- .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Copywriting Contract — exact German strings)
|
||||
</read_first>
|
||||
<action>
|
||||
Add a certManager namespace to de.json using the exact keys and German strings from the RESEARCH i18n Namespace Structure and the UI-SPEC Copywriting Contract: title 'Zertifikat-Manager', description 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', tabs.{inspect,split,merge,convert} = Analysieren/Aufteilen/Zusammenfuehren/Konvertieren, dropZone.{placeholder,formats}, paste.placeholder, password.label 'Passwort (PFX/P12)', or 'oder', actions.{inspect,split,merge,convert,download,processing}, emptyState.{inspect,inspectBody,split,splitBody,merge,mergeBody,convert,convertBody}, error.{generic,wrongPassword,unknownFormat}. Use the exact umlaut spellings from UI-SPEC. Add the same key structure to en.json with English equivalents. Preserve existing JSON ordering/formatting; append the namespace only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node -e "const de=require('./apps/web/src/messages/de.json'); const en=require('./apps/web/src/messages/en.json'); const k=Object.keys(de.certManager.tabs).sort().join(','); if(k!=='convert,inspect,merge,split') throw new Error('de tabs '+k); if(!en.certManager.actions.download) throw new Error('en missing download'); console.log('I18N_OK');"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `de.certManager.title` equals 'Zertifikat-Manager' and `de.certManager.tabs.merge` equals 'Zusammenfuehren'
|
||||
- de.json and en.json share identical key paths under certManager (same tabs, actions, emptyState, error keys)
|
||||
- `pnpm --filter @tessera/web type-check` still passes (valid JSON)
|
||||
</acceptance_criteria>
|
||||
<done>certManager namespace exists in both de.json and en.json with the full key set; German copy matches the UI-SPEC Copywriting Contract.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Build page shell, shared input card, DropZone, PasswordField, tab stubs, and client helpers</name>
|
||||
<files>apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/app/(portal)/modules/domaincheck/page.tsx (client component + useTranslations + Card layout + loading/error state pattern)
|
||||
- apps/web/src/app/(portal)/modules/domaincheck/actions.ts (fetch wrapper + credentials:'include' pattern)
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx (hidden file input + drag-over DropZone pattern)
|
||||
- .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Layout Contract, Conditional Elements, Interaction Contract, Color/Spacing/Typography)
|
||||
- .planning/phases/09-cert-manager-module/09-PATTERNS.md (page.tsx header/layout pattern, DropZone pattern, actions.ts pattern, downloadBase64 helper)
|
||||
</read_first>
|
||||
<action>
|
||||
Create page.tsx as a 'use client' component using useTranslations('certManager'). State: activeTab ('inspect'|'split'|'merge'|'convert'), file (File|null), pemText (string), password (string). Layout per UI-SPEC: max-w-4xl mx-auto p-6 space-y-6; header (h1 text-2xl font-bold tracking-tight + p text-sm text-muted-foreground); shared input Card (rounded-lg border border-border bg-card p-6 shadow-sm space-y-4) containing DropZone, an 'oder' divider (t('or')), a PEM textarea (t('paste.placeholder')), and PasswordField shown only when the selected file extension is .pfx/.p12 OR activeTab is 'merge' with PFX output (pass a `show` prop). Tab nav (border-b border-border flex gap-6; active tab border-b-2 border-primary text-foreground, inactive text-muted-foreground). Tab content Card renders the active tab component, passing { file, pemText, password }. Selecting a file clears pemText and vice versa (single active source per Interaction Contract). Changing the active tab clears the previous tab's result but keeps the shared input.
|
||||
Create components/DropZone.tsx per PATTERNS DropZone pattern: hidden file input, click-to-browse, drag-over highlight (border-primary bg-primary/5), accept prop, calls onFile; reset e.target.value to allow re-selecting the same file. Use i18n for placeholder text.
|
||||
Create components/PasswordField.tsx: input[type=password] with a show/hide toggle rendered as an inline SVG eye icon (no external icon lib per UI-SPEC); props value, onChange, show (render null when show is false — no reflow). Label from t('password.label').
|
||||
Create components/InspectTab.tsx, SplitTab.tsx, MergeTab.tsx, ConvertTab.tsx as stubs: each accepts { file, pemText, password } and renders the corresponding empty state from t('emptyState.*'). No API calls yet — later slices fill these in.
|
||||
Create actions.ts with: API_URL const (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'); downloadBase64(filename, content, mimeType) per PATTERNS (atob -> Uint8Array -> Blob -> object URL -> anchor click -> revoke); a postForm(endpoint, form) helper that fetches `${API_URL}/modules/cert-manager/${endpoint}` with method POST, body form, credentials 'include', no manual Content-Type, and throws Error(`${status} ${body}`) on !response.ok, else returns response.json().
|
||||
All strings via t(); no hardcoded UI copy. No shadcn, no Radix, Tailwind utilities only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` starts with `'use client'` and calls `useTranslations('certManager')`
|
||||
- `grep -q "max-w-4xl" apps/web/src/app/(portal)/modules/cert-manager/page.tsx`
|
||||
- `grep -q "credentials: 'include'" apps/web/src/app/(portal)/modules/cert-manager/actions.ts`
|
||||
- `grep -q "URL.createObjectURL" apps/web/src/app/(portal)/modules/cert-manager/actions.ts`
|
||||
- PasswordField renders nothing when `show` is false and renders an input[type=password] with a toggle when true
|
||||
- `pnpm --filter @tessera/web type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>The page renders the header, four tabs, shared input card with DropZone + textarea + conditional PasswordField, and delegates to tab stubs; actions.ts exposes downloadBase64 + postForm helpers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Shell render tests</name>
|
||||
<files>apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/vitest.config.ts (jsdom env, globals, setupFiles ./src/test/setup.ts)
|
||||
- apps/web/src/test/setup.ts (existing test setup — how providers/i18n are wired for tests)
|
||||
- Any existing *.test.tsx under apps/web/src/app/(portal)/modules (reference NextIntlClientProvider wiring in component tests)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: page renders the title 'Zertifikat-Manager' and all four tab labels (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren).
|
||||
- Test: the password field is NOT in the document on initial render (no PFX file, inspect tab).
|
||||
- Test: each tab, when active, shows its empty-state text from certManager.emptyState.
|
||||
</behavior>
|
||||
<action>
|
||||
Create cert-manager.test.tsx rendering CertManagerPage wrapped in NextIntlClientProvider with the de messages (follow the existing module component-test wiring found in read_first). Implement the Behavior assertions using @testing-library/react queries (getByText / queryByLabelText). Write the tests to describe the shell contract; they should pass against the Task 2 implementation (GREEN). If the shell is missing anything they assert, fix the shell.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` exits 0 with the title, tab-label, hidden-password, and empty-state assertions passing
|
||||
- The test file imports NextIntlClientProvider and renders with de messages
|
||||
</acceptance_criteria>
|
||||
<done>Shell render tests are green: title, four tabs, hidden password field, and per-tab empty states are asserted.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser -> API | Client sends uploaded cert bytes + optional password to the API via fetch |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-02 | Information Disclosure | PasswordField / actions.ts | high | mitigate | Password is held in local React state and sent only in the FormData body over the authenticated fetch; never placed in URL query, console.log, or download filename |
|
||||
| T-09-04 | Elevation of Privilege | client fetch to /modules/cert-manager/* | high | mitigate | All requests use `credentials: 'include'`; the API enforces JwtAuthGuard + ModuleGuard, so an unauthenticated/unactivated client cannot process certs |
|
||||
| T-09-05 | Tampering | client-side accept filter | low | accept | `accept=".pem,.crt,..."` is a UX guard only; real validation happens server-side in the API (Plan 01/03+) — client filter is not a security boundary |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` — shell tests green
|
||||
- `pnpm --filter @tessera/web type-check` — web compiles
|
||||
- Manual (deferred to phase gate): open /modules/cert-manager after activation, confirm tabs, DropZone drag highlight, and password field toggling on .pfx selection
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- certManager i18n namespace complete in de + en
|
||||
- Page shell renders title, four tabs, shared input card, conditional password field
|
||||
- actions.ts exposes downloadBase64 + postForm; no shadcn/Radix used
|
||||
- Shell tests green; web type-checks clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-cert-manager-module/09-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
phase: 09-cert-manager-module
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [09-01, 09-02]
|
||||
files_modified:
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
||||
autonomous: true
|
||||
requirements: [CERT-01, CERT-05]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A user uploads (or pastes) a PEM/DER/PFX/P7B certificate and sees subject, issuer, validity, SANs, key type/size, serial, signature algorithm, and SHA-1 + SHA-256 fingerprints"
|
||||
- "A password-protected PFX is parsed when the correct password is supplied; a wrong password returns HTTP 400 (not 500)"
|
||||
- "The Inspect tab renders a key-value result grid on success and a localized error on failure"
|
||||
artifacts:
|
||||
- "CertManagerService.parseCert implemented (PEM/DER/PFX/P7B -> CertDetails)"
|
||||
- "POST /modules/cert-manager/parse wired to parseCert"
|
||||
- "InspectTab.tsx renders the CertDetails grid + inspect action"
|
||||
key_links:
|
||||
- "InspectTab -> inspectCertAction -> POST /modules/cert-manager/parse -> CertManagerService.parseCert"
|
||||
- "parseCert wraps node-forge in try/catch -> BadRequestException (wrong password / malformed)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
First functional vertical slice: certificate inspection. Implement CertManagerService.parseCert to accept an uploaded file (PEM/DER/PFX/P7B) or pasted PEM text plus an optional PFX password, and return structured CertDetails. Wire the POST /parse endpoint and build the Inspect tab to render the result grid.
|
||||
|
||||
MVP: after this plan a user can activate the module, upload a cert, and read its parsed details — a complete end-to-end capability (CERT-01). Password-protected PFX open (CERT-05 read half) is covered because parsing a .pfx requires the supplied password.
|
||||
|
||||
Purpose: Delivers CERT-01 and the read half of CERT-05; establishes the parse-and-render pattern reused by later slices.
|
||||
Output: Working Inspect tab end-to-end + tested parseCert service.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/09-cert-manager-module/09-CONTEXT.md
|
||||
@.planning/phases/09-cert-manager-module/09-RESEARCH.md
|
||||
@.planning/phases/09-cert-manager-module/09-PATTERNS.md
|
||||
@.planning/phases/09-cert-manager-module/09-UI-SPEC.md
|
||||
@.planning/phases/09-cert-manager-module/09-01-SUMMARY.md
|
||||
@.planning/phases/09-cert-manager-module/09-02-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<artifacts>
|
||||
## Artifacts this plan produces
|
||||
|
||||
- Implemented method: `CertManagerService.parseCert({ file?, pemText?, password? }): CertDetails`
|
||||
- New TS interface: `CertDetails` (subject, issuer, validity{notBefore,notAfter,isExpired,daysLeft}, san[], keyType, keyBits, serialNumber, signatureAlgorithm, fingerprint{sha1,sha256}, pemPreview) — per RESEARCH Inspect Response Shape
|
||||
- Wired route: `POST /modules/cert-manager/parse` (FileInterceptor('file') + @Body pemText/password)
|
||||
- New action: `inspectCertAction(input)` in actions.ts (JSON path for pemText, multipart path for file)
|
||||
- Implemented component: `InspectTab` (key-value result grid + inspect button + loading/error)
|
||||
</artifacts>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: RED — failing parseCert spec</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.spec.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (existing helper/seed tests + beforeAll self-signed cert generator from Plan 01)
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (current parseCert stub throwing NotImplementedException + helpers)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 node-forge parse APIs; Inspect Response Shape; Pitfall 1 binary encoding)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: parseCert({ pemText: <self-signed PEM> }) returns CertDetails with subject.cn matching the generated CN, fingerprint.sha256 matching /^[0-9A-F]{2}(:[0-9A-F]{2})+$/, keyType 'RSA', keyBits 2048, and validity.isExpired false.
|
||||
- Test: parseCert({ file: { originalname:'c.der', buffer: <DER of the cert> } }) returns the same subject.cn (DER path uses 'binary' encoding).
|
||||
- Test: parseCert({ file: { originalname:'c.pfx', buffer: <PFX built with password 'secret'> }, password: 'secret' }) returns CertDetails for the enclosed cert.
|
||||
- Test: parseCert({ file: { originalname:'c.pfx', buffer: <same PFX> }, password: 'wrong' }) throws BadRequestException (asserted via rejects.toThrow / expect(() => ...).toThrow with the Nest exception).
|
||||
- Test: parseCert({ pemText: 'not a cert' }) throws BadRequestException.
|
||||
</behavior>
|
||||
<action>
|
||||
Extend cert-manager.service.spec.ts with the Behavior tests above. Build the DER and password-protected PFX fixtures in the spec from the beforeAll self-signed cert using node-forge (forge.asn1.toDer + forge.pkcs12.toPkcs12Asn1 with password 'secret'). Run the suite and confirm these new tests FAIL against the current parseCert stub (RED). Do not implement parseCert in this task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- New parseCert tests exist in cert-manager.service.spec.ts covering PEM, DER, PFX-correct-password, PFX-wrong-password (BadRequestException), and malformed input
|
||||
- Running the suite shows the parseCert tests failing (RED) while the Plan 01 helper/seed tests still pass
|
||||
</acceptance_criteria>
|
||||
<done>Failing parseCert spec committed (RED) covering all input formats + wrong-password + malformed cases.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — implement parseCert + wire POST /parse</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (helpers detectFormat/toForgeBuffer/getFingerprint/parsePemChain from Plan 01)
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts (parse route stub + FileInterceptor from Plan 01)
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (the RED tests from Task 1 — target contract)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 full node-forge API: certificateFromPem, fromDer, pkcs12FromAsn1, subject/issuer getField, SAN extraction, RSA bitLength; Inspect Response Shape)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement CertManagerService.parseCert to: resolve input (pemText -> parse as PEM/chain; file -> detectFormat, then PEM via certificateFromPem, DER via asn1.fromDer(toForgeBuffer(...)) + certificateFromAsn1, PFX via pkcs12FromAsn1(asn1, password ?? '') then extract certBag, P7B via messageFromPem or messageFromAsn1 depending on content sniff). Build CertDetails: subject/issuer CN/O/OU/C via cert.subject.getField / cert.issuer.getField; validity.notBefore/notAfter from cert.validity, isExpired and daysLeft computed against now; san[] from the subjectAltName extension; keyType 'RSA'/'EC' and keyBits from the public key bitLength; serialNumber; signatureAlgorithm from the cert; fingerprint.sha1 and .sha256 via getFingerprint; pemPreview via certificateToPem. Wrap ALL node-forge calls in try/catch and throw BadRequestException with a generic message on failure (covers malformed cert AND wrong PFX password -> 400, threat T-09-01/T-09-02). Never log the password.
|
||||
Define and export the CertDetails interface (co-located in the service or a types file).
|
||||
In cert-manager.controller.ts, ensure POST parse uses FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), reads @Body('pemText') and @Body('password'), rejects when neither file nor pemText present (BadRequestException), and delegates to parseCert. Run the suite until all parseCert tests pass (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` exits 0 with all parseCert tests passing
|
||||
- `grep -q "BadRequestException" apps/api/src/cert-manager/cert-manager.service.ts` in the parseCert catch path
|
||||
- No `console.log`/logger call in the service references the password value (grep shows no `password` argument passed to logger)
|
||||
- `grep -q "fileSize: 5" apps/api/src/cert-manager/cert-manager.controller.ts`
|
||||
- `pnpm --filter @tessera/api type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>parseCert returns full CertDetails for PEM/DER/PFX/P7B, throws 400 on wrong password/malformed input, and POST /parse is wired; API tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Inspect tab UI + action + render test</name>
|
||||
<files>apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx (empty-state stub from Plan 02)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts (API_URL, postForm, downloadBase64 from Plan 02)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (shell tests + i18n wiring from Plan 02)
|
||||
- apps/web/src/app/(portal)/modules/domaincheck/page.tsx (loading/error state pattern)
|
||||
- .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Analysieren result = key-value grid grid-cols-2 gap-2 text-sm; loading label swap; error text-destructive)
|
||||
</read_first>
|
||||
<action>
|
||||
Add inspectCertAction to actions.ts: if pemText is present, POST JSON { pemText, password } to /modules/cert-manager/parse with Content-Type application/json; else build FormData with file + optional password and use the postForm('parse', form) helper. Return the parsed CertDetails JSON; throw on !ok (reuse postForm error behavior for the multipart path).
|
||||
Implement InspectTab: accept { file, pemText, password }. Render a primary 'Analysieren' button (t('actions.inspect'), disabled + label t('actions.processing') while loading, per UI-SPEC). On click call inspectCertAction and store the result; on error store a localized message (wrong-password -> t('error.wrongPassword'), unknown format -> t('error.unknownFormat'), else t('error.generic')). Render the empty state (t('emptyState.inspect')) when no result; render a grid grid-cols-2 gap-2 text-sm of subject/issuer/validity/SANs/keyType/keyBits/serial/signatureAlgorithm/fingerprint.sha1/fingerprint.sha256 on success; render error in text-sm text-destructive. All labels via t(). No shadcn.
|
||||
Extend cert-manager.test.tsx with a test that mocks inspectCertAction to resolve a CertDetails object and asserts the InspectTab renders the subject CN and the sha256 fingerprint after clicking Analysieren; and a test that mocks a rejection and asserts a text-destructive error is shown.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "inspectCertAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts`
|
||||
- InspectTab shows the empty state before a result and a key-value grid (grid-cols-2) after a successful inspect
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new success + error InspectTab tests
|
||||
- `pnpm --filter @tessera/web type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Inspect tab loads a cert end-to-end, renders the details grid on success and a localized destructive error on failure; web tests green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client -> API /parse | Untrusted cert bytes + optional PFX password enter node-forge parsing |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-01 | Tampering | CertManagerService.parseCert (node-forge) | medium | mitigate | Every node-forge call wrapped in try/catch; malformed cert -> BadRequestException (400), never an unhandled 500 |
|
||||
| T-09-02 | Information Disclosure | parseCert password handling | high | mitigate | Wrong PFX password caught -> generic 400 message; password value never logged and never echoed in the response |
|
||||
| T-09-03 | Denial of Service | POST /parse upload | high | mitigate | FileInterceptor `limits.fileSize` = 5 MB caps in-memory buffer |
|
||||
| T-09-04 | Elevation of Privilege | POST /parse | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` on the controller |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` — parseCert suite green (all formats + wrong password 400)
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` — InspectTab success + error tests green
|
||||
- `pnpm --filter @tessera/api type-check` and `pnpm --filter @tessera/web type-check` clean
|
||||
- Manual (phase gate): upload a real cert, verify grid; upload a password PFX with wrong then right password
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- parseCert returns full CertDetails for PEM/DER/PFX/P7B (CERT-01) and opens password PFX with correct password / 400 on wrong (CERT-05 read)
|
||||
- Inspect tab works end-to-end with localized errors
|
||||
- All API + web tests green; type-checks clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-cert-manager-module/09-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
phase: 09-cert-manager-module
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [09-03]
|
||||
files_modified:
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
||||
autonomous: true
|
||||
requirements: [CERT-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A user uploads a fullchain.pem or a P7B bundle and receives each individual certificate as a separately downloadable file"
|
||||
- "The Split tab lists one download button per returned certificate with its subject CN and expiry"
|
||||
artifacts:
|
||||
- "CertManagerService.splitCerts implemented (fullchain PEM + P7B -> array of certs)"
|
||||
- "POST /modules/cert-manager/split wired to splitCerts"
|
||||
- "SplitTab.tsx renders per-cert download list"
|
||||
key_links:
|
||||
- "SplitTab -> splitCertsAction -> POST /modules/cert-manager/split -> CertManagerService.splitCerts"
|
||||
- "each returned cert.content (base64 PEM) -> downloadBase64 on click"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Vertical slice: split a fullchain.pem or a P7B/PKCS7 bundle into its individual certificates, each downloadable. Implement CertManagerService.splitCerts, wire POST /split, and build the Split tab to list per-cert download buttons.
|
||||
|
||||
MVP: after this plan a user can upload a chain/bundle and download each cert individually — a complete capability (CERT-02).
|
||||
|
||||
Purpose: Delivers CERT-02, reusing the parse helpers and the base64-download pattern.
|
||||
Output: Working Split tab end-to-end + tested splitCerts service.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/09-cert-manager-module/09-RESEARCH.md
|
||||
@.planning/phases/09-cert-manager-module/09-PATTERNS.md
|
||||
@.planning/phases/09-cert-manager-module/09-UI-SPEC.md
|
||||
@.planning/phases/09-cert-manager-module/09-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<artifacts>
|
||||
## Artifacts this plan produces
|
||||
|
||||
- Implemented method: `CertManagerService.splitCerts({ file }): SplitResponse`
|
||||
- New TS interface: `SplitResponse` ({ count, certs: [{ index, filename, content(base64 PEM), subject{cn}, validity{notAfter} }] }) per RESEARCH Split Response Shape
|
||||
- Wired route: `POST /modules/cert-manager/split` (FileInterceptor('file'))
|
||||
- New action: `splitCertsAction(file)` in actions.ts
|
||||
- Implemented component: `SplitTab` (per-cert download list)
|
||||
</artifacts>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: RED — failing splitCerts spec</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.spec.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (self-signed cert generator + fixtures from prior plans)
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (splitCerts stub + parsePemChain helper)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 parsePemChain + pkcs7 messageFromPem/messageFromAsn1; Split Response Shape; Pitfall 4 P7B binary vs PEM)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: splitCerts({ file: { originalname:'fullchain.pem', buffer: <two concatenated cert PEMs> } }) returns count 2 and certs[0]/certs[1] each with a base64 content that decodes to a single valid PEM (contains one BEGIN CERTIFICATE block) and a subject.cn.
|
||||
- Test: splitCerts on a P7B PEM bundle (built via forge.pkcs7 from the test certs) returns the enclosed certs count.
|
||||
- Test: splitCerts({ file: { originalname:'x.pem', buffer: <garbage> } }) throws BadRequestException.
|
||||
</behavior>
|
||||
<action>
|
||||
Add the Behavior tests to cert-manager.service.spec.ts. Build the fullchain fixture by concatenating two self-signed cert PEMs; build the P7B fixture with node-forge pkcs7. Confirm the tests FAIL against the splitCerts stub (RED). Do not implement splitCerts here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- splitCerts tests exist covering fullchain PEM, P7B bundle, and malformed input
|
||||
- The suite shows splitCerts tests failing while all prior tests still pass
|
||||
</acceptance_criteria>
|
||||
<done>Failing splitCerts spec committed (RED).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — implement splitCerts + wire POST /split</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (parsePemChain, detectFormat, toForgeBuffer helpers)
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts (split route stub + FileInterceptor)
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (RED contract from Task 1)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 PEM chain split + pkcs7 parse; Pitfall 4 sniff -----BEGIN for PEM vs DER P7B)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement CertManagerService.splitCerts({ file }): detectFormat; for PEM/CRT use parsePemChain to get the cert array; for P7B sniff the first bytes — if the buffer contains '-----BEGIN' use forge.pkcs7.messageFromPem, else asn1.fromDer(toForgeBuffer(...)) + messageFromAsn1 — and read the .certificates array. Build SplitResponse: count plus certs[] where each entry has index, filename `cert-${index+1}.pem`, content = base64 of certificateToPem(cert), subject.cn and validity.notAfter. Wrap in try/catch -> BadRequestException. In the controller, POST split uses FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), rejects a missing file, and delegates. Run the suite to GREEN.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` exits 0 with splitCerts tests passing
|
||||
- Each returned cert content base64-decodes to exactly one BEGIN CERTIFICATE block
|
||||
- `grep -q "messageFromPem" apps/api/src/cert-manager/cert-manager.service.ts` (P7B path present)
|
||||
- `pnpm --filter @tessera/api type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>splitCerts returns individual base64 PEM certs for fullchain + P7B, 400 on malformed; POST /split wired; API tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Split tab UI + action + render test</name>
|
||||
<files>apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx (empty-state stub)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx (established loading/error/result pattern from Plan 03)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts (postForm, downloadBase64)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (test wiring)
|
||||
- .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Aufteilen result = list of certs, each with a bg-secondary download button; empty state 'Keine Datei geladen.')
|
||||
</read_first>
|
||||
<action>
|
||||
Add splitCertsAction(file) to actions.ts: build FormData with the file and call postForm('split', form); return the SplitResponse.
|
||||
Implement SplitTab: accept { file }. Primary 'Aufteilen' button (t('actions.split'), disabled + t('actions.processing') while loading). On success store certs[] and render a list — each row shows the cert subject.cn + validity.notAfter and a secondary download button (bg-secondary text-secondary-foreground, t('actions.download')) that calls downloadBase64(filename, content, 'application/x-pem-file'). Empty state t('emptyState.split') when no result; localized error (t('error.generic')/t('error.unknownFormat')) in text-destructive on failure.
|
||||
Extend cert-manager.test.tsx: mock splitCertsAction to resolve two certs and assert SplitTab renders two download buttons after clicking Aufteilen.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "splitCertsAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts`
|
||||
- SplitTab renders one download button per returned cert; clicking it calls downloadBase64
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new SplitTab test
|
||||
- `pnpm --filter @tessera/web type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Split tab uploads a chain/bundle and lists downloadable per-cert files end-to-end; web tests green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client -> API /split | Untrusted chain/bundle bytes enter node-forge parsing |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-01 | Tampering | splitCerts (node-forge PEM/PKCS7) | medium | mitigate | try/catch around parsePemChain + pkcs7 parse -> BadRequestException on malformed bundle |
|
||||
| T-09-03 | Denial of Service | POST /split upload | high | mitigate | FileInterceptor `limits.fileSize` = 5 MB |
|
||||
| T-09-04 | Elevation of Privilege | POST /split | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` — splitCerts green
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` — SplitTab green
|
||||
- type-checks clean
|
||||
- Manual (phase gate): upload a real fullchain.pem, download each cert, verify each opens as a valid single cert
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- splitCerts splits fullchain PEM + P7B into individual downloadable certs (CERT-02)
|
||||
- Split tab works end-to-end
|
||||
- All tests green; type-checks clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-cert-manager-module/09-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
phase: 09-cert-manager-module
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: [09-04]
|
||||
files_modified:
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
||||
autonomous: true
|
||||
requirements: [CERT-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A user uploads a certificate in any supported format and downloads it converted to a chosen target format (PEM, DER, P7B)"
|
||||
- "A PEM -> DER -> PEM round trip yields a byte-identical certificate"
|
||||
- "The Convert tab offers a target-format selector and a download button for the converted file"
|
||||
artifacts:
|
||||
- "CertManagerService.convertCert implemented (any input -> PEM/DER/P7B target)"
|
||||
- "POST /modules/cert-manager/convert wired to convertCert"
|
||||
- "ConvertTab.tsx renders format selector + download"
|
||||
key_links:
|
||||
- "ConvertTab -> convertCertAction -> POST /modules/cert-manager/convert -> CertManagerService.convertCert"
|
||||
- "convertCert returns { filename, content(base64), mimeType } -> downloadBase64"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Vertical slice: convert a certificate between formats. Implement CertManagerService.convertCert (parse any supported input, re-serialize to the chosen target), wire POST /convert, and build the Convert tab with a target-format selector and download.
|
||||
|
||||
MVP: after this plan a user can upload a cert and download it in a different format — a complete capability (CERT-04). (PFX output as a convert target is delivered together with the PFX-create logic in Plan 06; this plan covers PEM/DER/P7B targets.)
|
||||
|
||||
Purpose: Delivers CERT-04 for PEM/DER/P7B round-trips, reusing parse helpers + base64-download.
|
||||
Output: Working Convert tab end-to-end + tested convertCert service.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/09-cert-manager-module/09-RESEARCH.md
|
||||
@.planning/phases/09-cert-manager-module/09-PATTERNS.md
|
||||
@.planning/phases/09-cert-manager-module/09-UI-SPEC.md
|
||||
@.planning/phases/09-cert-manager-module/09-04-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<artifacts>
|
||||
## Artifacts this plan produces
|
||||
|
||||
- Implemented method: `CertManagerService.convertCert({ file?, pemText?, targetFormat, password? }): FileResponse`
|
||||
- New TS interface: `FileResponse` ({ filename, content(base64), mimeType }) per RESEARCH Convert/Merge Response Shape
|
||||
- Target-format -> mimeType map (pem: application/x-pem-file, der: application/x-x509-ca-cert, p7b: application/x-pkcs7-certificates)
|
||||
- Wired route: `POST /modules/cert-manager/convert` (FileInterceptor('file') + @Body targetFormat/password)
|
||||
- New action: `convertCertAction(input, targetFormat)` in actions.ts
|
||||
- Implemented component: `ConvertTab` (format selector + convert button + download)
|
||||
</artifacts>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: RED — failing convertCert spec</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.spec.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (fixtures + prior tests)
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (convertCert stub + parse helpers reused from parseCert)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 certificateToPem / certificateToAsn1 -> toDer; Convert Response Shape; Pitfall 1 binary encoding)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: convertCert({ pemText: <self-signed PEM>, targetFormat: 'der' }) returns FileResponse with mimeType application/x-x509-ca-cert and content that base64-decodes to DER bytes which, re-parsed, equal the original cert (round-trip).
|
||||
- Test: convertCert({ file: { originalname:'c.der', buffer: <DER> }, targetFormat: 'pem' }) returns a PEM whose parsed cert subject.cn equals the original (DER->PEM round trip identical).
|
||||
- Test: convertCert({ pemText: <PEM>, targetFormat: 'p7b' }) returns a P7B whose enclosed cert count is 1.
|
||||
- Test: convertCert({ pemText: 'garbage', targetFormat: 'der' }) throws BadRequestException.
|
||||
</behavior>
|
||||
<action>
|
||||
Add the Behavior tests to cert-manager.service.spec.ts, building DER fixtures from the self-signed cert. Assert round-trip identity by re-parsing the converted output and comparing the DER bytes (or subject + fingerprint). Confirm RED against the convertCert stub. Do not implement convertCert here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- convertCert tests exist for PEM->DER, DER->PEM (identity), PEM->P7B, and malformed input
|
||||
- Suite shows convertCert tests failing while all prior tests pass
|
||||
</acceptance_criteria>
|
||||
<done>Failing convertCert spec committed (RED) including a round-trip identity assertion.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — implement convertCert + wire POST /convert</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (parse helpers, detectFormat, toForgeBuffer)
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts (convert route stub)
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (RED contract)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 serialization: certificateToPem, certificateToAsn1 -> asn1.toDer -> bytesToHex -> Buffer; pkcs7 create for P7B)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement CertManagerService.convertCert: parse the input to a forge cert reusing the same input-resolution logic as parseCert (extract a shared private helper if helpful). Serialize to targetFormat: 'pem' via certificateToPem; 'der' via asn1.toDer(certificateToAsn1(cert)).getBytes() -> Buffer.from(bytesToHex, 'hex'); 'p7b' via forge.pkcs7.createSignedData / addCertificate then messageToPem (or asn1 -> DER). Build FileResponse: filename `converted.${targetFormat}`, content = base64 of the output bytes (for PEM/P7B text use Buffer.from(str,'utf-8').toString('base64'); for DER use derBuffer.toString('base64')), mimeType from the target->mime map. Reject an unsupported targetFormat and wrap all forge calls in try/catch -> BadRequestException. Never log password. In the controller, POST convert uses FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), reads @Body('targetFormat') and @Body('password'), rejects when neither file nor pemText present. Run suite to GREEN.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` exits 0 with convertCert tests passing including the round-trip identity test
|
||||
- `grep -q "converted." apps/api/src/cert-manager/cert-manager.service.ts` (filename built) and DER path uses toString('base64') on a Buffer, not 'utf-8'
|
||||
- `pnpm --filter @tessera/api type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>convertCert converts between PEM/DER/P7B with byte-identical round trips and 400 on malformed input; POST /convert wired; API tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Convert tab UI + action + render test</name>
|
||||
<files>apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx (empty-state stub)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx (result/loading/error pattern)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts (postForm, downloadBase64)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (wiring)
|
||||
- .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Konvertieren = format selector + single download button; empty state 'Keine Datei geladen.' + 'waehle ein Ausgabeformat')
|
||||
</read_first>
|
||||
<action>
|
||||
Add convertCertAction(input, targetFormat) to actions.ts: build FormData with file (or send JSON with pemText) plus targetFormat and optional password; call postForm('convert', form); return FileResponse.
|
||||
Implement ConvertTab: accept { file, pemText, password }. Render a target-format selector (native select) offering pem, der, p7b (labels localized; PFX intentionally not offered here — added in Plan 06). Primary 'Konvertieren' button (t('actions.convert'), loading label swap). On success call downloadBase64(filename, content, mimeType) from the FileResponse. Empty state t('emptyState.convert'); localized error in text-destructive on failure.
|
||||
Extend cert-manager.test.tsx: assert the format selector renders pem/der/p7b options; mock convertCertAction to resolve a FileResponse and assert downloadBase64 is invoked after clicking Konvertieren.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "convertCertAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts`
|
||||
- ConvertTab format selector renders pem, der, p7b options
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new ConvertTab tests
|
||||
- `pnpm --filter @tessera/web type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Convert tab converts and downloads end-to-end for PEM/DER/P7B; web tests green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client -> API /convert | Untrusted cert bytes enter node-forge parse + re-serialize |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-01 | Tampering | convertCert (node-forge) | medium | mitigate | try/catch around parse + serialize -> BadRequestException; unsupported targetFormat rejected as 400 |
|
||||
| T-09-06 | Tampering | binary encoding on DER output | medium | mitigate | DER built via bytesToHex -> Buffer.from(hex) -> base64; never utf-8 round-trip (Pitfall 1) |
|
||||
| T-09-03 | Denial of Service | POST /convert upload | high | mitigate | FileInterceptor `limits.fileSize` = 5 MB |
|
||||
| T-09-04 | Elevation of Privilege | POST /convert | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` — convertCert green incl. round-trip identity
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` — ConvertTab green
|
||||
- type-checks clean
|
||||
- Manual (phase gate): convert a real PEM to DER, re-upload the DER to Inspect, confirm identical cert
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- convertCert converts PEM/DER/P7B with byte-identical round trips (CERT-04)
|
||||
- Convert tab works end-to-end
|
||||
- All tests green; type-checks clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-cert-manager-module/09-05-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
phase: 09-cert-manager-module
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: [09-05]
|
||||
files_modified:
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
|
||||
autonomous: true
|
||||
requirements: [CERT-03, CERT-05]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A user uploads two or more certificates and downloads them merged as a single PEM chain"
|
||||
- "A user merges certs into a password-protected PFX/PKCS12 bundle by supplying a password; the resulting PFX opens with that password"
|
||||
- "The merge button is disabled until at least two files are selected; the password field appears when PFX output is chosen"
|
||||
artifacts:
|
||||
- "CertManagerService.mergeCerts implemented (PEM chain + PFX create with password)"
|
||||
- "POST /modules/cert-manager/merge wired to mergeCerts (FilesInterceptor)"
|
||||
- "MergeTab.tsx (multi-file + output selector + conditional password) and PFX output option added to ConvertTab"
|
||||
key_links:
|
||||
- "MergeTab -> mergeCertsAction(files, outputFormat, password) -> POST /modules/cert-manager/merge -> CertManagerService.mergeCerts"
|
||||
- "outputFormat 'pfx' -> forge.pkcs12.toPkcs12Asn1 with password -> base64 PFX -> downloadBase64"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Final vertical slice: merge multiple certificates into a PEM chain or a password-protected PFX/PKCS12 bundle. Implement CertManagerService.mergeCerts (multi-file), wire POST /merge with FilesInterceptor, build the Merge tab (multi-file upload, output-format selector, conditional password field), and add PFX as an output option to the Convert tab.
|
||||
|
||||
MVP: after this plan a user can combine certs into a chain or a password PFX and download it — completing CERT-03 and the write half of CERT-05.
|
||||
|
||||
Purpose: Delivers CERT-03 and CERT-05 (PFX create); resolves RESEARCH Open Question 1 (null-key PFX) during implementation.
|
||||
Output: Working Merge tab end-to-end + tested mergeCerts service + PFX convert option.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/09-cert-manager-module/09-RESEARCH.md
|
||||
@.planning/phases/09-cert-manager-module/09-PATTERNS.md
|
||||
@.planning/phases/09-cert-manager-module/09-UI-SPEC.md
|
||||
@.planning/phases/09-cert-manager-module/09-05-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<artifacts>
|
||||
## Artifacts this plan produces
|
||||
|
||||
- Implemented method: `CertManagerService.mergeCerts({ files, outputFormat, password? }): FileResponse`
|
||||
- PFX-create helper: cert(s) -> forge.pkcs12.toPkcs12Asn1 (cert-only, null-key path resolved per Open Question 1) -> base64
|
||||
- Wired route: `POST /modules/cert-manager/merge` (FilesInterceptor('files', 20) + @Body outputFormat/password)
|
||||
- New action: `mergeCertsAction(files, outputFormat, password?)` in actions.ts
|
||||
- Implemented component: `MergeTab` (multi-file list + output selector + conditional password + download)
|
||||
- ConvertTab gains a 'pfx' output option (reuses PFX-create + password field)
|
||||
</artifacts>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: RED — failing mergeCerts spec (PEM chain + password PFX)</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.spec.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (fixtures + prior tests)
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (mergeCerts stub + parse helpers)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 certificateToPem concat + pkcs12.toPkcs12Asn1; Pitfall 3 null-key PFX; Open Question 1)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: mergeCerts({ files: [ {buffer: certA PEM}, {buffer: certB PEM} ], outputFormat: 'pem' }) returns FileResponse whose base64 content decodes to a PEM containing exactly two BEGIN CERTIFICATE blocks, mimeType application/x-pem-file.
|
||||
- Test: mergeCerts({ files: [ {buffer: certA PEM} ], outputFormat: 'pfx', password: 'secret' }) returns a PFX whose base64 content, re-parsed via pkcs12FromAsn1 with password 'secret', yields the enclosed cert (round trip); mimeType application/x-pkcs12.
|
||||
- Test: mergeCerts({ files: [singleFile], outputFormat: 'pem' }) is allowed by the service (the 2-file minimum is enforced at the controller); OR assert controller-level guard separately — document which layer enforces the minimum.
|
||||
- Test: mergeCerts({ files: [ {buffer: garbage} ], outputFormat: 'pem' }) throws BadRequestException.
|
||||
</behavior>
|
||||
<action>
|
||||
Add the Behavior tests to cert-manager.service.spec.ts using the two self-signed cert fixtures. For the PFX test, re-open the produced bundle with pkcs12FromAsn1 + password 'secret' to prove it is password-protected and round-trips. Confirm RED against the mergeCerts stub. Do not implement mergeCerts here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- mergeCerts tests exist for PEM-chain (2 certs), password-PFX round trip, and malformed input
|
||||
- Suite shows mergeCerts tests failing while all prior tests pass
|
||||
</acceptance_criteria>
|
||||
<done>Failing mergeCerts spec committed (RED) including a password-PFX round-trip assertion.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — implement mergeCerts + PFX-create + wire POST /merge</name>
|
||||
<files>apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts (parse helpers, toForgeBuffer, convertCert serialization)
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts (merge route stub + FilesInterceptor from Plan 01)
|
||||
- apps/api/src/cert-manager/cert-manager.service.spec.ts (RED contract)
|
||||
- .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 toPkcs12Asn1; Pitfall 3 + Open Question 1 null-key handling)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement CertManagerService.mergeCerts({ files, outputFormat, password }): parse each file's buffer to a forge cert (reuse the shared input-resolution helper). For outputFormat 'pem': concatenate certificateToPem(cert) for all certs -> FileResponse { filename 'chain.pem', content base64(utf-8), mimeType application/x-pem-file }. For outputFormat 'pfx': build the PKCS12 via forge.pkcs12.toPkcs12Asn1 with the supplied password (require a non-empty password for PFX output -> BadRequestException if missing). Resolve Open Question 1: attempt cert-only creation with a null private key; if node-forge throws (Pitfall 3), fall back to the lower-level certBag-only construction. Serialize -> bytesToHex -> Buffer -> base64 -> FileResponse { filename 'bundle.pfx', mimeType application/x-pkcs12 }. Wrap all forge calls in try/catch -> BadRequestException; never log the password.
|
||||
In the controller, POST merge uses FilesInterceptor('files', 20, { limits: { fileSize: 5*1024*1024 } }), reads @Body('outputFormat') and @Body('password'), rejects when files.length < 2 (BadRequestException), and delegates. Run suite to GREEN.
|
||||
Note the Open Question 1 resolution (null-key worked vs. fallback used) in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` exits 0 with mergeCerts tests passing including the password-PFX round trip
|
||||
- `grep -q "toPkcs12Asn1" apps/api/src/cert-manager/cert-manager.service.ts`
|
||||
- Missing password on PFX output returns BadRequestException (asserted in spec)
|
||||
- `grep -q "length < 2" apps/api/src/cert-manager/cert-manager.controller.ts` (or equivalent 2-file guard)
|
||||
- `pnpm --filter @tessera/api type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>mergeCerts produces a PEM chain and a password-protected PFX that round-trips; POST /merge wired with a 2-file minimum; API tests green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Merge tab UI (multi-file + password) + PFX convert option + render tests</name>
|
||||
<files>apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx (empty-state stub)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx (format selector from Plan 05 — add 'pfx' option)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx (shared PasswordField show-condition — extend for PFX output on merge/convert)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts (postForm, downloadBase64)
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (wiring)
|
||||
- .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Zusammenfuehren = multi-file list + output selector + single download; merge button disabled < 2 files; password field appears when PFX output selected)
|
||||
</read_first>
|
||||
<action>
|
||||
Add mergeCertsAction(files, outputFormat, password?) to actions.ts: build FormData appending each file under field 'files', plus outputFormat and optional password; call postForm('merge', form); return FileResponse.
|
||||
Implement MergeTab: accept the shared password value; maintain a local list of selected files (multi-select via a file input allowing multiple, or repeated DropZone adds). Render an output-format selector (pem | pfx). The primary 'Zusammenfuehren' button (t('actions.merge'), loading label swap) is disabled until files.length >= 2 (per UI-SPEC). When output is 'pfx', ensure the shared password field is shown (update the page.tsx show-condition so PasswordField appears when the active tab's chosen output is PFX). On success call downloadBase64(filename, content, mimeType). Empty state t('emptyState.merge'); localized errors in text-destructive.
|
||||
Update page.tsx PasswordField show-condition: show when the selected file is .pfx/.p12 (existing) OR the active MergeTab/ConvertTab output format is 'pfx'. Add a 'pfx' option to ConvertTab's selector and pass the password through to convertCertAction so Convert can also emit a password PFX (reuses the same backend path — Convert with target 'pfx' may route through convertCert delegating to the PFX-create helper, or document that PFX convert uses the merge/PFX helper).
|
||||
Extend cert-manager.test.tsx: assert the Zusammenfuehren button is disabled with fewer than two files and enabled with two; assert the password field becomes visible when PFX output is selected; mock mergeCertsAction to resolve a FileResponse and assert downloadBase64 is invoked.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "mergeCertsAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts`
|
||||
- Merge button is disabled when fewer than 2 files are selected and enabled at 2 (asserted in test)
|
||||
- Password field is shown when PFX output is selected (asserted in test)
|
||||
- ConvertTab selector now includes a 'pfx' option
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new MergeTab tests
|
||||
- `pnpm --filter @tessera/web type-check` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Merge tab combines >=2 certs into a PEM chain or password PFX end-to-end; PFX output option added to Convert; web tests green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client -> API /merge | Multiple untrusted cert files + PFX password enter node-forge |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-01 | Tampering | mergeCerts (node-forge parse + pkcs12) | medium | mitigate | try/catch around parse + toPkcs12Asn1 -> BadRequestException; missing PFX password rejected as 400 |
|
||||
| T-09-02 | Information Disclosure | PFX password handling | high | mitigate | Password used only to build the PKCS12 MAC; never logged, never returned in the response, never in the filename |
|
||||
| T-09-03 | Denial of Service | POST /merge multi-upload | high | mitigate | FilesInterceptor maxCount 20 + `limits.fileSize` = 5 MB per file caps total memory |
|
||||
| T-09-04 | Elevation of Privilege | POST /merge | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/api test cert-manager --run` — mergeCerts green incl. password-PFX round trip
|
||||
- `pnpm --filter @tessera/web test cert-manager --run` — MergeTab disabled/enabled + password-visibility + download tests green
|
||||
- `pnpm --filter @tessera/api test --run && pnpm --filter @tessera/web test --run` — full phase suite green (phase gate)
|
||||
- type-checks clean
|
||||
- Manual (phase gate): merge two real certs to a PFX with a password, re-upload to Inspect with that password, confirm it opens
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- mergeCerts produces PEM chains and password-protected PFX bundles (CERT-03 + CERT-05 write)
|
||||
- Merge tab + PFX convert option work end-to-end with conditional password field
|
||||
- Full API + web suites green; type-checks clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-cert-manager-module/09-06-SUMMARY.md` when done
|
||||
</output>
|
||||
Reference in New Issue
Block a user