// @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;