Source: Router/registry/registryRouter.js

// @ts-check
/**
 * @import {ExpressRequestAuthorized, ExpressResponse} from '../../types.js'
 */

/**
 * Registry Router — Laufzeit-Sicht auf das App-Registry
 *
 * Für **Maschinen**, nicht für Menschen: Broker, Backends und Bots lesen hier die
 * Host→Org→App-Zuordnung. Bedient wird das Registry (Schreiben) weiterhin über
 * `/api/kpe20/orgaSettings/*` — dieselbe Datenbasis, andere Zielgruppe.
 *
 * Mounted at: /api/registry  (siehe http-server.js)
 *
 * ## Die Grenze: `bot` und `employee` — aber kein Mandanten-Nutzer
 *
 * Der Router verlangt `makeAuthCheck(['bot', 'employee'])`. Das ist bewusst
 * **nicht** `['user']` und auch nicht die `checkAdmin`-Prüfung der
 * `orgaSettings`-Endpunkte, die einen Mandanten-`db-admin` durchlässt.
 *
 * Der Grund ist `/domains`: der Endpunkt liefert die Host-Zuordnung **aller**
 * Organisationen — `/cors/origins` entsprechend. Diese Sicht hat bisher nur im
 * Backend existiert (Vault-Scan über alle Orgs) und ist nie adressierbar
 * gewesen. Mit diesem Router wird sie es. Ohne eigenes Gate könnte ein
 * `db-admin` des Kunden A die Domain-Liste **aller** anderen Kunden lesen, also
 * Mandanten auszählen. Die Trennung verläuft damit nicht zwischen „angemeldet"
 * und „nicht angemeldet", sondern zwischen **Mandant** und **Plattform**:
 *
 * | Prinzipal | Zugang | Warum |
 * |---|---|---|
 * | `bot` (Service-Account) | ja | Broker und Backends lesen die Zuordnung pro Request |
 * | `employee` (CommTool-Personal) | ja | das interne Frontend (§2.7 der Planung) |
 * | `user` mit `db-admin` | **nein** | Mandanten-Admin — genau das Leck |
 * | nicht angemeldet | nein | — |
 *
 * **Warum `bot` mit drin ist, obwohl „nur employees" naheliegend wäre:** die
 * Konsumenten dieses Endpunkts sind Maschinen. `shared-auth`
 * (`organizationDomains.js`) und der Static-Server holen die Domain-Liste mit
 * einem **Service-Token** — nach „nur employees" hätten sie keinen Zugang mehr
 * und die Host-Auflösung wäre tot. Das Leck, das geschlossen werden soll, ist
 * der Mandanten-Admin, nicht der Service-Account.
 *
 * Die org-scoped Endpunkte (`/:orgId/apps*`) vertragen denselben Kreis: die
 * `orgId` steht im Pfad, ein Mandanten-Nutzer ist ausgesperrt, und Personal darf
 * jede Organisation einsehen — das ist seine Aufgabe.
 *
 * @swagger
 * tags:
 *   - name: Registry
 *     description: |
 *       Runtime view of the app registry for brokers, backends and bots.
 *       Schemas are defined in ./registry/registry.swagger.yaml.
 *       Use $ref: './registry/registry.swagger.yaml#/components/schemas/{SchemaName}' in route blocks.
 */

import { Router } from 'express';
import { makeAuthCheck } from '@commtool/shared-auth';
import { errorLoggerRead } from '../../utils/requestLogger.js';
import * as registryService from '../orgaSettings/registryService.js';

const router = Router();

/**
 * Das Gate für den gesamten Router — `bot` und `employee`, kein Mandanten-Nutzer.
 * Begründung und Abgrenzung: Dateikopf.
 *
 * Statischer Import ist hier in Ordnung: `addons.js` macht es genauso, und die
 * Middleware wird erst beim ersten Request ausgeführt — zu diesem Zeitpunkt sind
 * die Secrets konfiguriert.
 */
const registryAuth = makeAuthCheck(['bot', 'employee']);

