From 0fa9567571a2cbf8f23912d486abd70af687c359 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 08:43:29 +0200 Subject: [PATCH] feat(13-02): implement SourceRegistry with hard denylist gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GREEN — SourceRegistry.register() throws DeniedPortalError when any of an adapter's declared portals is in DENYLISTED_PORTALS (vergabe24, aumass), enforced at DI-registration time (INGEST-07/ D-06), not just documented. get()/activeAdapters() support the Plan 13-03 poll-once-fan-out-many scheduler. 6/6 tests pass, no Prisma/scraping import. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/tenders/source-registry.ts | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/api/src/tenders/source-registry.ts diff --git a/apps/api/src/tenders/source-registry.ts b/apps/api/src/tenders/source-registry.ts new file mode 100644 index 0000000..59601f2 --- /dev/null +++ b/apps/api/src/tenders/source-registry.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import type { SourceType } from './tender.types'; +import type { TenderSourceAdapter } from './adapters/tender-source-adapter.interface'; + +/** + * Portals whose AGB (Nutzungsbedingungen) prohibit automated/scraping + * access (INGEST-07/D-06). This is the hard code boundary — NOT just + * documentation: `SourceRegistry.register()` checks every adapter's + * `portals` against this list and refuses registration outright if any + * entry matches, at DI-boot time, before any poll can run. + */ +export const DENYLISTED_PORTALS = ['vergabe24', 'aumass'] as const; + +/** + * Thrown by `SourceRegistry.register()` when an adapter declares a + * denylisted portal. Proves Erfolgskriterium 4 (13-CONTEXT.md D-06) — + * see source-registry.spec.ts. + */ +export class DeniedPortalError extends Error { + constructor(portal: string) { + super( + `Portal '${portal}' ist AGB-seitig für automatisierten Zugriff gesperrt und darf nicht registriert werden.`, + ); + this.name = 'DeniedPortalError'; + } +} + +/** + * Central registry mapping `SourceType` -> `TenderSourceAdapter`, gated by + * the AGB denylist (T-13-02-01/T-13-02-02). `pollDueSources` (Plan 13-03) + * uses `get()`/`activeAdapters()` to fan out over all registered sources + * (poll-once-fan-out-many) instead of hardwiring a single adapter. + */ +@Injectable() +export class SourceRegistry { + private readonly adapters = new Map(); + + /** + * Registers an adapter. Iterates ALL of the adapter's declared `portals` + * — a single denylisted entry rejects the whole adapter, even if the + * rest of its portals are legitimate (T-13-02-02: no partial/mixed + * registration). + */ + register(adapter: TenderSourceAdapter): void { + for (const portal of adapter.portals) { + if ((DENYLISTED_PORTALS as readonly string[]).includes(portal)) { + throw new DeniedPortalError(portal); + } + } + this.adapters.set(adapter.sourceType, adapter); + } + + /** Returns the adapter for a sourceType, or undefined if none is registered — never throws. */ + get(type: SourceType): TenderSourceAdapter | undefined { + return this.adapters.get(type); + } + + /** All currently registered adapters, for fan-out scheduling. */ + activeAdapters(): TenderSourceAdapter[] { + return [...this.adapters.values()]; + } +}