A role-aware IT service launcher for universities. Each role — students, staff, whatever the institution's IdP distinguishes — lands on a dashboard pre-arranged for their kind of work — they can search the full service catalog, pin favorites, and stay informed through announcements. Admins curate the catalog and default views through a form UI or an MCP server.
Built as a Go modular monolith with an embedded React/TypeScript SPA, PostgreSQL, and generic OIDC authentication. Designed to be reused: all branding, OIDC claim mapping, and the product name are runtime config — no institution is hardcoded.
The primary dev loop runs without Docker. You'll need Go 1.26+, Node 24+, and Podman (or Docker — just swap podman for docker in the Makefile).
# 1. Copy the environment template and fill in any secrets
cp .env.example .env
# 2. Start a local Postgres 17 container
make db
# 3. Start the mock OIDC identity provider (needed for login)
make idp
# 4. Apply all migrations
make migrate
# 5. (Optional) seed the catalog with example services
make seed
# 6. Install frontend dependencies
make web-installThen run the Go server and the Vite dev server in two separate terminals:
# Terminal A — Go API on :8080
make run
# Terminal B — Vite on :5173, proxying /api/* to :8080
make web-devOpen http://localhost:5173 in your browser. The mock IdP lets you log in with any username; set is_admin: true in dev/mock-oidc-config.json to test admin features.
Note: after any schema change (
migrations/gets a new file), runmake migratebefore restarting the server.
Beta services need no configuration: the seed's "Zettelkasten Labor" is tagged beta, so it is
hidden until you switch on Beta-Dienste anzeigen in the account menu. To try a restricted
category locally, start the server with the e2e config, which configures one visibility group:
CONFIG_FILE=dev/config.e2e.yaml make run. The seed's "IT-Infrastruktur" category is restricted to
that group, and the mock IdP grants nobody — so it and its service stay invisible to every user,
while the admin screens still manage both. Point match: at dashboard-admins (which the mock IdP
does emit) to see the holder's side.
Layout correctness on real phone widths is a gate, not a polish pass (CLAUDE.md → "Responsive & viewport discipline"). The Playwright suite drives the embedded binary — the artifact we ship — against the local Postgres and mock IdP, and runs every covered screen at six fixed resolutions (324×756, 360×800, 390×844, 768×1024, 1280×720, 1920×1080), failing on horizontal overflow, text under 12px and phone touch targets under 44px.
make e2e-install # once: download the Chromium build Playwright drives
make db idp seed # the suite needs the same stack as `make run`
make e2e # build the SPA + binary, then run the whole matrix
make e2e-ui # the same suite in Playwright's interactive UI modeA single project or spec:
cd web-ui
npx playwright test --project=mobile-324
npx playwright test e2e/dashboard.spec.ts
npx playwright show-reportBoth suites take the dev database's user-side state. The e2e run truncates
users(cascade) andannouncementsfirst, andgo test ./internal/service/clearsannouncements. Both are deliberate: the announcements table and the shared test user are whole-table state the tests assert over, so a leftover row silently changes the answer (issue #234). The catalog is never touched. If you are keeping an announcement or a favorite in the dev database by hand, expect to lose it —make seedrestores the catalog, and the next login re-seeds the role-default favorites.
The specs live in web-ui/e2e/; docs/specs/responsive-viewport-testing.md is
the design, including what the shared assertions check and how to add a screen.
Specs named issue-<n>-*.spec.ts reproduce an open layout bug and are annotated
test.fixme with a link to it — the PR that fixes the bug removes the annotation.
make test Run Go tests with the race detector
make lint Run golangci-lint
make web-check Frontend typecheck + lint + tests
make check Run the full local gate (Go + frontend combined)
make e2e Playwright viewport suite (see above)
make migrate-down Roll back the last migration
make sqlc Regenerate type-safe queries from SQL (after editing *.sql)
make build Build a single binary with the SPA embedded → bin/server
make mcp Build the admin MCP server → bin/mcp
make clean Remove build artifacts
CLAUDE.md forbids AI-tool attribution in commit messages and pull request
text. A CI job (.github/workflows/attribution.yml) enforces it on every pull
request, checking every commit message in the PR range plus the PR title and
body, and failing with the offending line and how to fix it.
This is about authorship, not secrecy. AI assistance is not hidden here — it
just does not get recorded as a contributor. A commit's author and co-authors
are the people accountable for the change, and a tool is not one of them.
Saying in prose that something was AI-assisted is fine and passes the check;
what it rejects is authorship metadata (Co-Authored-By: trailers,
session-URL trailers, tool email addresses) and the generated credit line.
The repo also ships a versioned commit-msg hook running the same check, so
the failure arrives before the push instead of after it. It is opt-in — enable
it once per clone:
git config core.hooksPath .githooks
Both call scripts/check-attribution.sh, so there is one pattern list. CI
stays the real gate: hooks are opt-in and --no-verify bypasses them.
One narrowness worth knowing: the check rejects any line starting with a
claude-<word>: trailer key, so a commit subject cannot begin that way. The
repo uses conventional-commit types (feat, fix, chore, docs, lint),
so nothing legitimate does.
The Dockerfile builds the SPA (Node) and embeds it — along with the SQL migrations — into a static Go binary, producing one small distroless image (~20 MB) that runs as a non-root user with a read-only root filesystem. On every push to main (and every v* tag) CI publishes it to GHCR at ghcr.io/<owner>/<repo>. Before publishing, CI scans the built image with Trivy and refuses to push if it finds a CRITICAL or HIGH vulnerability that has a released fix; the image it pushes is the exact image it scanned. A weekly workflow (security-scan.yml, also runnable on demand) rescans the published :latest, because a CVE disclosed after release never triggers a build. Both report to the repository's Security tab (code scanning).
The app applies forward-only migrations itself on startup (advisory-locked via goose, so rolling-deploy replicas don't race; a no-op when the schema is already current). There's no separate migration image or step — set AUTO_MIGRATE=false to opt out and run the goose CLI yourself.
Two compose files ship: compose.yaml builds from source (staging / a quick end-to-end run), and compose.prod.yaml pulls the released image (production). Both put Caddy in front as the only exposed surface, keep Postgres on an internal-only network, and harden every service (no-new-privileges, cap_drop: ALL, read-only roots). Caddy waits on the app's /readyz healthcheck before accepting traffic.
No source tree or build toolchain on the host. Point it at the published image and a released version, then bring it up:
export IMAGE_REPO=ghcr.io/<owner>/<repo> # e.g. ghcr.io/virtuos/wolke
export WOLKE_VERSION=1.4.0 # a released tag, or a @sha256 digest
docker compose -f compose.prod.yaml up -d # or: podman-compose -f compose.prod.yaml up -dOn startup the app waits for Postgres, applies any pending migrations, then serves.
Everything fails closed — these must be set (in a .env beside the file, or the environment) or compose up aborts: IMAGE_REPO, WOLKE_VERSION, POSTGRES_PASSWORD, SESSION_SECRET, PUBLIC_URL, OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET. Before the first deploy, put the real hostname in the Caddyfile (replace localhost) and provide a config.yaml next to the compose file.
POSTGRES_PASSWORD=$(openssl rand -base64 24) SESSION_SECRET=$(openssl rand -base64 32) \
docker compose up -d --buildCaddy serves HTTPS on :443 (its internal CA for localhost; real certificates for a public hostname). Rootless podman can't bind <1024 — set CADDY_HTTPS_PORT=8443 then. Bring up the mock IdP for a full login flow with --profile idp.
Branding and OIDC claim mapping live in config.yaml (copy from config.example.yaml). This is the one file you edit to reskin for a different institution — colors, logo paths, product name, and which OIDC claim maps to which role. The roles themselves come from that mapping: the distinct slugs in oidc.role.values ∪ precedence ∪ {default} are the role set (optional labels: give them display names), so a deployment whose IdP only tells students from employees configures two roles and sees exactly two everywhere — admin editors, default views, announcement audiences. Slugs are [a-z0-9-]{1,32} and all is reserved; more than five roles works but logs a startup warning, since the per-role admin screens are built for a handful. Supplying the role: block in your config file replaces the built-in one whole — set all of it (claim, values, precedence, default, optional labels), not just the keys you want to change. OIDC is provider-agnostic (Keycloak, Authentik, Zitadel, Entra, …); for a step-by-step Keycloak setup see docs/oidc-keycloak.md. wolke also implements OIDC back-channel logout — logging out at the IdP ends the wolke session too. Register PUBLIC_URL + /auth/backchannel-logout as the client's back-channel logout URL at your IdP (with "session required" enabled where offered); that registration is the only knob, the endpoint is always on. TRUSTED_PROXIES must cover the proxy's network so X-Forwarded-For is trusted (it's preset to the compose edge subnet).
Service visibility covers two separate things. Beta services are just the beta tag: a
tagged service is badged Beta and hidden until the user switches on "Beta-Dienste anzeigen" in
the account menu (behind a warning that they may vanish and their data is not migrated). Nothing to
configure. Restricted categories are the third block in config.yaml: a list of visibility
groups (visibility:), each granted by an IdP claim — the same shape as the admin mapping,
nested paths included — and re-derived at every login. An admin restricts a category to a group
in the category editor, and that restricts every service in it; everyone else never sees the
category or its services — not in the catalog, search, favorites, or the public catalog MCP server.
A service in several categories needs all of their groups held. Leaving the list out keeps every
category public. Slugs follow the role rules and may not collide with a role; each entry needs
claim + match. See config.example.yaml, docs/specs/service-visibility.md and the runbook
below.
Optional links are all branding config and all hidden when empty: imprint_url /
privacy_url / feedback_url in the footer, bot_url / help_url as top-bar quick actions, and
news_url — a link to the institution's news site at the foot of the notification panel behind the
bell. Each has an env override (FEEDBACK_URL, BOT_URL, HELP_URL, NEWS_URL). feedback_url
also takes an email/mailto: and help_url a phone number/tel:; news_url is http(s) only — a
news site is a website, so anything else fails at startup rather than rendering a dead link.
The feedback link can also be renamed: branding.feedback_label is a localized {de, en} map
(file-only — a map has no env override) that replaces the built-in "Feedback" for a deployment
pointing the link at a ticket system or a help desk. Empty is the default and keeps today's label.
Filling one language only is legitimate: a reader of the other gets the language that is filled,
not the built-in label. The label does not enable the link — with feedback_url empty nothing
renders. The top bar's bot_url / help_url keep their built-in labels for now; nobody has asked
for those to be renameable, and feedback_label is the precedent to follow when someone does.
Typography is configurable, fonts are not mountable. branding.fonts.body and
branding.fonts.display select the family for each role at runtime (they become the
--font-body / --font-display CSS variables), so a deployment can switch to a system stack
like system-ui, sans-serif, or re-face headings alone, by editing config.yaml and
restarting. What you cannot do is supply a font file: the faces are npm dependencies bundled
into the SPA at build time, and unlike the logo, favicon and watermark there is no mount for
one. Deploying your own licensed corporate face therefore means forking, adding the package and
rebuilding the image — the single place where "re-skin by editing one file" does not hold. Every
stack must end in a generic family (sans-serif, system-ui, …) or the server refuses to start,
which is what keeps a face the browser cannot load degrading to a system font rather than to the
browser default. The bundled face is Hanken Grotesk Variable; see docs/03 §3.
Branding assets are plain files: mount a directory over the bundled branding/
(compose: - ./branding:/branding:ro,z). The mount replaces the bundled set
wholesale — provide all seven files: logo-light.svg, logo-dark.svg,
favicon.svg, icon-192.png, icon-512.png, icon-maskable-512.png,
apple-touch-icon.png (a missing file 404s; there is no per-file fallback, and the
manifest still advertises the icons, so the PWA install silently degrades). The
logos and favicon are referenced from config.yaml (branding.logo_light etc.);
the PNGs serve the PWA manifest and the apple-touch link. An optional eighth file,
watermark.svg, adds the decorative institution mark in the launcher background:
it is off unless you both provide the file and set branding.watermark: /branding/watermark.svg — with no value the app renders no such element at all,
which is what stops a missing or unreachable file leaving an unmasked block on the
page. The value must be a same-origin path: the content security policy allows
img-src 'self', so a mark hosted elsewhere would be blocked by the browser, and
the server refuses an absolute URL at startup rather than let that fail silently. It is used as a CSS mask, so only its alpha matters: ship a single-colour
silhouette on a transparent background and the app tints it with the accent
theme token at 7%. It is drawn as a full-height backdrop — scaled to the viewport
height, cropped by the canvas edges, behind the opaque desktop tiles and through
the transparent phone list rows — so a portrait mark with a fair amount of
open space (an outline rather than a solid block) works best. These eight names are served publicly (unauthenticated, at
/branding/) — only they are; any other filename in the directory, and
/branding/ itself, 404 rather than being listed or served. Keep only these assets in it anyway, never drafts, working files, or
secrets — the allowlist is a floor, not a reason to get careless with the mount.
PWA updates reach open clients. The app is an installable PWA whose service worker updates in prompt mode: a deploy never reloads anyone's tab by surprise, but every running client — a long-lived desktop tab or the installed app on a phone — checks for a new version hourly and on every resume, then offers an in-app "Neue Version verfügbar. / Reload" notice. So a fix ships to open clients within an hour, or on the next app resume, whichever comes first. See docs/02 §11.1.
Migrations are forward-only (goose) and applied by the app on startup (see above). Rolling back requires an explicit make migrate-down in dev.
The catalog (services, categories, role defaults) and favorites are the
irreplaceable data — everything else regenerates (users from OIDC on next
login) or is disposable (sessions, analytics). compose.prod.yaml ships an
opt-in backup service that keeps them safe: a pg_dump -Fc of the wolke
database, pushed to a restic repository (an S3 bucket in the tested setup),
followed by restic forget --prune. restic encrypts client-side and
de-duplicates, so a daily full dump costs little more than the delta.
It is deliberately boring: one pinned image (postgres:17-alpine, whose
pg_dump therefore always matches the server's major version) running
deploy/backup/backup.sh — a POSIX shell loop. No
cron daemon, no scheduler, no second datastore.
Nothing to enable by default: without --profile backup the service does not
exist and the stack runs exactly as before. To turn it on, set the variables in
your .env (the annotated block is at the bottom of
.env.example) and add the profile:
# .env
RESTIC_REPOSITORY=s3:https://s3.example.org/wolke-backups
RESTIC_PASSWORD=$(openssl rand -base64 32) # store this OFF the server
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...docker compose -f compose.prod.yaml --profile backup up -d
docker compose -f compose.prod.yaml --profile backup logs -f backupThe repository is created on first run (BACKUP_INIT_REPO=false to do it
yourself). Nothing institution-specific is committed — every value above is
env, and the bucket, endpoint and credentials are yours.
| Variable | Default | Meaning |
|---|---|---|
RESTIC_REPOSITORY |
— | restic repository URL, e.g. s3:https://host/bucket. Required |
RESTIC_PASSWORD |
— | Encrypts the repository. Required. Losing it loses every backup |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
— | Required for s3: repositories |
AWS_DEFAULT_REGION |
us-east-1 |
Region; MinIO and most S3-compatibles ignore it |
BACKUP_INTERVAL_SECONDS |
86400 |
Seconds between backups (daily) |
BACKUP_RUN_ON_START |
true |
Back up immediately at container start |
BACKUP_KEEP_DAILY |
7 |
restic forget --keep-daily |
BACKUP_KEEP_WEEKLY |
4 |
restic forget --keep-weekly |
BACKUP_KEEP_MONTHLY |
6 |
restic forget --keep-monthly |
BACKUP_TAG |
wolke-db |
Snapshot tag; also scopes the retention policy |
BACKUP_INIT_REPO |
true |
Create the repository on first run |
BACKUP_PROBE_TIMEOUT |
45 |
Seconds before the repository is called unreachable |
BACKUP_RESTIC_VERSION |
— | Pin the restic Alpine package, e.g. 0.18.1-r7 |
PGHOST/PGUSER/PGDATABASE are fixed to the stack's postgres service and
PGPASSWORD reuses POSTGRES_PASSWORD; there is nothing to set for those.
A backup job that silently stops is worse than none. Every failure path logs at
ERROR and exits non-zero, so the container crash-loops under
restart: unless-stopped and shows up in docker compose ps and your log
alerting. The distinguishable cases, each with its own message:
| Situation | Logged as |
|---|---|
| Enabled but unconfigured | missing required environment: … — refuses to start |
| S3 endpoint unreachable | cannot reach the backup repository: … |
| Wrong S3 credentials | the backup repository rejected our credentials: … |
Wrong RESTIC_PASSWORD |
RESTIC_PASSWORD does not open this repository — refuses rather than start a second repo |
| Postgres unreachable / wrong password | pg_dump failed — is postgres reachable … |
pg_dump produced an empty file |
pg_dump produced an empty file |
A cycle only logs backup cycle ok when pg_dump, restic backup and
restic forget --prune all succeeded.
Because the backup service is enabled by a profile, Compose cannot fail closed on its variables the way it does for
SESSION_SECRETand friends — it interpolates the file before it applies profiles, so a:?default would break stacks that never enable backups. The check therefore lives inbackup.sh, which refuses to start when the configuration is incomplete.
The service joins backend (to reach Postgres) and a dedicated egress
network for the S3 endpoint. It deliberately does not join edge: that
subnet is what the app trusts for X-Forwarded-* headers, and a backup job has
no business being in it. Postgres itself stays internal-only, as before.
restic is installed from the Alpine community repository at container start
rather than baked into a second published image — so the container needs
outbound access to the Alpine mirrors at start-up as well as to your S3
endpoint. If it can't reach them it says so and exits (could not install restic); pin BACKUP_RESTIC_VERSION if you need a reproducible version.
See the runbook: Restore PostgreSQL from backup. Backups you have never restored are a hypothesis, not a backup — rehearse it.
The app exposes Prometheus metrics at GET /metrics on its own listener (:8080), in the standard Prometheus text format. The endpoint is mounted only when the metrics collector is wired (the default for the server binary).
All series are prefixed wolke_ (never an institution name), and labels are aggregate only — never a user identifier:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
wolke_http_request_duration_seconds |
histogram | route, method, code |
Request latency. route is the matched chi pattern, not the raw path, so cardinality stays bounded |
wolke_service_clicks_total |
counter | service, role, target (service / documentation) |
Clicks per service and role, split by whether the launch link or the documentation link was followed (the usage-by-role signal) |
wolke_active_sessions |
gauge | role |
Currently valid server-side sessions, per role. The total is sum(max by (role) (…)) — see docs/02 §7; a bare max gives the largest single role |
wolke_catalog_services |
gauge | state (active / inactive) |
Catalog size by state |
wolke_announcements_active |
gauge | severity |
In-window announcements by severity |
wolke_service_favorites |
gauge | service |
Users currently having each active service favorited (zeros included, so a service nobody pinned still reports 0) |
wolke_service_favorites_added_total |
counter | service, role |
Favorites users starred themselves. Never counts the role defaults seeded at first login, so added minus removed is the delta to the pre-configured set |
wolke_service_favorites_removed_total |
counter | service, role |
Favorites users un-starred themselves — including a seeded default they dropped, which is a user choice |
The histogram and the three counters update in-process per request; the four gauges are refreshed from the database every 30s by a background worker. Metrics live on a private registry, so the endpoint exposes only these series — no default Go/process collectors.
Prometheus scrapes the app directly on the internal network (e.g. app:8080/metrics inside the Compose/cluster network); Grafana visualises it (a starter dashboard lives in deploy/grafana/).
/metrics must never be publicly reachable — and protection is by topology, not the application:
- Caddy 404s it at the edge. The public vhost returns 404 for
/metrics(seeCaddyfile), so it is invisible from the internet. - The app port is internal-only.
:8080is not published to the host; only Caddy and Prometheus reach it, over the internal network.
In this setup you do not need an application-level secret — protecting at the reverse proxy + network boundary is the intended model. So METRICS_TOKEN is optional:
- Unset (default):
/metricsserves with no auth. Correct when the scrape path stays inside a trusted network. - Set: the endpoint additionally requires
Authorization: Bearer <token>(constant-time compared). Reach for this only when the scrape path crosses a boundary you don't fully control — a shared/multi-tenant network, or cross-host scraping without mTLS.
The load-bearing invariant is that the app's
:8080isn't reachable by untrusted parties. The Caddy 404 only covers the public vhost — it does not protect a direct hit on the app port. So if you ever publish:8080to the host or a shared LAN, setMETRICS_TOKEN(or put mTLS in front of the scrape).
All admin endpoints require an authenticated session (login via OIDC) belonging to an admin user. Regular catalog reads are available to any authenticated user.
| Method | Path | Description |
|---|---|---|
GET |
/api/me |
Current user profile and preferences |
GET |
/api/catalog |
All active services and categories |
GET |
/api/catalog/defaults |
Role-ordered default view for the current user |
GET |
/api/search?q=… |
Full-text search across the catalog |
GET |
/api/favorites |
Current user's favorited services |
POST |
/api/favorites/items |
Add a favorite { "service_id": "…" } |
DELETE |
/api/favorites/items |
Remove a favorite { "service_id": "…" } |
GET |
/api/announcements |
Active announcements for the current user |
PATCH |
/api/me/prefs |
Update preferences (theme, view mode, …) |
POST |
/api/events/click |
Record a service launch (fire-and-forget) |
Writes happen immediately — no staging step. Every write is audit-logged with actor_kind = "form".
Services
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/services |
List all services including inactive |
POST |
/api/admin/services |
Create a service |
PATCH |
/api/admin/services/{id} |
Update a service |
DELETE |
/api/admin/services/{id} |
Soft-delete a service |
Service body (create/update):
{
"name": "VPN",
"description": {
"de": "Sicherer Zugang zum Hochschulnetz.",
"en": "Secure access to the university network."
},
"service_url": "https://vpn.example.edu",
"doc_url": "https://docs.example.edu/vpn",
"icon": "shield",
"categories": ["netzwerk"],
"tag": "wartung"
}tag is optional — "beta" shows a blue badge, "wartung" shows an amber badge on the tile. Omit or set to "" for no badge.
Categories, announcements, role defaults
| Method | Path | Description |
|---|---|---|
POST |
/api/admin/categories |
Create a category |
GET |
/api/admin/announcements |
List all announcements |
POST |
/api/admin/announcements |
Create an announcement |
PATCH |
/api/admin/announcements/{id} |
Update an announcement |
GET |
/api/admin/role-defaults/{role} |
Get default service order for a role |
PUT |
/api/admin/role-defaults/{role} |
Replace default order { "service_ids": ["…"] } |
GET |
/api/admin/audit |
Audit log (last 100 entries; ?limit=N up to 500) |
The MCP server gives Claude (or any MCP client) access to the same admin operations as the HTTP API, with one important difference: writes are staged. A propose_* call validates the change and returns a preview — no data is written. Only change.confirm with the returned token actually commits. Tokens expire after 10 minutes and are single-use.
Every confirmed write is audit-logged with actor_kind = "mcp", distinct from form writes.
The admin user must have logged into the web UI at least once so their record exists in the database.
make mcp # builds bin/mcpSet these environment variables when launching the binary:
DATABASE_URL postgres connection string (same as the app)
MCP_ADMIN_SUB OIDC subject of the admin user (find it in the audit log or DB)
CONFIG_FILE optional path to config.yaml
For Claude Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"wolke-admin": {
"command": "/path/to/bin/mcp",
"env": {
"DATABASE_URL": "postgres://wolke:…@localhost:5432/wolke?sslmode=disable",
"MCP_ADMIN_SUB": "the-admin-oidc-sub"
}
}
}
}Reads (no staging required):
| Tool | Description |
|---|---|
service.list |
List all services including inactive |
service.get |
Get one service by ID |
category.list |
List all categories, with the visibility group restricting each |
Propose (validates and stages — no write, returns a change_token and before/after preview):
| Tool | Description |
|---|---|
propose_create |
Stage a new service |
propose_update |
Stage an edit to an existing service |
propose_delete |
Stage a soft-delete |
visibility.list |
The configured visibility groups a category may be restricted to (read-only; a service is restricted by its categories, see category.list) |
Commit or discard:
| Tool | Description |
|---|---|
change.confirm |
Execute the staged change (consumes the token) |
change.discard |
Abandon the staged change |
A second, read-only MCP server that any university member can run — no admin rights, no user identity required. It exposes the same public catalog the web UI shows: which services exist, which are in maintenance or beta, where their documentation lives, plus search and active announcements. It has no write path at all (enforced at the package level), and never returns soft-deleted services — nor restricted ones: with no identity it reads the catalog as a holder of nothing that cannot ask for beta, so neither a service in a restricted category nor a beta one is ever served here.
It needs only a database connection — set DATABASE_URL.
make catalog-mcp # builds bin/catalog-mcpFor Claude Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"wolke-catalog": {
"command": "/path/to/bin/catalog-mcp",
"env": {
"DATABASE_URL": "postgres://wolke:…@localhost:5432/wolke?sslmode=disable"
}
}
}
}For defense-in-depth, point
DATABASE_URLat a Postgres role with onlySELECTgrants — the server only ever reads.
| Tool | Description |
|---|---|
service.list |
Active services; optional category (slug) and status (beta/wartung) filters |
service.get |
One active service by ID, with its documentation links and status |
service.search |
Fuzzy search by name, description, or category |
service.list_in_maintenance |
Active services currently tagged wartung |
category.list |
The catalog categories |
announcements.list |
Active announcements across all audiences (maintenance windows, outages) |
Step-by-step runbooks for common operator tasks, grounded in what the code actually does (not just what the spec aspires to):
- Post / retire an outage announcement
- Add / edit / remove a catalog service (form + MCP)
- Restore PostgreSQL from backup (see also Backups for the scheduled job)
- Revoke a compromised admin
- Grant / revoke access to a restricted service group
Apache 2.0