/**
 * @swagger
 * /api/registry/resolve:
 *   get:
 *     summary: Resolve a host to organisation and app
 *     description: >
 *       Returns which organisation and which app serve the given host. Used by the
 *       broker on every request and by backends that need their domain context.
 *       Returns 404 when the host has no app — a host may exist without one.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: query
 *         name: host
 *         required: true
 *         schema:
 *           type: string
 *         example: myclub.app.kpe.de
 *     responses:
 *       200:
 *         description: Mapping found
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/DomainResolution'
 *       400:
 *         description: Missing host query parameter
 *       404:
 *         description: No mapping for this host
 */
router.get('/resolve', registryAuth, async (req, res) => {
    try {
        const host = typeof req.query.host === 'string' ? req.query.host : '';
        if (!host) return res.status(400).json({ success: false, message: 'host query param required' });

        const result = await registryService.resolveDomain(host);
        if (!result) return res.status(404).json({ success: false, message: 'No mapping found' });
        res.json({ success: true, result });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to resolve host.' });
    }
});

/**
 * @swagger
 * /api/registry/domains:
 *   get:
 *     summary: All domain → organisation → app mappings (cross-organisation)
 *     description: >
 *       Returns the mapping for every organisation. This is a cross-tenant view
 *       and is therefore restricted to CommTool staff (`employees`); an
 *       organisation admin must not be able to enumerate other tenants.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: All mappings
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: array
 *                   items:
 *                     $ref: './registry/registry.swagger.yaml#/components/schemas/DomainMapping'
 *       403:
 *         description: Caller is a tenant user or unauthenticated
 */
router.get('/domains', registryAuth, async (_req, res) => {
    try {
        const mappings = await registryService.getAllDomainMappings();
        res.json({ success: true, result: mappings });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load domain mappings.' });
    }
});

/**
 * @swagger
 * /api/registry/cors/origins:
 *   get:
 *     summary: CORS origins for all external domains (cross-organisation)
 *     description: >
 *       Cross-tenant view, restricted to CommTool staff (`employees`) — see
 *       /api/registry/domains for the reasoning.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Origin list
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: array
 *                   items:
 *                     type: string
 *                   example: ['https://myclub.de']
 *       403:
 *         description: Caller is a tenant user or unauthenticated
 */
router.get('/cors/origins', registryAuth, async (_req, res) => {
    try {
        const origins = await registryService.getAllCorsOrigins();
        res.json({ success: true, result: origins });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load CORS origins.' });
    }
});

/**
 * @swagger
 * /api/registry/app-catalog:
 *   get:
 *     summary: The app catalogue — which apps exist at all
 *     description: >
 *       The template a new organisation is seeded from
 *       (`orgas/data/default/apps`). It carries only presentation metadata
 *       (`title`, `description`, `icon`, `category`, `roles`) — no domain, no
 *       organisation, no UID. This is deliberately **not** an overlay: an
 *       organisation's own apps are never merged with it, so an entry that
 *       came from the catalogue stays readable as such.
 *
 *       It is a platform-level view across all tenants, hence the same gate as
 *       `/domains`.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Catalogue map, keyed by appId
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/AppsMap'
 *       403:
 *         description: Caller is a tenant user or unauthenticated
 */
