// @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, errorLoggerUpdate } from '../../utils/requestLogger.js';
import * as registryService from '../orgaSettings/registryService.js';
import { importContract } from './contractImport.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: one lookup by the name the organisation
* chose (`OrgAppDeployment.Version`), or `stable` when it chose none. The
* broker uses this to pick the S3 artefact; `env.js` uses the `backends`
* field of the same answer, which is why frontend and backend choice
* 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: >
* Every stored `(appKey, version)` with its prefix. `version` is a
* **name** the organisation can select (`latest`, `stable`, `5.9.0`).
* tags: [Registry]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: appKey
* required: true
* schema:
* type: string
* example: member.app
* responses:
* 200:
* description: Release list, newest name 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 env.js overlay for an app
* description: >
* Returns the backends of the **branch** this organisation selected
* (`OrgAppDeployment.Branch` → `AppBackendBranch.Backends`), under exactly
* the keys the frontend reads (`api`, `apiPortal`, …) — no renaming, so a
* new backend name needs no code change here.
*
* `env: null` is valid and means "no branch selected — keep the deployment
* values". Only an unknown app yields 404, which is what separates "no
* choice" 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.' });
}
});
// ── Schreiben: Plattform und Vertrag ──────────────────────────────────────────
//
// Der Router war bis hier ausschließlich lesend — und die Schreibfunktionen des
// Service waren repoweit **ohne Aufrufer**. Das war die Lücke aus §6.9: „wo legt
// der Admin fest, welches Backend die App benutzt?" hatte keine Antwort, weil es
// keinen Weg in die Tabellen gab.
//
// Zwei Gates, zwei Zuständigkeiten — und die Trennung ist der Punkt, nicht die
// Bequemlichkeit:
//
// | Weg | Gate | Wer |
// |---|---|---|
// | `/backend-branches/:appKey` | `bot` + `employee` | der Importer (Service-Token) |
// | `/releases` | `employee` + `release` | CommTool-Personal **und** der Release-Job |
// | `/…/deployments/…` (orgaSettings) | `checkAdmin` | der Kunde, **eigene** Org |
//
// Der Kunde schreibt **nicht** hier. Das ist dieselbe Grenze wie beim Lesen
// (Dateikopf): `admin.app` bedient alle Kunden, und ein `db-admin` erreicht über
// diesen Router sonst fremde Mandanten. Sein Weg liegt in `orgaSettings`, wo die
// Organisation aus der Session kommt und nicht aus dem Pfad.
/**
* Der Importer schreibt mit einem **Service-Token** (`bot`) — deshalb steht
* `bot` hier mit drin, obwohl ein Vertragsimport eine Plattform-Sache ist.
* Es ist derselbe Kompromiss wie beim Lesen (Dateikopf): der Konsument ist eine
* Maschine, und das Leck, das geschlossen werden soll, ist der Mandanten-Admin,
* nicht der Service-Account.
*/
const contractAuth = makeAuthCheck(['bot', 'employee']);
/** Plattform-Schreibvorgänge: ausschließlich CommTool-Personal. */
const platformAuth = makeAuthCheck(['employee']);
/**
* Der Release-Job: er legt die Release-Zeilen an — die konkrete Version
* (`5.9.0`) und die Namen `latest`/`stable` — PLAN.md §6.2.
*
* Sein Kennzeichen ist eine **Gruppe** (`release-ci`), kein Mensch-Konto, und
* bewusst **nicht** `app-bot`: sonst dürfte jedes Bot-Token Releases schreiben.
* `employee` bleibt daneben, weil dieselben Routen auch das CommTool-Frontend
* bedient (§2.7).
*/
const releaseAuth = makeAuthCheck(['employee', 'release']);
/**
* @swagger
* /api/registry/backend-branches/{appKey}:
* put:
* summary: Import the backend branch contract for an app
* description: >
* Replaces the branch catalogue of one app — the **offer**, not the
* choices. Called by the importer process that watches the mounted YAML
* file in the broker repository; idempotent, so the same contract may be
* pushed repeatedly.
*
* A branch that disappears from the contract is removed only when no
* organisation still selects it. Such a branch is reported as `kept`
* instead: the import stays clean, but the contract is wrong, and that has
* to be visible rather than silently pull a backend out from under a
* tenant. Backend values are delivered to the browser, so keys that look
* like secrets are rejected here.
* tags: [Registry]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: appKey
* required: true
* schema:
* type: string
* example: member.app
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: './registry/registry.swagger.yaml#/components/schemas/BackendBranchContract'
* example:
* prod:
* api: member
* baseUrl: api.commtool.org/api
* test:
* api: member
* baseUrl: test.api.commtool.org/api
* responses:
* 200:
* description: Import result
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* result:
* $ref: './registry/registry.swagger.yaml#/components/schemas/BackendBranchImportResult'
* 400:
* description: Invalid branch name or backend values
* 500:
* description: Import failed
*/
router.put('/backend-branches/:appKey', contractAuth, async (req, res) => {
try {
const result = await registryService.saveBackendBranches(req.params.appKey, req.body);
res.json({ success: true, result });
} catch (e) {
// Ein ungültiger Vertrag ist ein Fehler des Absenders, kein Serverfehler.
// Der Importer soll die Meldung sehen und nicht auf einen Retry hoffen.
if (typeof e?.message === 'string' && /^Invalid |: /.test(e.message)) {
return res.status(400).json({ success: false, message: e.message });
}
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to import backend branches.' });
}
});
/**
* @swagger
* /api/registry/app-environment/{appKey}:
* put:
* summary: Import the app environment of the contract
* description: >
* Writes the `AppCatalog` row for one app — the flat env.js object that is
* this app's app-level base (`api`, `baseUrl`, `NODE_ENV`, and the display
* defaults `title`, `icon`, `roles`, `domain`). A branch
* (`/backend-branches`) overrides it key by key. This is CommTool's write
* (the contract import), never the org admin's: an organisation's override
* lives on its `ObjectBase` app and is untouched.
* tags: [Registry]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: appKey
* required: true
* schema:
* type: string
* example: member.app
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* example:
* NODE_ENV: production
* title: Mitgliederdatenbank
* roles: [member]
* domain: db
* api: member
* baseUrl: api.commtool.org/api
* responses:
* 200:
* description: App environment saved
* 400:
* description: Invalid app key or environment
* 500:
* description: Import failed
*/
router.put('/app-environment/:appKey', contractAuth, async (req, res) => {
try {
const result = await registryService.saveAppCatalog(req.params.appKey, req.body ?? {});
res.json({ success: true, result });
} catch (e) {
// Wie beim Zweig-Import: ein ungültiger Vertrag ist ein Fehler des
// Absenders. Der Importer soll die Meldung sehen, nicht auf einen Retry
// hoffen.
if (typeof e?.message === 'string' && /^Invalid |must /.test(e.message)) {
return res.status(400).json({ success: false, message: e.message });
}
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to import app environment.' });
}
});
/**
* @swagger
* /api/registry/contract/reload:
* post:
* summary: Re-import the backend contract from the config bucket
* description: >
* Reads `config/contract/backends.yaml` from the config bucket and writes
* `AppCatalog` (app level) and `AppBackendBranch` (branches) — the same
* import that runs at startup. CommTool's write (contract import), never
* the org admin's: an organisation's override on its `ObjectBase` app is
* untouched, and a branch a tenant still selects is kept, not removed.
* tags: [Registry]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Import result
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* result:
* type: object
* 400:
* description: Invalid contract (shape)
* 404:
* description: No contract found in the bucket
* 500:
* description: Import failed
*/
router.post('/contract/reload', contractAuth, async (_req, res) => {
try {
const result = await importContract();
if (!result.ok) {
// Kein Vertrag ist kein Serverfehler — die Auslieferung läuft mit dem
// letzten Katalog weiter. Der Aufrufer soll die Ursache sehen.
return res.status(404).json({ success: false, message: `Contract not imported: ${result.reason}` });
}
res.json({ success: true, result });
} catch (e) {
// Ein formfehlerhafter Vertrag ist ein Fehler des Absenders (400), kein
// Serverfehler — dieselbe Trennung wie beim Zweig- und App-Import.
if (typeof e?.message === 'string'
&& (/^"/.test(e.message) || /not an object|is missing/.test(e.message))) {
return res.status(400).json({ success: false, message: e.message });
}
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to import contract.' });
}
});
/**
* @swagger
* /api/registry/releases/reload:
* post:
* summary: Derive the release list from the artefact bucket
* description: >
* Lists `<produkt>/<version>/…` in the artefact bucket
* (`commtool-apps`) and writes one `AppRelease` row per version — keyed
* by **product**, because an artefact serves every environment of that
* product. Removes version rows whose folder is gone; channel rows
* (`latest`, `stable`) stay, they are resolution rules. The bucket is
* enumerated, so a new upload needs no second place to maintain.
* tags: [Registry]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Import summary (products, upserted, removed)
* 404:
* description: Artefact bucket not readable
* 500:
* description: Import failed
*/
router.post('/releases/reload', contractAuth, async (_req, res) => {
try {
const { importReleases } = await import('./releaseImport.js');
const result = await importReleases();
if (!result.ok) {
return res.status(404).json({ success: false, message: `Releases not imported: ${result.reason}` });
}
res.json({ success: true, result });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to import releases.' });
}
});
/**
* @swagger
* /api/registry/backend-branches/{appKey}:
* get:
* summary: The backend branch catalogue of an app
* description: >
* The offer including the backend URLs. Restricted to CommTool staff
* (`employee`) — unlike the customer-facing projection in
* `orgaSettings/deployments`, which lists names only.
* tags: [Registry]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: appKey
* required: true
* schema:
* type: string
* example: member.app
* responses:
* 200:
* description: Branch list
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* result:
* type: array
* items:
* $ref: './registry/registry.swagger.yaml#/components/schemas/BackendBranch'
* 500:
* description: Failed to load branches
*/
router.get('/backend-branches/:appKey', platformAuth, async (req, res) => {
try {
const branches = await registryService.listBackendBranches(req.params.appKey);
res.json({ success: true, result: branches });
} catch (e) {
errorLoggerRead(e);
res.status(500).json({ success: false, message: 'Failed to load backend branches.' });
}
});
/**
* @swagger
* /api/registry/releases:
* post:
* summary: Create or update a release
* description: >
* Writes one `(appKey, version)` row with its S3 prefix. `version` is a
* **name**: `latest`, `stable` or a concrete version (`5.9.0`). There is
* no pointer — an organisation without its own choice follows `stable`.
*
* This is CommTool's write — the org admin only ever chooses from what
* exists here. It is also the release job's write (`release-ci`, §6.2):
* the job uploads the artefact, then writes the concrete version and the
* name `latest` (or `stable` on promotion).
* tags: [Registry]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: './registry/registry.swagger.yaml#/components/schemas/ReleaseInput'
* example:
* appKey: member.app
* version: 5.9.0
* prefix: member/5.9.0/
* responses:
* 200:
* description: Release saved
* 400:
* description: Missing appKey, version or prefix
* 500:
* description: Failed to save release
*/
router.post('/releases', releaseAuth, async (req, res) => {
try {
const { appKey, version, prefix } = req.body ?? {};
if (!appKey || !version || !prefix) {
return res.status(400).json({ success: false, message: 'appKey, version and prefix are required.' });
}
await registryService.saveAppRelease({ appKey, version, prefix });
res.json({ success: true });
} catch (e) {
errorLoggerUpdate(e);
res.status(500).json({ success: false, message: 'Failed to save release.' });
}
});
export default router;