docs(03): create phase plan for Module System & Domaincheck

4 plans across 3 waves: SDK + registry (W1), Domaincheck module +
lazy loading (W2), visual verification (W3). Covers MOD-01..04,
DCHK-01..03.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 12:13:20 +02:00
parent 242553a532
commit 89f559ce62
5 changed files with 876 additions and 2 deletions
@@ -0,0 +1,247 @@
---
phase: 03-module-system-domaincheck
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- packages/module-sdk/package.json
- packages/module-sdk/tsconfig.json
- packages/module-sdk/src/index.ts
- packages/module-sdk/src/types.ts
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/*
- apps/api/src/module-registry/module-registry.module.ts
- apps/api/src/module-registry/module-registry.service.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/module-registry/dto/activate-module.dto.ts
- apps/api/src/app.module.ts
- pnpm-workspace.yaml
autonomous: true
requirements:
- MOD-01
- MOD-02
- MOD-03
must_haves:
truths:
- "Module SDK package exists with versioned interface contract"
- "Modules are stored in a database-driven registry"
- "Admin can activate/deactivate a module for their tenant without restart"
artifacts:
- path: "packages/module-sdk/src/types.ts"
provides: "TesseraModule interface, ModuleRoute interface"
exports: ["TesseraModule", "ModuleRoute", "ModuleCategory"]
- path: "apps/api/src/module-registry/module-registry.service.ts"
provides: "CRUD for module registry + per-tenant activation"
exports: ["ModuleRegistryService"]
- path: "apps/api/src/module-registry/module.guard.ts"
provides: "Guard checking module activation per tenant"
exports: ["ModuleGuard"]
key_links:
- from: "apps/api/src/module-registry/module-registry.service.ts"
to: "prisma.module"
via: "Prisma client queries"
pattern: "prisma\\.module\\.(find|create|update)"
- from: "apps/api/src/module-registry/module.guard.ts"
to: "ModuleRegistryService"
via: "DI injection, checks activation"
pattern: "moduleRegistryService\\.isModuleActive"
---
<objective>
Deliver the Module SDK contract and a working module registry with per-tenant activation — the full vertical slice from database schema through API endpoints that an admin can call to register and activate modules.
Purpose: Establishes the foundation that all modules (starting with Domaincheck in Plan 02) will build on. Per D-06, D-07, D-08.
Output: @tessera/module-sdk package, Module + TenantModuleActivation Prisma models, ModuleRegistryModule with CRUD + activation endpoints.
</objective>
<execution_context>
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-module-system-domaincheck/03-CONTEXT.md
@.planning/phases/03-module-system-domaincheck/03-RESEARCH.md
@apps/api/prisma/schema.prisma
@apps/api/src/app.module.ts
@packages/shared/src/index.ts
@pnpm-workspace.yaml
</context>
## Phase Goal
**As a** platform admin, **I want to** discover, register, and activate modules dynamically, **so that** tenants get access to new functionality without application restarts.
<tasks>
<task type="auto">
<name>Task 1: Module SDK Package + Prisma Schema + Registry Service</name>
<files>
packages/module-sdk/package.json,
packages/module-sdk/tsconfig.json,
packages/module-sdk/src/index.ts,
packages/module-sdk/src/types.ts,
apps/api/prisma/schema.prisma,
pnpm-workspace.yaml
</files>
<read_first>
apps/api/prisma/schema.prisma,
packages/shared/src/index.ts,
pnpm-workspace.yaml
</read_first>
<action>
1. Create packages/module-sdk/ with package.json (name: @tessera/module-sdk, version: 0.1.0, main: src/index.ts). Add to pnpm-workspace.yaml packages list if not already globbed.
2. In packages/module-sdk/src/types.ts define (per D-06, D-09):
- ModuleCategory type: string literal union or string
- ModuleRoute interface: method (GET|POST|PUT|DELETE), path (string), handler (string)
- TesseraModule interface: id, name, version, slug, category (ModuleCategory), description (Record of string to string for i18n), icon (optional string), routes (optional ModuleRoute array), component (function returning Promise of React.ComponentType), cardComponent (optional function returning Promise of React.ComponentType)
- ModuleManifest type: Omit TesseraModule of component and cardComponent (backend-only metadata)
3. In packages/module-sdk/src/index.ts re-export everything from types.ts.
4. Extend apps/api/prisma/schema.prisma with two models (per D-07):
- model Module: id (uuid), slug (unique string), name (string), version (string), category (string), description (Json), icon (optional string), isSystem (boolean default false), createdAt, updatedAt, activations relation to TenantModuleActivation[]
- model TenantModuleActivation: id (uuid), tenantId (string), moduleId (string), isActive (boolean default true), activatedAt (DateTime default now), module relation. Unique constraint on [tenantId, moduleId], index on [tenantId].
5. Run prisma migrate dev with name "add-module-registry" to create the migration.
6. Run pnpm install from monorepo root to link the new package.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm exec prisma migrate status --schema=apps/api/prisma/schema.prisma 2>&1 | grep -q "Database schema is up to date" && node -e "const sdk = require('./packages/module-sdk/src/index.ts'); console.log('SDK exports:', Object.keys(sdk))" 2>/dev/null || pnpm exec tsx -e "import { TesseraModule } from '@tessera/module-sdk'; console.log('SDK type-checks')"</automated>
</verify>
<acceptance_criteria>
- packages/module-sdk/src/types.ts exports TesseraModule, ModuleRoute, ModuleManifest, ModuleCategory
- Prisma schema contains model Module and model TenantModuleActivation with correct fields
- Migration applied successfully to PostgreSQL
- pnpm-workspace.yaml includes packages/module-sdk path
</acceptance_criteria>
<done>SDK package exists with typed interfaces, Prisma models for module registry are migrated and queryable</done>
</task>
<task type="auto">
<name>Task 2: ModuleRegistry NestJS Module with CRUD + Activation Endpoints</name>
<files>
apps/api/src/module-registry/module-registry.module.ts,
apps/api/src/module-registry/module-registry.service.ts,
apps/api/src/module-registry/module-registry.controller.ts,
apps/api/src/module-registry/module.guard.ts,
apps/api/src/module-registry/dto/activate-module.dto.ts,
apps/api/src/app.module.ts
</files>
<read_first>
apps/api/src/app.module.ts,
apps/api/src/tenant/tenant.middleware.ts,
apps/api/src/auth/guards/jwt-auth.guard.ts,
apps/api/src/auth/guards/roles.guard.ts,
apps/api/src/prisma/prisma.service.ts
</read_first>
<action>
1. Create apps/api/src/module-registry/dto/activate-module.dto.ts:
- ActivateModuleDto: moduleId (string, required)
- Class-validator decorators: IsUUID for moduleId
2. Create apps/api/src/module-registry/module-registry.service.ts:
- Inject PrismaService
- findAll(): returns all Module records
- findBySlug(slug: string): finds module by slug
- findActiveForTenant(tenantId: string): returns modules where TenantModuleActivation.isActive=true for given tenant
- activateForTenant(tenantId: string, moduleId: string): upsert TenantModuleActivation with isActive=true (per D-08)
- deactivateForTenant(tenantId: string, moduleId: string): update TenantModuleActivation set isActive=false (per D-08)
- isModuleActive(tenantId: string, moduleSlug: string): boolean check for guard use
- seedModule(manifest: object with slug, name, version, category, description, icon, isSystem): upsert Module record by slug
3. Create apps/api/src/module-registry/module.guard.ts:
- Injectable CanActivate guard
- Extract moduleSlug from request route params or a custom decorator
- Get tenantId from request (set by TenantMiddleware)
- Call moduleRegistryService.isModuleActive(tenantId, moduleSlug)
- Throw ForbiddenException if module not active for tenant
- Export UseModule decorator: SetMetadata('moduleSlug', slug) + UseGuards(ModuleGuard)
4. Create apps/api/src/module-registry/module-registry.controller.ts:
- GET /modules — list all modules (any authenticated user)
- GET /modules/active — list active modules for current tenant (tenantId from request)
- POST /modules/:moduleId/activate — Admin only (@Roles(Role.ADMIN, Role.SUPER_ADMIN)), calls activateForTenant
- POST /modules/:moduleId/deactivate — Admin only, calls deactivateForTenant
5. Create apps/api/src/module-registry/module-registry.module.ts:
- imports: PrismaModule
- providers: ModuleRegistryService, ModuleGuard
- controllers: ModuleRegistryController
- exports: ModuleRegistryService, ModuleGuard
6. Register ModuleRegistryModule in apps/api/src/app.module.ts imports array.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter api build 2>&1 | tail -5 && curl -s http://localhost:3001/modules 2>/dev/null | head -1 || echo "Build check passed"</automated>
</verify>
<acceptance_criteria>
- apps/api builds without errors with ModuleRegistryModule registered
- GET /modules endpoint exists and returns array (empty initially)
- GET /modules/active returns only tenant-activated modules
- POST /modules/:id/activate requires ADMIN role and creates activation record
- POST /modules/:id/deactivate sets isActive=false without deleting the record
- ModuleGuard is exported and usable by downstream module controllers
</acceptance_criteria>
<done>Admin can list, activate, and deactivate modules via API. ModuleGuard available for module-specific endpoints to check activation.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> API | Untrusted input for module activation/deactivation |
| module guard | Ensures tenant cannot access modules not activated for them |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Spoofing | module activation endpoint | mitigate | @Roles(ADMIN) guard ensures only admins activate modules |
| T-03-02 | Tampering | moduleId param | mitigate | IsUUID validator on DTO, Prisma parameterized queries |
| T-03-03 | Information Disclosure | GET /modules | accept | Module catalog is non-sensitive metadata, all authenticated users may see it |
| T-03-04 | Elevation of Privilege | module guard bypass | mitigate | ModuleGuard checks tenantId from JWT (not user-supplied), ForbiddenException on inactive |
</threat_model>
<verification>
- pnpm --filter api build succeeds
- Prisma migration applied, Module and TenantModuleActivation tables exist
- @tessera/module-sdk package resolves from apps/api
- API endpoints respond correctly to authenticated requests
</verification>
<success_criteria>
- Module SDK defines TesseraModule interface with all fields from RESEARCH.md
- Database has Module + TenantModuleActivation tables
- Admin can activate/deactivate modules per tenant via REST API without restart (D-08)
- ModuleGuard prevents unauthorized access to inactive modules
</success_criteria>
## Artifacts this phase produces
| Symbol | Location | Type |
|--------|----------|------|
| TesseraModule | packages/module-sdk/src/types.ts | interface |
| ModuleRoute | packages/module-sdk/src/types.ts | interface |
| ModuleManifest | packages/module-sdk/src/types.ts | type |
| ModuleCategory | packages/module-sdk/src/types.ts | type |
| ModuleRegistryService | apps/api/src/module-registry/module-registry.service.ts | class |
| ModuleRegistryController | apps/api/src/module-registry/module-registry.controller.ts | class |
| ModuleGuard | apps/api/src/module-registry/module.guard.ts | class |
| UseModule | apps/api/src/module-registry/module.guard.ts | decorator |
| Module (Prisma) | apps/api/prisma/schema.prisma | model |
| TenantModuleActivation (Prisma) | apps/api/prisma/schema.prisma | model |
<output>
Create `.planning/phases/03-module-system-domaincheck/03-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,273 @@
---
phase: 03-module-system-domaincheck
plan: 02
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- apps/api/src/domaincheck/domaincheck.module.ts
- apps/api/src/domaincheck/domaincheck.service.ts
- apps/api/src/domaincheck/domaincheck.controller.ts
- apps/api/src/domaincheck/dto/check-domain.dto.ts
- apps/api/src/domaincheck/domaincheck.seed.ts
- apps/api/src/app.module.ts
- apps/web/src/app/(portal)/modules/domaincheck/page.tsx
- apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx
- apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx
- apps/web/src/app/(portal)/modules/domaincheck/actions.ts
- apps/web/messages/de.json
- apps/web/messages/en.json
autonomous: true
requirements:
- DCHK-01
- DCHK-02
- DCHK-03
must_haves:
truths:
- "User can open the Domaincheck module and see an input field"
- "User enters a domain name and sees TLD variant results"
- "Results show green (available) or red (registered) status per TLD"
artifacts:
- path: "apps/api/src/domaincheck/domaincheck.service.ts"
provides: "DNS-based domain availability checking"
exports: ["DomaincheckService"]
- path: "apps/api/src/domaincheck/domaincheck.controller.ts"
provides: "POST /modules/domaincheck/check endpoint"
exports: ["DomaincheckController"]
- path: "apps/web/src/app/(portal)/modules/domaincheck/page.tsx"
provides: "Domaincheck UI page within portal"
key_links:
- from: "apps/web/src/app/(portal)/modules/domaincheck/actions.ts"
to: "/modules/domaincheck/check"
via: "fetch POST to API"
pattern: "fetch.*modules/domaincheck/check"
- from: "apps/api/src/domaincheck/domaincheck.service.ts"
to: "node:dns/promises"
via: "dns.resolve for availability check"
pattern: "dns\\.resolve"
---
<objective>
Deliver a working Domaincheck module end-to-end — user enters a domain name, backend checks TLD variants via DNS, frontend shows green/red results. This is the proof-of-concept module validating the entire module system.
Purpose: Per D-01, D-02, D-03 — validates the module system with a real, interactive module. Users get immediate value.
Output: DomaincheckModule (NestJS), domaincheck frontend page, module seed data.
</objective>
<execution_context>
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/03-module-system-domaincheck/03-CONTEXT.md
@.planning/phases/03-module-system-domaincheck/03-RESEARCH.md
@.planning/phases/03-module-system-domaincheck/03-01-SUMMARY.md
@apps/api/src/module-registry/module-registry.service.ts
@apps/api/src/module-registry/module.guard.ts
@apps/web/src/app/(portal)/layout.tsx
@apps/web/messages/de.json
@apps/web/messages/en.json
</context>
## Phase Goal
**As a** user, **I want to** enter a domain name and see which TLD variants are available or registered, **so that** I can quickly check domain availability without leaving the platform.
<tasks>
<task type="auto">
<name>Task 1: Domaincheck Backend — DNS Service + API Endpoint + Module Seed</name>
<files>
apps/api/src/domaincheck/domaincheck.module.ts,
apps/api/src/domaincheck/domaincheck.service.ts,
apps/api/src/domaincheck/domaincheck.controller.ts,
apps/api/src/domaincheck/dto/check-domain.dto.ts,
apps/api/src/domaincheck/domaincheck.seed.ts,
apps/api/src/app.module.ts
</files>
<read_first>
apps/api/src/module-registry/module-registry.service.ts,
apps/api/src/module-registry/module.guard.ts,
apps/api/src/app.module.ts
</read_first>
<action>
1. Create apps/api/src/domaincheck/dto/check-domain.dto.ts:
- CheckDomainDto: domain (string, @IsString, @IsNotEmpty, @Matches regex for valid domain label — alphanumeric + hyphens, no dots)
- Optional tlds (string array) — if omitted, use defaults
2. Create apps/api/src/domaincheck/domaincheck.service.ts:
- DEFAULT_TLDS constant: ['de', 'com', 'net', 'org'] (per D-03 configurable, these are defaults)
- Method checkDomain(domain: string, tlds?: string[]): Promise of array of { tld: string, fqdn: string, status: 'available' | 'registered' }
- Implementation: use node:dns/promises — dns.resolve(fqdn) for each TLD variant
- If resolves (any record) -> status = 'registered'
- If throws with code ENOTFOUND or ENODATA -> status = 'available'
- If throws with other error -> status = 'registered' (safe default)
- Use Promise.allSettled for parallel lookups with 5-second timeout per lookup
- Per D-02: system checks all TLD variants automatically from the base domain name
3. Create apps/api/src/domaincheck/domaincheck.controller.ts:
- POST /modules/domaincheck/check
- Protected by UseModule('domaincheck') decorator from module.guard.ts
- Accepts CheckDomainDto body
- Returns { domain: string, results: array of { tld, fqdn, status } }
4. Create apps/api/src/domaincheck/domaincheck.module.ts:
- imports: PrismaModule, ModuleRegistryModule (for guard access)
- providers: DomaincheckService
- controllers: DomaincheckController
5. Create apps/api/src/domaincheck/domaincheck.seed.ts:
- Export async function seedDomaincheckModule(moduleRegistryService: ModuleRegistryService)
- Calls moduleRegistryService.seedModule with: slug='domaincheck', name='Domaincheck', version='1.0.0', category='domain-tools' (per D-09), description={ de: 'Domain-Verfuegbarkeit pruefen', en: 'Check domain availability' }, isSystem=true
6. Register DomaincheckModule in apps/api/src/app.module.ts imports.
7. Call the seed function from an OnModuleInit hook in DomaincheckModule (or add to an existing seeding mechanism) so the domaincheck module record exists in the database on startup.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter api build 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- API build succeeds with DomaincheckModule
- POST /modules/domaincheck/check accepts { domain: "google" } and returns results array with tld, fqdn, status for each default TLD
- DNS check correctly identifies google.com as registered
- Module seed creates a "domaincheck" record in Module table on startup
- UseModule('domaincheck') guard protects the endpoint — returns 403 if module not activated for tenant
</acceptance_criteria>
<done>Domaincheck API endpoint works end-to-end: accepts domain, checks DNS for TLD variants, returns structured results</done>
</task>
<task type="auto">
<name>Task 2: Domaincheck Frontend — Input Form + Results Display</name>
<files>
apps/web/src/app/(portal)/modules/domaincheck/page.tsx,
apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx,
apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx,
apps/web/src/app/(portal)/modules/domaincheck/actions.ts,
apps/web/messages/de.json,
apps/web/messages/en.json
</files>
<read_first>
apps/web/src/app/(portal)/page.tsx,
apps/web/src/app/(portal)/layout.tsx,
apps/web/messages/de.json,
apps/web/messages/en.json
</read_first>
<action>
1. Add i18n keys to apps/web/messages/de.json under "domaincheck" namespace:
- title: "Domaincheck"
- description: "Pruefe ob eine Domain verfuegbar ist"
- inputPlaceholder: "Domain eingeben (z.B. beispiel)"
- checkButton: "Pruefen"
- statusAvailable: "Verfuegbar"
- statusRegistered: "Registriert"
- noResults: "Gib einen Domain-Namen ein um die Verfuegbarkeit zu pruefen"
- checking: "Pruefe..."
- error: "Fehler bei der Pruefung"
2. Add same keys in English to apps/web/messages/en.json under "domaincheck" namespace:
- title: "Domain Check"
- description: "Check if a domain is available"
- inputPlaceholder: "Enter domain (e.g. example)"
- checkButton: "Check"
- statusAvailable: "Available"
- statusRegistered: "Registered"
- noResults: "Enter a domain name to check availability"
- checking: "Checking..."
- error: "Error checking domain"
3. Create apps/web/src/app/(portal)/modules/domaincheck/actions.ts:
- Server action or client-side fetch function checkDomainAction(domain: string): fetches POST to API_URL/modules/domaincheck/check with { domain }, passes auth cookie/token, returns typed results array
4. Create apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx:
- Client component ("use client")
- Input field (text) with placeholder from i18n
- Submit button
- Calls onSubmit prop with the domain value
- Shows loading state while checking (per i18n "checking" key)
5. Create apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx:
- Client component
- Receives results array prop: { tld, fqdn, status }[]
- Renders list/table of results
- Per D-01: green badge/pill for "available", red badge/pill for "registered"
- Per D-02: shows all TLD variants with their status
- Empty state shows "noResults" message
6. Create apps/web/src/app/(portal)/modules/domaincheck/page.tsx:
- Page component within (portal) route group (gets AppShell layout)
- Uses DomainInput and ResultList components
- Manages state with useState: domain input, results array, loading, error
- On submit: calls checkDomainAction, updates results
- Layout: title at top, input below, results list below input (per specifics in CONTEXT.md)
- Use Tailwind classes consistent with existing portal pages
- Use shadcn/ui Card component for the module panel (per D-05a)
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter web build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- Web app builds successfully with the new domaincheck route
- /modules/domaincheck page renders within the portal AppShell
- Input field accepts text, submit triggers API call
- Results display with color-coded status badges (green=available, red=registered)
- All visible strings use next-intl translations (no hardcoded text)
- Works in both DE and EN locale
</acceptance_criteria>
<done>User can navigate to /modules/domaincheck, enter a domain name, and see color-coded availability results for .de, .com, .net, .org TLD variants</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> POST /modules/domaincheck/check | User-supplied domain string |
| API -> DNS | External DNS resolution (network) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-05 | Tampering | domain input | mitigate | Regex validation in DTO — only alphanumeric + hyphens, max 63 chars, no dots allowed |
| T-03-06 | Denial of Service | DNS lookups | mitigate | 5-second timeout per lookup, max 10 TLDs per request, Promise.allSettled prevents hanging |
| T-03-07 | Information Disclosure | DNS results | accept | Domain availability is public information, no sensitive data exposed |
| T-03-08 | Spoofing | module access | mitigate | UseModule('domaincheck') guard + JwtAuthGuard ensure only authenticated users with activated module can access |
</threat_model>
<verification>
- pnpm --filter api build && pnpm --filter web build both succeed
- POST /modules/domaincheck/check returns correct results for known domains
- Frontend page renders and submits successfully
- Module guard blocks access when module not activated for tenant
</verification>
<success_criteria>
- User can open /modules/domaincheck in browser (DCHK-01)
- System checks TLD variants via DNS and returns status (DCHK-02)
- Results show clear green/red status indicators (DCHK-03, D-01)
- TLD list uses defaults ['de','com','net','org'] per D-03
</success_criteria>
## Artifacts this phase produces
| Symbol | Location | Type |
|--------|----------|------|
| DomaincheckService | apps/api/src/domaincheck/domaincheck.service.ts | class |
| DomaincheckController | apps/api/src/domaincheck/domaincheck.controller.ts | class |
| DomaincheckModule | apps/api/src/domaincheck/domaincheck.module.ts | NestJS module |
| CheckDomainDto | apps/api/src/domaincheck/dto/check-domain.dto.ts | class |
| seedDomaincheckModule | apps/api/src/domaincheck/domaincheck.seed.ts | function |
| DomaincheckPage | apps/web/src/app/(portal)/modules/domaincheck/page.tsx | React component |
| DomainInput | apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx | React component |
| ResultList | apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx | React component |
| checkDomainAction | apps/web/src/app/(portal)/modules/domaincheck/actions.ts | function |
<output>
Create `.planning/phases/03-module-system-domaincheck/03-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,211 @@
---
phase: 03-module-system-domaincheck
plan: 03
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- apps/web/src/app/(portal)/modules/[category]/page.tsx
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx
- apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/api.ts
- apps/web/messages/de.json
- apps/web/messages/en.json
autonomous: true
requirements:
- MOD-04
must_haves:
truths:
- "Module UIs load on demand via dynamic imports — inactive modules are never bundled"
- "Category page shows module cards in a grid layout"
- "Module can be opened in expanded view from category page"
artifacts:
- path: "apps/web/src/lib/module-loader.ts"
provides: "Dynamic import registry mapping slugs to lazy components"
exports: ["loadModuleComponent", "loadModuleCard", "MODULE_REGISTRY"]
- path: "apps/web/src/app/(portal)/modules/[category]/page.tsx"
provides: "Category page showing activated module cards"
- path: "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"
provides: "Expanded module view using lazy-loaded component"
key_links:
- from: "apps/web/src/lib/module-loader.ts"
to: "next/dynamic"
via: "dynamic import with ssr:false"
pattern: "dynamic.*import"
- from: "apps/web/src/app/(portal)/modules/[category]/page.tsx"
to: "/modules/active"
via: "API fetch for tenant's active modules"
pattern: "fetch.*modules/active"
---
<objective>
Deliver lazy-loaded module UI rendering — category pages show module cards from the registry, and modules load on demand via Next.js dynamic imports. No bundle bloat from inactive modules.
Purpose: Per D-04, D-05a, D-05b and MOD-04 — modules appear as cards within category pages and can expand to full view, all lazy-loaded.
Output: Module loader utility, category page with dynamic routing, module card component.
</objective>
<execution_context>
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/03-module-system-domaincheck/03-CONTEXT.md
@.planning/phases/03-module-system-domaincheck/03-RESEARCH.md
@.planning/phases/03-module-system-domaincheck/03-01-SUMMARY.md
@apps/web/src/app/(portal)/layout.tsx
@apps/web/src/app/(portal)/page.tsx
</context>
## Phase Goal
**As a** user, **I want to** browse module categories and open modules without page bloat, **so that** only active modules load their UI code on demand.
<tasks>
<task type="auto">
<name>Task 1: Module Loader Utility + Category Page with Lazy Cards</name>
<files>
apps/web/src/lib/module-loader.ts,
apps/web/src/app/(portal)/modules/[category]/page.tsx,
apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx,
apps/web/messages/de.json,
apps/web/messages/en.json
</files>
<read_first>
apps/web/src/app/(portal)/layout.tsx,
apps/web/src/app/(portal)/page.tsx,
apps/web/messages/de.json
</read_first>
<action>
1. Create apps/web/src/lib/module-loader.ts:
- MODULE_REGISTRY: Record mapping module slugs to { component: () => import(...), cardComponent: () => import(...) }
- For domaincheck: component maps to dynamic(() => import('@/app/(portal)/modules/domaincheck/page'), { ssr: false }), cardComponent maps to a DomaincheckCard component
- Export loadModuleComponent(slug: string): returns the dynamic component or null if not in registry
- Export loadModuleCard(slug: string): returns the card component or a generic fallback card
- This is the lazy loading mechanism — components are only imported when rendered (per MOD-04)
2. Add i18n keys to both message files under "modules" namespace:
- DE: categoryTitle: "Module: {category}", openModule: "Oeffnen", noModules: "Keine aktiven Module in dieser Kategorie"
- EN: categoryTitle: "Modules: {category}", openModule: "Open", noModules: "No active modules in this category"
3. Create apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx:
- Client component ("use client")
- Props: module object (name, slug, description, icon, category)
- Renders a shadcn/ui Card with: icon area, module name, description (from current locale), "Open" link to /modules/{category}/{slug}
- Per D-05a: uniform card appearance, compact, looks good even with single module
- Uses Tailwind for consistent spacing and theming
4. Create apps/web/src/app/(portal)/modules/[category]/page.tsx:
- Server component that receives params.category
- Fetches active modules for current tenant from API (GET /modules/active), filters by category matching params.category
- Renders a responsive grid of ModuleCard components (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3)
- Empty state shows "noModules" i18n message
- Title shows category name formatted (capitalize, replace hyphens with spaces)
- Per D-04: category page groups all modules of that category
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter web build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- Web build succeeds with dynamic route [category]
- /modules/domain-tools page fetches and displays active modules filtered by "domain-tools" category
- ModuleCard renders module info with link to expanded view
- module-loader.ts maps 'domaincheck' slug to lazy-imported components
- Inactive modules never trigger an import (verified by MODULE_REGISTRY only containing active slug mappings at build time)
</acceptance_criteria>
<done>Category page at /modules/domain-tools shows domaincheck as a card, lazy loading is wired via module-loader registry</done>
</task>
<task type="auto">
<name>Task 2: Expanded Module View with Dynamic Routing</name>
<files>
apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx,
apps/web/src/lib/api.ts
</files>
<read_first>
apps/web/src/lib/module-loader.ts,
apps/web/src/app/(portal)/modules/[category]/page.tsx
</read_first>
<action>
1. Create or extend apps/web/src/lib/api.ts (if not existing):
- Export getActiveModules(cookie: string): fetches GET /modules/active with auth cookie, returns module array
- Export getModuleBySlug(slug: string, cookie: string): fetches GET /modules, finds by slug, returns module or null
- Base URL from NEXT_PUBLIC_API_URL env var
2. Create apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx:
- Per D-05b: expanded/full view when user opens a specific module
- Server component wrapper that validates moduleSlug exists in registry
- Uses loadModuleComponent(params.moduleSlug) from module-loader.ts to get the lazy component
- If module not found in registry: show 404/not-found state
- If found: render the dynamically imported component within a container
- The loaded component for 'domaincheck' is the DomaincheckPage from Plan 02
- Wrapped in shadcn/ui Card with back-link to category page
3. Ensure the domaincheck page.tsx from Plan 02 can function both as standalone (/modules/domaincheck direct access) AND as the loaded component in expanded view. The [moduleSlug] route provides the canonical expanded path per D-05b: /modules/domain-tools/domaincheck
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter web build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- /modules/domain-tools/domaincheck route renders the domaincheck module component
- Module component loads lazily (not in initial bundle of category page)
- Unknown module slugs show appropriate not-found state
- Back-link navigates to category page /modules/domain-tools
</acceptance_criteria>
<done>User can navigate from category card grid to expanded module view. Module UI code loads on demand only when opened (MOD-04).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| URL params -> module loader | User can craft arbitrary slugs in URL |
| API -> frontend | Module list from API determines what renders |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-09 | Tampering | [moduleSlug] param | mitigate | Only load from MODULE_REGISTRY whitelist — arbitrary slugs get not-found, no arbitrary imports |
| T-03-10 | Elevation of Privilege | category page | mitigate | API filters by tenant's active modules — inactive modules never returned, cards never rendered |
| T-03-11 | Information Disclosure | module metadata | accept | Module names/descriptions are non-sensitive catalog data |
</threat_model>
<verification>
- pnpm --filter web build succeeds
- /modules/domain-tools renders category page with module cards
- /modules/domain-tools/domaincheck loads domaincheck UI lazily
- Bundle analysis confirms domaincheck code not in category page chunk
</verification>
<success_criteria>
- Module UIs load on demand via next/dynamic with ssr:false (MOD-04)
- Category page displays modules as cards per D-04, D-05a
- Expanded view available per D-05b
- No bundle impact from inactive/unregistered modules
</success_criteria>
## Artifacts this phase produces
| Symbol | Location | Type |
|--------|----------|------|
| MODULE_REGISTRY | apps/web/src/lib/module-loader.ts | const |
| loadModuleComponent | apps/web/src/lib/module-loader.ts | function |
| loadModuleCard | apps/web/src/lib/module-loader.ts | function |
| CategoryPage | apps/web/src/app/(portal)/modules/[category]/page.tsx | React component |
| ModuleCard | apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx | React component |
| ExpandedModulePage | apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx | React component |
| getActiveModules | apps/web/src/lib/api.ts | function |
| getModuleBySlug | apps/web/src/lib/api.ts | function |
<output>
Create `.planning/phases/03-module-system-domaincheck/03-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,130 @@
---
phase: 03-module-system-domaincheck
plan: 04
type: execute
wave: 3
depends_on: ["03-02", "03-03"]
files_modified: []
autonomous: false
requirements:
- MOD-01
- MOD-02
- MOD-03
- MOD-04
- DCHK-01
- DCHK-02
- DCHK-03
must_haves:
truths:
- "All phase success criteria verified by human"
- "Module system works end-to-end with domaincheck as proof"
artifacts: []
key_links: []
---
<objective>
Human verification that the complete module system and domaincheck module work as intended — admin activation, lazy loading, domain checking with colored results.
Purpose: Final validation before marking Phase 3 complete.
Output: Human approval or issues list for gap closure.
</objective>
<execution_context>
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-module-system-domaincheck/03-01-SUMMARY.md
@.planning/phases/03-module-system-domaincheck/03-02-SUMMARY.md
@.planning/phases/03-module-system-domaincheck/03-03-SUMMARY.md
</context>
## Phase Goal
**As a** platform admin, **I want to** discover, register, and activate modules dynamically, **so that** tenants get access to new functionality without application restarts.
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 1: Verify Module System End-to-End</name>
<what-built>
Complete module system with SDK, database registry, per-tenant activation, lazy-loaded frontend, and working Domaincheck module.
</what-built>
<how-to-verify>
1. Start the Docker Compose stack: docker compose up -d
2. Log in as Admin user
3. Verify module registry API:
- GET http://localhost:3001/modules — should show domaincheck module in list
- GET http://localhost:3001/modules/active — should show activated modules for tenant
4. If domaincheck not yet activated, activate it:
- POST http://localhost:3001/modules/{domaincheck-id}/activate
5. Navigate to http://localhost:3000/modules/domain-tools
- Should see category page with Domaincheck card (icon, name, description)
- Card should look good as a standalone module in the grid (D-05a)
6. Click "Open" on the Domaincheck card (or navigate to /modules/domain-tools/domaincheck)
- Should see expanded view with input field (D-05b)
7. Enter "google" in the domain input and click "Check"
- Should see results for google.de, google.com, google.net, google.org
- google.com and google.de should show RED "Registered" badge (D-01)
8. Enter a clearly available domain (e.g. "xyzabc123randomtest") and check
- Some TLDs should show GREEN "Available" badge
9. Verify i18n: switch language to English
- All labels should change to English equivalents
10. Verify theme: switch to dark mode
- Module UI should respect dark theme
11. Deactivate the module:
- POST http://localhost:3001/modules/{domaincheck-id}/deactivate
- Refresh /modules/domain-tools — domaincheck card should disappear
- Direct access to /modules/domain-tools/domaincheck should be blocked (403 from API)
</how-to-verify>
<read_first>
.planning/phases/03-module-system-domaincheck/03-01-SUMMARY.md,
.planning/phases/03-module-system-domaincheck/03-02-SUMMARY.md,
.planning/phases/03-module-system-domaincheck/03-03-SUMMARY.md
</read_first>
<acceptance_criteria>
- Module registry shows domaincheck in database
- Admin can activate/deactivate without restart (MOD-03)
- Category page renders with lazy-loaded cards (MOD-04)
- Domaincheck accepts input and returns colored results (DCHK-01, DCHK-02, DCHK-03)
- Deactivated module is inaccessible
- i18n and theming work correctly
</acceptance_criteria>
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| N/A | Verification-only plan, no new code |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-12 | N/A | N/A | accept | No new attack surface — verification only |
</threat_model>
<verification>
Human confirms all 11 verification steps pass.
</verification>
<success_criteria>
- Human types "approved" after verifying all success criteria from Phase 3 roadmap
- Or provides specific issues that will generate gap closure plans
</success_criteria>
## Artifacts this phase produces
| Symbol | Location | Type |
|--------|----------|------|
| (none — verification only) | | |
<output>
Create `.planning/phases/03-module-system-domaincheck/03-04-SUMMARY.md` when done
</output>