module-loader.ts MODULE_REGISTRY['tender-radar'] entry is MANDATORY — page 404s without it even when activated
Deliver the first user-facing vertical slice: register the Ausschreibungs-Radar module in the marketplace (`isSystem: true`, admin-activatable per tenant) and make its portal page load when activated. Seed the singleton `doe-opendata` poll-config row so later plans have a config to drive.
Phase Goal (user story)
As a Tessera-Administrator, I want to das Ausschreibungs-Radar-Modul im Marketplace aktivieren, so that ich Zugang zum Ausschreibungs-Katalog fuer einen Mandanten freischalten kann — genau wie beim DKV-Fleet- und Cert-Manager-Modul (CONFIG-01).
Purpose: After this plan a real admin can DO something new: find the module in the marketplace, activate it for a tenant, and open its page. This is the CONFIG-01 slice end-to-end (registry seed → marketplace → activation → lazy-loaded page).
Output: Self-seeding TendersModule, module-loader whitelist entry, minimal placeholder page, seeded singleton poll config.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md
@apps/api/src/dkv/dkv.module.ts
@apps/api/src/cert-manager/cert-manager.seed.ts
@apps/api/src/module-registry/module-registry.service.ts
@apps/web/src/lib/module-loader.ts
Task 1: TendersModule skeleton + registry self-seed + singleton poll config
- apps/api/src/dkv/dkv.module.ts (exact OnModuleInit self-seed pattern to copy)
- apps/api/src/cert-manager/cert-manager.seed.ts (preferred seed analog — isSystem:true + "admin must activate via Marketplace" phrasing matches CONFIG-01)
- apps/api/src/module-registry/module-registry.service.ts (seedModule upsert-by-slug signature, lines ~158-187)
- apps/api/src/app.module.ts (imports array — add TendersModule alongside DkvModule/CertManagerModule)
apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tenders.seed.ts, apps/api/src/app.module.ts
Create `tenders.seed.ts` exporting `seedTendersModule(moduleRegistryService)` that calls `moduleRegistryService.seedModule({ slug: 'tender-radar', name: 'Ausschreibungs-Radar', version: '1.0.0', category: 'procurement', description: { de: 'Deutsche Ausschreibungen automatisch erfassen und durchsuchen', en: 'Automatically track and search German public tenders' }, isSystem: true })` — mirror `cert-manager.seed.ts` verbatim in structure (CONFIG-01). Confirm `procurement` is acceptable as a new category value (seedModule does not constrain category); if the marketplace UI needs a known category, reuse an existing one and note it in the summary.
Create `tenders.module.ts` implementing `OnModuleInit` exactly like `DkvModule`: `imports: [ModuleRegistryModule]`, `controllers: []` (added Plan 05), `providers: []` (services added Plans 03-04), and an `onModuleInit()` that calls `seedTendersModule(this.moduleRegistryService)` inside try/catch with a Logger. Also, in `onModuleInit()`, upsert the singleton poll config: `prisma.tenderSourcePollConfig.upsert({ where: { sourceType: 'doe-opendata' }, update: {}, create: { sourceType: 'doe-opendata', pollIntervalMin: 60, isActive: true } })` — inject `PrismaService` (PrismaModule is global). isActive defaults true so the platform-global DÖE poll (D-04 hourly) runs regardless of tenant activation; the day-cursor gate (Plan 04) makes this idempotent. Do NOT use `forTenant()` — this config and the Tender table are global (D-03).
Add `TendersModule` to the `imports` array in `app.module.ts`.
cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5 && grep -c "TendersModule" src/app.module.ts
- `tenders.seed.ts` seeds slug `tender-radar` with `isSystem: true`.
- `TendersModule` is imported in `app.module.ts` (`grep -c "TendersModule" src/app.module.ts` >= 2 — import line + array entry).
- Singleton `doe-opendata` config upserted on init (idempotent — re-boot does not create a second row; guaranteed by `sourceType @unique`).
- `tsc --noEmit` passes.
Module self-seeds into the registry on boot and seeds the singleton poll config; app compiles.
Task 2: Web module-loader whitelist entry + minimal portal page
- apps/web/src/lib/module-loader.ts (MODULE_REGISTRY object — copy the 'cert-manager' entry shape)
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx (page.tsx conventions: 'use client', useTranslations, default export)
apps/web/src/lib/module-loader.ts, apps/web/src/app/(portal)/modules/tender-radar/page.tsx
Add a `'tender-radar'` entry to `MODULE_REGISTRY` in `module-loader.ts` pointing at `dynamic(() => import('@/app/(portal)/modules/tender-radar/page'), { ssr: false })` — identical shape to the existing `'cert-manager'`/`'dkv-fleet'` entries. This whitelist entry is MANDATORY for CONFIG-01: the page 404s even when the module is activated if the slug is not whitelisted (security-motivated whitelist, module-loader header comment).
Create a minimal `tender-radar/page.tsx` client component (default export, `'use client'`) that renders the module title and a short "Ausschreibungen werden erfasst" placeholder. Real results UI is Phase 11 — this is the thinnest page that proves the activation → page-load path. Use a hardcoded German string here (full i18n is CONFIG-03, Phase 14); note this as an intentional MVP stub in the summary.
cd apps/web && grep -c "tender-radar" src/lib/module-loader.ts && test -f "src/app/(portal)/modules/tender-radar/page.tsx" && pnpm exec tsc --noEmit 2>&1 | tail -5
- `MODULE_REGISTRY['tender-radar']` entry present (`grep -c "tender-radar" module-loader.ts` >= 1).
- `tender-radar/page.tsx` exists and default-exports a component.
- Web `tsc --noEmit` passes.
Activated module opens its page instead of 404; whitelist entry in place.
Task 3: Seed registration test (Wave 0 coverage for CONFIG-01)
- apps/api/src/cert-manager/cert-manager.service.spec.ts (existing vitest spec style in this repo)
- apps/api/src/tenders/tenders.seed.ts (unit under test, from Task 1)
apps/api/src/tenders/tenders.seed.spec.ts
Create `tenders.seed.spec.ts` (Vitest). Provide a mock `ModuleRegistryService` with a spied `seedModule`. Assert that `seedTendersModule(mock)` calls `seedModule` once with `slug: 'tender-radar'`, `isSystem: true`, and a description object containing both `de` and `en` keys. This is the Wave 0 automated proof for CONFIG-01 registration.
cd apps/api && pnpm test -- tenders.seed 2>&1 | tail -15
- Test asserts `seedModule` called with `slug: 'tender-radar'` and `isSystem: true`.
- `pnpm --filter @tessera/api test -- tenders.seed` passes.
Registration behaviour has automated coverage; green.
<threat_model>
Trust Boundaries
Boundary
Description
tenant → module activation
A tenant's activation must gate only visibility, not create a second data copy or leak another tenant's config
URL slug → dynamic import
Only whitelisted slugs may resolve to a component (module-loader security whitelist)
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-10-03
Elevation of Privilege
module-loader.ts dynamic import
medium
mitigate
Only the fixed 'tender-radar' slug is whitelisted; arbitrary URL slugs return null (existing whitelist mechanism, unchanged)
T-10-04
Information Disclosure
per-tenant module activation
high
mitigate
Activation uses the existing ModuleRegistryService / TenantModuleActivation mechanism unchanged; the singleton doe-opendata config is global (one row, sourceType @unique) so a 2nd tenant activation cannot create or read a per-tenant duplicate. No forTenant() applied to the global config
T-10-05
Tampering
registry self-seed on boot
low
accept
seedModule is an idempotent upsert-by-slug; repeated boots do not duplicate the module row
</threat_model>
- Module appears in the registry with slug `tender-radar` after boot (seed test green).
- Activating the module for a tenant opens `/modules/tender-radar` (whitelist entry present).
- Singleton poll config seeded exactly once (idempotent upsert).
- `pnpm --filter @tessera/api test` green; both apps `tsc --noEmit` clean.
<success_criteria>
CONFIG-01: admin can find and activate Ausschreibungs-Radar in the marketplace, same as DKV/Cert-Manager, and open its page.
</success_criteria>
Create `.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-02-SUMMARY.md` when done.