router.get('/app-catalog', registryAuth, async (_req, res) => {
    try {
        const catalog = await registryService.getAppCatalog();
        res.json({ success: true, result: catalog });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load app catalog.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/apps:
 *   get:
 *     summary: App registry of one organisation
 *     description: >
 *       Returns the app map exactly as the admin frontend maintains it
 *       (`{ [appId]: { domain, roles, title, … } }`), so portal and bots can
 *       consume the same shape they used from Vault.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *         example: UUID-4efce539-bd70-11f1-b929-5e3866f0f490
 *     responses:
 *       200:
 *         description: App map
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/AppsMap'
 */
router.get('/:orgId/apps', registryAuth, async (req, res) => {
    try {
        const apps = await registryService.getOrgApps(req.params.orgId);
        res.json({ success: true, result: apps });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load apps.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/apps/{appId}:
 *   get:
 *     summary: One app of one organisation
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: App entry
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/AppEntry'
 *       404:
 *         description: App not found
 */
router.get('/:orgId/apps/:appId', registryAuth, async (req, res) => {
    try {
        const app = await registryService.getOrgApp(req.params.orgId, req.params.appId);
        if (!app) return res.status(404).json({ success: false, message: 'App not found' });
        res.json({ success: true, result: app });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load app.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/apps/{appId}/manifest:
 *   get:
 *     summary: PWA manifest / branding for one app
 *     description: >
 *       Replaces the Vault-backed PWA cache in protected-static-server: name,
 *       short_name and icons come from the registry.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Web app manifest
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 result:
 *                   type: object
 *       404:
 *         description: App or manifest not found
 */
router.get('/:orgId/apps/:appId/manifest', registryAuth, async (req, res) => {
    try {
        const manifest = await registryService.getAppManifest(req.params.orgId, req.params.appId);
        if (!manifest) return res.status(404).json({ success: false, message: 'Manifest not found' });
        res.json({ success: true, result: manifest });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load manifest.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/releases/{appKey}:
 *   get:
 *     summary: Resolve which release an organisation receives
 *     description: >
 *       Applies the release resolution: `OrgReleaseOverride` (canary) first,
 *       then `AppRelease.Current` (the pointer), then nothing. The broker uses
 *       this to pick the S3 artefact; `env.js` uses the `backends` field of the
 *       same answer, which is why frontend and backends cannot drift apart.
 *       Returns 404 when the app has no release at all — that is a real 503
 *       upstream, not an empty artefact.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *         example: UUID-4efce539-bd70-11f1-b929-5e3866f0f490
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Resolved release
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/ResolvedRelease'
 *       404:
 *         description: No release for this app
 */
router.get('/:orgId/releases/:appKey', registryAuth, async (req, res) => {
    try {
        const { orgId, appKey } = req.params;
        const release = await registryService.resolveRelease(appKey, orgId);
        if (!release) return res.status(404).json({ success: false, message: 'No release for this app' });
        res.json({ success: true, result: release });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to resolve release.' });
    }
});

/**
 * @swagger
 * /api/registry/releases/{appKey}:
 *   get:
 *     summary: All releases of one app
 *     description: >
 *       The deploy view: every stored `(appKey, version)` with its prefix,
 *       backends and pointer state. Used by the admin surface that flips the
 *       pointer.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Release list, current first
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: array
 *                   items:
 *                     $ref: './registry/registry.swagger.yaml#/components/schemas/ResolvedRelease'
 */
router.get('/releases/:appKey', registryAuth, async (req, res) => {
    try {
        const releases = await registryService.listAppReleases(req.params.appKey);
        res.json({ success: true, result: releases });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load releases.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/env/{appKey}:
 *   get:
 *     summary: Per-organisation window.env overlay for an app
 *     description: >
 *       Returns the `Backends` of the release this organisation receives, under
 *       exactly the keys the frontend reads (`api`, `apiPortal`, …) — no
 *       renaming, so a new backend name needs no code change here.
 *
 *       This is what makes a backend canary possible: today `env.js` merges the
 *       backend URLs from the deployment's own Vault, so every organisation
 *       served by a container talks to the same backend. `result.env` is applied
 *       on top of those values.
 *
 *       `env: null` is valid and means "release without a backends overlay —
 *       keep the deployment values". Only an unknown app yields 404, which is
 *       what separates "no canary" from "wrong app key".
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *         example: UUID-4efce539-bd70-11f1-b929-5e3866f0f490
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Env overlay (may be null)
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/ReleaseEnv'
 *       404:
 *         description: The app has no release at all
 */
router.get('/:orgId/env/:appKey', registryAuth, async (req, res) => {
    try {
        const { orgId, appKey } = req.params;
        // Eine Auflösung, beide Antworten: `env` kommt aus demselben Release,
        // aus dem auch das Artefakt kommt — genau die Kopplung aus §6.2.
        const resolved = await registryService.resolveReleaseEnv(appKey, orgId);
        if (!resolved) return res.status(404).json({ success: false, message: 'No release for this app' });
        res.json({ success: true, result: { orgId, appKey, ...resolved } });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to resolve env overlay.' });
    }
});

export default router;