diff --git a/internal/catalog/testdata/snapshot.json b/internal/catalog/testdata/snapshot.json index e1c616b..55d04a7 100644 --- a/internal/catalog/testdata/snapshot.json +++ b/internal/catalog/testdata/snapshot.json @@ -1,9 +1,9 @@ { "schema_version": 1, - "generated_at": "2026-08-10T19:40:20Z", - "store_ref": "af7f954297ff4cf6ee331b2a1c56d57dd1e331a7", + "generated_at": "2026-08-14T20:00:41Z", + "store_ref": "c95b610969e24af3fd8c9319512fabfaf17b76e4", "os_capabilities_version": 1, - "index_sha256": "9a99227813183b2d06f78c178af28774f14830b0784ff57dcd2c3b46ad4ad38f", + "index_sha256": "f3cb3073d3fbb4e22c69735c48dc2c440f0347ceb8809d7a147069b19b64c04a", "apps": [ { "id": "actual-budget", @@ -796,12 +796,86 @@ } } }, + { + "id": "laminar", + "name": "Laminar", + "version": "0.1.13", + "short_description": "Open-source tracing for AI agents: see every step, prompt, and tool call your agent made.", + "long_description": "## Overview\nLaminar records what your AI agent actually did. Your code sends a trace of\nevery model call and every tool call, and Laminar shows you the run as a\ntree you can open and read: the prompts, the answers, how long each step\ntook, and where it went wrong. It speaks OpenTelemetry, so it collects from\nthe same instrumentation the rest of your stack uses.\n\n## Features\n- Full traces of agent runs, nested step by step, with search over them\n- Dashboards over your span data, and a SQL editor for your own questions\n- Datasets built from real traces, so your test cases come from real runs\n- Evaluations you can run over a dataset and compare between versions\n- Labels and human review queues for scoring runs by hand\n- Works with the OpenTelemetry standard, and with the Python and JS SDKs\n- malmo provisions and manages the database it needs, so there is nothing\n to wire up\n\n## Setting up\nThe first account you register after install becomes the owner, and it\ncreates the first workspace and project. Open the project, then Settings, to\nget the project API key your application sends traces with.\n\nPoint your SDK at this app's address and **turn on HTTP export**. The\nLaminar SDK sends spans over gRPC by default, and gRPC cannot reach the app\nthrough this box. In the JS SDK pass `forceHttp: true`; anywhere else set\n`OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`. Without it the SDK reports no\nerror and no trace ever arrives.\n\n## Good to know\n- **Apps running somewhere else can send traces, and the app stays private.**\n malmo keeps this app owner-only, so the pages are behind the box login. The\n tracing API is the exception: it answers without it, and the project API\n keys you mint are what guards it.\n- **Anyone who can open the pages can sign in as anyone.** Laminar's\n self-hosted sign-in accepts any email address with no password. The box\n login in front of the app is the real protection here, so think twice\n before you make the whole app public from its page.\n- **Images and very large steps are not kept.** A span carrying an image, a\n document, or more than about half a megabyte of text needs an object store,\n which this app does not include. The step still appears in the trace, but\n that content is dropped rather than saved. Ordinary text steps, which is\n nearly all of them, are stored in full.\n- **The browser agent is not included.** Laminar can drive a web browser for\n you, but that runs as its own separate service, so it is not part of this\n app. Tracing, dashboards, datasets, and evaluations are unaffected.\n- **Evaluations and chat need your own model provider key.** You paste it\n into the app, and the provider bills you directly. Tracing, dashboards, and\n datasets use no model and cost nothing.\n- **Email invitations need your own email provider key.** Add it in this\n app's settings on this box. Without it an invitation is still recorded, it\n is just not emailed, and you can add people by having them sign in once and\n then granting them access.\n- **This is a heavy app.** It runs a web tier, a Rust ingestion server, and a\n ClickHouse analytics database, alongside malmo's managed database. Trace\n history grows with use.\n", + "categories": [ + "ai", + "developer-tools" + ], + "author": { + "name": "Laminar", + "url": "https://laminar.sh" + }, + "license": "Apache-2.0", + "links": { + "homepage": "https://laminar.sh", + "source": "https://github.com/lmnr-ai/lmnr", + "support": "https://laminar.sh/docs" + }, + "changelog_url": "https://github.com/lmnr-ai/lmnr/releases", + "footprint": { + "image_download_bytes": 402448527, + "image_disk_bytes": 1281856512, + "estimated_state": "12MB" + }, + "external_costs": [ + { + "id": "model-access", + "title": "Model access", + "description": "Laminar's evaluations and its chat over a trace answer through a model provider you choose, using your own API key, and that provider bills you directly. You paste the key into the app. Providers charge by token, which is roughly three quarters of a word. Tracing, dashboards, and datasets do not use a model and cost nothing.\n", + "required": false, + "estimate": "$1 to $5 per million tokens in, $5 to $30 out (small models cost far less)", + "estimate_checked": "2026-08-10" + }, + { + "id": "email-sending", + "title": "Sending email", + "description": "malmo does not send this app's email for you. Workspace invitations go out through an email provider you sign up with, and you pay that provider directly. Everything else in the app works without one.\n", + "required": false, + "estimate": "free for most use ($0.40 to $1.50 per 1000 emails above the free tier)", + "estimate_checked": "2026-08-10" + } + ], + "icon_file": "icon.png", + "environments": [ + "appliance", + "hosted" + ], + "manifest": "id: laminar\nmanifest_version: 1\nname: Laminar\nversion: \"0.1.13\"\ndescription:\n short: \"Open-source tracing for AI agents: see every step, prompt, and tool call your agent made.\"\n long: |\n ## Overview\n Laminar records what your AI agent actually did. Your code sends a trace of\n every model call and every tool call, and Laminar shows you the run as a\n tree you can open and read: the prompts, the answers, how long each step\n took, and where it went wrong. It speaks OpenTelemetry, so it collects from\n the same instrumentation the rest of your stack uses.\n\n ## Features\n - Full traces of agent runs, nested step by step, with search over them\n - Dashboards over your span data, and a SQL editor for your own questions\n - Datasets built from real traces, so your test cases come from real runs\n - Evaluations you can run over a dataset and compare between versions\n - Labels and human review queues for scoring runs by hand\n - Works with the OpenTelemetry standard, and with the Python and JS SDKs\n - malmo provisions and manages the database it needs, so there is nothing\n to wire up\n\n ## Setting up\n The first account you register after install becomes the owner, and it\n creates the first workspace and project. Open the project, then Settings, to\n get the project API key your application sends traces with.\n\n Point your SDK at this app's address and **turn on HTTP export**. The\n Laminar SDK sends spans over gRPC by default, and gRPC cannot reach the app\n through this box. In the JS SDK pass `forceHttp: true`; anywhere else set\n `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`. Without it the SDK reports no\n error and no trace ever arrives.\n\n ## Good to know\n - **Apps running somewhere else can send traces, and the app stays private.**\n malmo keeps this app owner-only, so the pages are behind the box login. The\n tracing API is the exception: it answers without it, and the project API\n keys you mint are what guards it.\n - **Anyone who can open the pages can sign in as anyone.** Laminar's\n self-hosted sign-in accepts any email address with no password. The box\n login in front of the app is the real protection here, so think twice\n before you make the whole app public from its page.\n - **Images and very large steps are not kept.** A span carrying an image, a\n document, or more than about half a megabyte of text needs an object store,\n which this app does not include. The step still appears in the trace, but\n that content is dropped rather than saved. Ordinary text steps, which is\n nearly all of them, are stored in full.\n - **The browser agent is not included.** Laminar can drive a web browser for\n you, but that runs as its own separate service, so it is not part of this\n app. Tracing, dashboards, datasets, and evaluations are unaffected.\n - **Evaluations and chat need your own model provider key.** You paste it\n into the app, and the provider bills you directly. Tracing, dashboards, and\n datasets use no model and cost nothing.\n - **Email invitations need your own email provider key.** Add it in this\n app's settings on this box. Without it an invitation is still recorded, it\n is just not emailed, and you can add people by having them sign in once and\n then granting them access.\n - **This is a heavy app.** It runs a web tier, a Rust ingestion server, and a\n ClickHouse analytics database, alongside malmo's managed database. Trace\n history grows with use.\nicon: ./icon.png\ncategories: [ai, developer-tools]\nauthor:\n name: Laminar\n url: https://laminar.sh\n# Apache-2.0 (LICENSE.md), plain, with no open-core carve-out and no commercial-use bar,\n# so neither tier is gated. The bundled ClickHouse sidecar is Apache-2.0 too, shipped\n# unmodified from the published upstream image. See notes.md.\nlicense: Apache-2.0\nlinks:\n homepage: https://laminar.sh\n source: https://github.com/lmnr-ai/lmnr\n support: https://laminar.sh/docs\nchangelog_url: https://github.com/lmnr-ai/lmnr/releases\n\ncompose_file: compose.yml\n# The Caddy proxy, not the frontend: ingestion lives on app-server and the UI on the\n# frontend, and malmo routes exactly one service. The proxy is what puts both on one\n# origin. See compose.yml.\nmain_service: proxy\nmain_port: 8080\n\npreferred_slugs: [laminar, lmnr]\n\n# The sign-in page is the one route a logged-out client gets a real render from; the\n# frontend ships no health endpoint. Cold boot is front-loaded: the frontend applies the\n# drizzle schema before it serves, and it waits on ClickHouse being healthy first.\nhealth_probe:\n path: /sign-in\n start_period: 180s\n\n# The app stays owner-only; these paths still answer without the box login, so an SDK\n# running somewhere else can send spans (os APP_MANIFEST.md # E2, os 0.10.0+). Hosted only.\n#\n# /v1/* is Laminar's whole SDK and ingestion surface, and nothing else. Measured, not read off\n# the docs: the real SDK driven through a logging proxy calls POST /v1/traces (and /v1/logs\n# when logs are exported), and nothing outside that prefix. The scope is wrapped in\n# project-API-key auth in app-server, checked before any work is done, which is what makes\n# this safe: an unauthenticated POST /v1/traces answers 401 from Laminar itself.\n#\n# The bare /v1 is deliberately NOT declared. It reaches the same auth wrapper (401 without a\n# key) but has no endpoint behind it (404 with one), and no client calls it, so declaring it\n# would widen the carve-out for nothing.\n#\n# The UI, the login, sign-up, and the browser's own /api/* calls are all on the frontend,\n# which is the proxy's other branch, so they keep the box login. There is no path collision:\n# no frontend route begins with /v1. Widening this beyond /v1/* would be a security bug, not\n# a convenience: see the SECURITY INVARIANT note on the proxy in compose.yml.\naccess:\n public_paths:\n - /v1/*\n\n# FORCED by the bundled ClickHouse sidecar, which self-chowns its data dir and privilege-drops\n# ONLY when started as root; under cap_drop: ALL that root path dies on\n# `chown: … Operation not permitted`. Started as an arbitrary non-root uid over a pre-chowned\n# bind it skips both and boots clean. malmo pins ONE identity across every container of an app,\n# so the whole stack must tolerate it, and it does: the frontend ships USER nextjs and\n# app-server does no root-first setup. The one non-root snag is Caddy's file capabilities,\n# fixed the same way as langfuse, plane and baserow; see compose.yml.\nservice_user: true\n\n# A Next.js web tier, a Rust ingestion server, and a ClickHouse analytics database, atop\n# malmo's managed Postgres. ClickHouse is the appetite here: it will use whatever memory it is\n# given for query execution and mark caches.\nresources:\n recommended: { memory: 6G, cpu: 4.0 }\n\n# On disk: ./data/clickhouse is the span store (the one that grows with use) and\n# ./data/clickhouse-logs its server logs. Application state (users, workspaces, projects, API\n# keys, datasets) lives in the managed Postgres, which malmo sizes separately. Measured with\n# `du -sb` right after a smoke trace: 10.5 MB, almost all of it ClickHouse's empty-table\n# overhead.\nstorage:\n data_volumes:\n - ./data/clickhouse\n - ./data/clickhouse-logs\n estimated_size: 12MB\n app_managed_user_content: true\n\n# Postgres holds users, workspaces, projects, API keys, datasets, and evaluations. The\n# frontend applies the drizzle schema to it at boot. 18 is what new manifests declare\n# (os SERVICE_PROVISIONING.md); upstream pins 16, and the schema is plain SQL with no\n# version-specific built-ins. No cache service: the frontend's Redis client is optional and\n# falls back to an in-process cache, and the only routes that use it are the vendor's own\n# billing pages.\nservices:\n database:\n type: postgres\n version: \"18\"\n\n# nextauth_secret signs the session cookie. shared_secret_token is the frontend-to-app-server\n# trust token, which upstream ships as the literal \"some_secret\". aead_key is the seed the\n# compose entrypoints hash into the 64-hex-character key Laminar demands; it encrypts the\n# model-provider API keys a user enters in the UI, at rest, so it must be stable.\n# clickhouse_password authenticates both tiers to the sidecar over the compose network.\nsecrets:\n - name: nextauth_secret\n - name: shared_secret_token\n - name: aead_key\n - name: clickhouse_password\n\n# Workspace invitations are emailed through one vendor's HTTP API, not SMTP, so malmo's mail\n# binding cannot drive it and no mail: block is declared. The user brings their own key\n# instead. RESEND_BASE_URL is offered because the frontend reads it, so a compatible endpoint\n# works too; it is left blank by default, which means the vendor's own.\nconfig:\n - app_env: RESEND_API_KEY\n title: \"Email sending key\"\n description: \"API key from your email provider, used to send workspace invitations. Without it invitations are still recorded in the app, they are just not emailed.\"\n secret: true\n service: frontend\n - app_env: RESEND_BASE_URL\n title: \"Email API address\"\n description: \"Only needed if you use a service that copies the same API. Leave blank for the standard one.\"\n service: frontend\n\n# Third-party charges, not malmo's. Numbers copied from docs/external-cost-sheet.md.\nexternal_costs:\n - id: model-access\n title: \"Model access\"\n description: \u003e\n Laminar's evaluations and its chat over a trace answer through a model provider you\n choose, using your own API key, and that provider bills you directly. You paste the key\n into the app. Providers charge by token, which is roughly three quarters of a word.\n Tracing, dashboards, and datasets do not use a model and cost nothing.\n required: false\n estimate: \"$1 to $5 per million tokens in, $5 to $30 out (small models cost far less)\"\n estimate_checked: 2026-08-10\n - id: email-sending\n title: \"Sending email\"\n description: \u003e\n malmo does not send this app's email for you. Workspace invitations go out through an\n email provider you sign up with, and you pay that provider directly. Everything else in\n the app works without one.\n required: false\n estimate: \"free for most use ($0.40 to $1.50 per 1000 emails above the free tier)\"\n estimate_checked: 2026-08-10\n\n# internet: Laminar calls out to model providers on the user's behalf, with keys the user\n# enters in the UI, and to the email provider above. lan: not needed; the brain routes\n# inbound traffic.\npermissions:\n internet: true\n lan: false\n\nimages:\n caddy:2.11.4:\n digest: sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648\n download_bytes: 23907283\n disk_bytes: 63392256\n clickhouse/clickhouse-server:25.8.28.1:\n digest: sha256:a9d328123ff8a61bf6b16448528b577d59deb85758172e13b09054b0727f8adf\n download_bytes: 229907543\n disk_bytes: 803879424\n ghcr.io/lmnr-ai/app-server:v0.1.13:\n digest: sha256:1bc28c4f5e6c73a2be5bde6b5f82a114c60efaabe6db92f54bc651c1b5aa5ec9\n download_bytes: 50987803\n disk_bytes: 136422400\n ghcr.io/lmnr-ai/frontend:v0.1.13:\n digest: sha256:5556801d34de5cb2505dd2ef03184f31e64686fc03b82adcc3ff0353653f604b\n download_bytes: 97645898\n disk_bytes: 278162432\n", + "compose": "# Laminar adapted for malmo's Tier-3 sandbox, from the upstream LITE docker-compose.yml\n# (github.com/lmnr-ai/lmnr, tag v0.1.13). Upstream's LITE file ships five services; malmo takes\n# one as a managed service, drops one, keeps two, and adds a proxy.\n#\n# malmo changes vs. upstream:\n# - postgres (postgres:16) dropped -\u003e managed Postgres 18 (${MALMO_SERVICE_DATABASE_DSN}). The\n# frontend applies the drizzle schema at boot, so nothing else has to run migrations.\n# - clickhouse KEPT as a sidecar. It is the span store and there is no managed ClickHouse type,\n# so per the gate's bundled-engine row the sidecar IS the intended path. Pinned to the 25.8 LTS\n# line off upstream's floating :latest, the same image this repo already ships for langfuse.\n# Upstream's ulimits: block is dropped (the brain owns them).\n# - upstream bind-mounts clickhouse-profiles-config.xml to /etc/clickhouse-server/users.d/lmnr.xml.\n# A catalog app ships a compose and a manifest, not side files, so the entrypoint writes the same\n# XML itself. /etc/clickhouse-server/users.d is empty and world-writable in the image, so the\n# non-root identity malmo pins can write it with no bind and no root. Do NOT drop this file: it\n# sets date_time_input_format = best_effort on the default profile, which is how ClickHouse is\n# told to accept the datetime strings the app sends.\n# - agent-manager DROPPED. It is the browser-agent runner, and it cannot work on a box: it drives\n# browsers on Scrapybara, a third-party paid browser service, and calls Anthropic for the model.\n# Shipping it would idle a container that can do nothing without two paid third-party accounts,\n# and its one real job sends the user's browsing off the box. app-server's `depends_on` on it is\n# dropped with it. Recorded as a limitation; see status.yml.\n# - proxy (caddy) ADDED, and it is load-bearing. Ingestion lives on app-server (:8000) and the UI\n# on frontend (:5667), and malmo routes exactly ONE main_service:main_port. Caddy path-routes\n# /v1/* to app-server and everything else to the frontend, which puts the whole SDK surface and\n# the UI on the one origin malmo gives an app. Same shape as langfuse, plane and baserow.\n# There is no path collision: the frontend serves /api/... and page routes, nothing under /v1.\n# - caddy entrypoint overridden to work around setcap vs cap_drop: ALL. /usr/bin/caddy carries\n# file capabilities (cap_net_bind_service=ep); with an empty bounding set the kernel refuses to\n# execve it AT ALL. Copying the binary drops the xattrs, and the capability was never needed\n# here because the listen address is :8080. Same fix as langfuse, plane and baserow, including\n# the deliberate absence of a tmpfs on /tmp (docker mounts tmpfs noexec, so the copied binary\n# could not be executed off one).\n# - AEAD_SECRET_KEY is DERIVED, not injected raw. Upstream's .env says it \"must be exactly 32\n# bytes (64 hex characters)\"; malmo's generated secrets are base64url, which can never satisfy\n# that. Both services hash the same injected seed to the same 64 hex characters, each with a\n# binary its own image already has (sha256sum in app-server, node in the frontend). sha256 over\n# 32 CSPRNG bytes preserves the full 256 bits, and the derivation is deterministic, so the key\n# is stable across restarts -- which it must be, since it encrypts stored credentials at rest.\n# The two must agree exactly: the frontend writes what app-server reads.\n# - NEXTAUTH_SECRET comes from the manifest's secrets:. Upstream ships the literal \"some_secret\",\n# and it signs the session cookie. SHARED_SECRET_TOKEN is the frontend-to-app-server trust\n# token, upstream's literal is \"some_secret\" too.\n# - NEXTAUTH_URL / NEXT_PUBLIC_URL point at ${MALMO_APP_URL} instead of http://localhost:5667.\n# - LAMINAR_TELEMETRY_DISABLED: \"true\". Upstream self-hosted deployments post anonymous usage\n# telemetry by default. A malmo box does not phone home.\n# - all host ports: mappings dropped (the brain owns ingress and routes to main_port); named\n# volumes -\u003e relative ./data/ binds; container_name dropped; pull_policy: always dropped (the\n# brain installs by digest).\n\nx-lmnr-env: \u0026lmnr-env\n # Managed Postgres 18. Both services take the same DSN; the frontend owns the schema.\n DATABASE_URL: ${MALMO_SERVICE_DATABASE_DSN}\n\n # ClickHouse sidecar, over the compose network.\n CLICKHOUSE_URL: http://clickhouse:8123\n CLICKHOUSE_USER: lmnr\n CLICKHOUSE_PASSWORD: ${MALMO_SECRET_CLICKHOUSE_PASSWORD}\n\n # Drops the runtime dependency on RabbitMQ in both services, which is why this stack does not\n # bundle a queue. See notes.md for what LITE gives up versus FULL.\n ENVIRONMENT: LITE\n\n # The frontend calls app-server with this; app-server checks it. It is one shared token, not a\n # user credential, and it never leaves the compose network.\n SHARED_SECRET_TOKEN: ${MALMO_SECRET_SHARED_SECRET_TOKEN}\n\n # Hashed to 64 hex characters by the wrappers below (see header). Never read directly.\n LMNR_AEAD_SECRET_SEED: ${MALMO_SECRET_AEAD_KEY}\n\n LAMINAR_TELEMETRY_DISABLED: \"true\"\n\n# Derives the hex AEAD_SECRET_KEY both services demand from the base64url secret malmo generates.\n# Two images, two binaries, ONE value: sha256 of the same seed, printed as 64 lowercase hex\n# characters. `--` puts the service's command: back into \"$@\" for the exec.\n#\n# Every $ below is DOUBLED so it survives compose interpolation and reaches the container shell at\n# runtime; a single $ would be resolved at deploy time against the brain's environment and silently\n# blank the value. tools/check-compose.py renders this file through a real `docker compose config`\n# to catch exactly that.\nx-app-server-entrypoint: \u0026app-server-entrypoint\n - /bin/sh\n - -c\n - |\n AEAD_SECRET_KEY=$$(printf %s \"$${LMNR_AEAD_SECRET_SEED}\" | sha256sum | cut -c1-64)\n export AEAD_SECRET_KEY\n unset LMNR_AEAD_SECRET_SEED\n exec \"$$@\"\n - --\n\nx-frontend-entrypoint: \u0026frontend-entrypoint\n - /bin/sh\n - -c\n - |\n AEAD_SECRET_KEY=$$(node -e 'process.stdout.write(require(\"crypto\").createHash(\"sha256\").update(process.env.LMNR_AEAD_SECRET_SEED || \"\").digest(\"hex\"))')\n export AEAD_SECRET_KEY\n unset LMNR_AEAD_SECRET_SEED\n exec \"$$@\"\n - --\n\nservices:\n # --- infrastructure sidecar ---------------------------------------------------\n clickhouse:\n image: clickhouse/clickhouse-server:25.8.28.1\n # Writes upstream's users.d profile file, then hands off to the image's own entrypoint\n # untouched. The official entrypoint skips its root-first chown/su-exec setup entirely when it\n # is already non-root, which is what lets it boot under cap_drop: ALL over a pre-chowned bind.\n entrypoint: [\"/bin/sh\", \"-c\"]\n command:\n - |\n cat \u003e /etc/clickhouse-server/users.d/lmnr.xml \u003c\u003c'XMLEOF'\n \u003c?xml version=\"1.0\"?\u003e\n \u003cclickhouse\u003e\n \u003cprofiles\u003e\n \u003cdefault\u003e\n \u003cdate_time_input_format replace=\"replace\"\u003ebest_effort\u003c/date_time_input_format\u003e\n \u003c/default\u003e\n \u003c/profiles\u003e\n \u003c/clickhouse\u003e\n XMLEOF\n exec /entrypoint.sh\n environment:\n CLICKHOUSE_DB: default\n CLICKHOUSE_USER: lmnr\n CLICKHOUSE_PASSWORD: ${MALMO_SECRET_CLICKHOUSE_PASSWORD}\n volumes:\n - ./data/clickhouse:/var/lib/clickhouse\n - ./data/clickhouse-logs:/var/log/clickhouse-server\n # The server writes its pid file and local sockets under /var/run, which is root-owned in the\n # image and not writable by the non-root identity malmo pins.\n tmpfs:\n - /var/run:size=16m\n - /tmp:size=64m\n healthcheck:\n test: [\"CMD-SHELL\", \"wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1\"]\n interval: 10s\n timeout: 5s\n retries: 12\n start_period: 60s\n\n # --- the app -----------------------------------------------------------------\n # The ingestion and query API. Every /v1 scope is wrapped in project-API-key or CLI auth, which\n # is what makes the access.public_paths carve-out in the manifest safe.\n app-server:\n image: ghcr.io/lmnr-ai/app-server:v0.1.13\n entrypoint: *app-server-entrypoint\n # NOT redundant with the image CMD: compose clears the image CMD whenever a service sets\n # entrypoint:, so without this \"$@\" arrives empty and the exec runs nothing. The image's CMD is\n # ./app-server relative to its WORKDIR /app-server.\n command: [\"./app-server\"]\n environment:\n \u003c\u003c: *lmnr-env\n PORT: \"8000\"\n GRPC_PORT: \"8001\"\n depends_on:\n clickhouse:\n condition: service_healthy\n\n # The Next.js UI. It also owns the drizzle schema: it applies migrations against the managed\n # Postgres at boot, so it is the service to read when a schema problem is suspected.\n frontend:\n image: ghcr.io/lmnr-ai/frontend:v0.1.13\n entrypoint: *frontend-entrypoint\n # Same reason as app-server. The image's own docker-entrypoint.sh only prefixes `node` when the\n # first argument is not executable, so nothing is lost by replacing it.\n command: [\"node\", \"server.js\"]\n environment:\n \u003c\u003c: *lmnr-env\n PORT: \"5667\"\n BACKEND_URL: http://app-server:8000\n NEXTAUTH_SECRET: ${MALMO_SECRET_NEXTAUTH_SECRET}\n # NOT ${MALMO_APP_URL}, and this is the load-bearing line of the whole adaptation.\n # frontend/middleware.ts authorizes every /projects/{id} page by calling\n # `${NEXTAUTH_URL}/api/auth` from INSIDE the container, and treats any failure as \"not\n # authorized\". Pointed at the app's own public URL that call cannot complete: the container\n # cannot resolve the box's .local name, and on hosted the same request would come back\n # through the box's own login gate. Either way every project page bounces to /sign-in and\n # the app is unusable past the project list. Pointed at the compose-network address it is a\n # local call that always succeeds. See notes.md.\n NEXTAUTH_URL: http://frontend:5667\n # The public origin the browser actually uses, for links the UI renders.\n NEXT_PUBLIC_URL: ${MALMO_APP_URL}\n depends_on:\n clickhouse:\n condition: service_healthy\n\n # --- the single browser-facing origin ----------------------------------------\n # /v1 and /v1/* are the app's whole SDK surface and live on app-server; everything else is the\n # UI. This is the app's main_service.\n #\n # SECURITY INVARIANT, do not \"simplify\" this: /api/* must keep going to the FRONTEND. app-server\n # also serves /api/v1/projects/{project_id}/... (mint and revoke project API keys, save a\n # model-provider key) and those routes carry NO auth wrapper of their own. Upstream's own comment\n # says why: \"auth on path projects/{project_id} is handled by middleware on Next.js\". They are\n # safe only because the frontend is the only thing that can reach them. Routing /api/* here, or\n # widening this matcher, would publish unauthenticated key minting. The /v1 scope is the opposite\n # case: it is wrapped in project_auth, which is what makes the manifest's public_paths safe.\n proxy:\n image: caddy:2.11.4\n entrypoint: [\"/bin/sh\", \"-c\"]\n command:\n - |\n cat \u003e /tmp/Caddyfile \u003c\u003c'CADDYEOF'\n :8080 {\n @api path /v1 /v1/*\n handle @api {\n reverse_proxy http://app-server:8000 {\n # Span batches stream in and the SQL and MCP endpoints stream out; a buffered body\n # would stall both.\n flush_interval -1\n }\n }\n\n handle {\n reverse_proxy http://frontend:5667\n }\n }\n CADDYEOF\n cp /usr/bin/caddy /tmp/caddy\n exec /tmp/caddy run --config /tmp/Caddyfile --adapter caddyfile\n environment:\n XDG_DATA_HOME: /tmp/caddy-data\n XDG_CONFIG_HOME: /tmp/caddy-config\n # No tmpfs on /tmp here, deliberately. The Caddyfile heredoc, the copied caddy binary and\n # Caddy's data/config dirs all live under /tmp, and docker mounts every tmpfs: entry noexec by\n # default, so the copied binary cannot be executed off one. Same shape as langfuse's caddy.\n depends_on:\n - frontend\n - app-server\n", + "images": { + "caddy:2.11.4": { + "digest": "sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648", + "download_bytes": 23907283, + "disk_bytes": 63392256 + }, + "clickhouse/clickhouse-server:25.8.28.1": { + "digest": "sha256:a9d328123ff8a61bf6b16448528b577d59deb85758172e13b09054b0727f8adf", + "download_bytes": 229907543, + "disk_bytes": 803879424 + }, + "ghcr.io/lmnr-ai/app-server:v0.1.13": { + "digest": "sha256:1bc28c4f5e6c73a2be5bde6b5f82a114c60efaabe6db92f54bc651c1b5aa5ec9", + "download_bytes": 50987803, + "disk_bytes": 136422400 + }, + "ghcr.io/lmnr-ai/frontend:v0.1.13": { + "digest": "sha256:5556801d34de5cb2505dd2ef03184f31e64686fc03b82adcc3ff0353653f604b", + "download_bytes": 97645898, + "disk_bytes": 278162432 + } + } + }, { "id": "langfuse", "name": "Langfuse", "version": "3.224.0", "short_description": "See exactly what your LLM apps and agents are doing: traces, costs, prompts, and evaluations in one place.", - "long_description": "## Overview\nLangfuse is the control room for anything you build on top of a language\nmodel. Your app sends a trace of every call it makes, and Langfuse shows you\nthe full tree: the prompts that went out, the answers that came back, how\nlong each step took, how many tokens it burned, and what it cost. When\nsomething goes wrong at 2am you can open the exact conversation that broke\ninstead of guessing from logs.\n\n## Features\n- Full traces of LLM calls, chains, agents, and tool use, nested step by step\n- Token counts and cost per trace, per user, per model, on a live dashboard\n- A prompt library with versioning, so you can change a prompt without a redeploy\n- Datasets and experiments to compare two prompts or two models on the same inputs\n- Evaluations, including LLM-as-a-judge scoring, plus manual annotation queues\n- Multimodal traces: images, audio, and files attached to a trace and viewable in the UI\n- Works with the OpenTelemetry standard and with the Python and JS SDKs, plus\n drop-in integrations for LangChain, LlamaIndex, OpenAI, and the Vercel AI SDK\n- malmo provisions and manages the database and cache Langfuse needs, so there\n is nothing to wire up\n\n## Setting up\nThe first account you register after install becomes the administrator, and\nit creates the first organization and project. Inside a project, open\nSettings then API keys to mint the public and secret key pair your\napplication will send traces with.\n\n## Good to know\n- **To receive traces from an app running somewhere else, this app has to be\n reachable by it.** malmo keeps every app owner-only by default, with the box\n login in front, which also blocks your own code from calling the tracing API.\n If your application runs outside this box, make the app public first, and rely\n on Langfuse's own login and API keys for access control.\n- **Sign-up is open by default.** If you do make the app public, turn off new\n sign-ups from the Langfuse settings once your own account exists, otherwise\n anyone who finds the URL can register.\n- **This is a heavy app.** It runs a web tier, a background worker, a ClickHouse\n analytics database, and an S3-compatible object store, alongside malmo's\n managed database and cache. That is several gigabytes to download and a few\n gigabytes of memory to run comfortably, and trace history grows with use.\n- **Email is off until you connect a mail provider.** Without one, user\n invitations, password resets, and notification emails are not sent. Everything\n else works, and you can still add members by having them register and then\n granting them access.\n- **Optional paid features are not included.** Langfuse's enterprise features\n are locked behind a commercial licence key that this app does not ship. The\n full open-source product, which is everything above, runs without one.\n", + "long_description": "## Overview\nLangfuse is the control room for anything you build on top of a language\nmodel. Your app sends a trace of every call it makes, and Langfuse shows you\nthe full tree: the prompts that went out, the answers that came back, how\nlong each step took, how many tokens it burned, and what it cost. When\nsomething goes wrong at 2am you can open the exact conversation that broke\ninstead of guessing from logs.\n\n## Features\n- Full traces of LLM calls, chains, agents, and tool use, nested step by step\n- Token counts and cost per trace, per user, per model, on a live dashboard\n- A prompt library with versioning, so you can change a prompt without a redeploy\n- Datasets and experiments to compare two prompts or two models on the same inputs\n- Evaluations, including LLM-as-a-judge scoring, plus manual annotation queues\n- Multimodal traces: images, audio, and files attached to a trace and viewable in the UI\n- Works with the OpenTelemetry standard and with the Python and JS SDKs, plus\n drop-in integrations for LangChain, LlamaIndex, OpenAI, and the Vercel AI SDK\n- malmo provisions and manages the database and cache Langfuse needs, so there\n is nothing to wire up\n\n## Setting up\nThe first account you register after install becomes the administrator, and\nit creates the first organization and project. Inside a project, open\nSettings then API keys to mint the public and secret key pair your\napplication will send traces with.\n\n## Good to know\n- **Apps running somewhere else can send traces, and the app stays private.**\n malmo keeps this app owner-only, so the pages are behind the box login. The\n tracing and API paths are the exception: they answer without it, and the\n project API keys you mint are what guards them. Nothing to switch on, and no\n need to make the whole app public.\n- **Sign-up is open by default.** The sign-up page sits behind the box login, so\n this only matters if you make the whole app public yourself. If you do, turn\n off new sign-ups from the Langfuse settings once your own account exists.\n- **This is a heavy app.** It runs a web tier, a background worker, a ClickHouse\n analytics database, and an S3-compatible object store, alongside malmo's\n managed database and cache. That is several gigabytes to download and a few\n gigabytes of memory to run comfortably, and trace history grows with use.\n- **Email is off until you connect a mail provider.** Without one, user\n invitations, password resets, and notification emails are not sent. Everything\n else works, and you can still add members by having them register and then\n granting them access.\n- **Optional paid features are not included.** Langfuse's enterprise features\n are locked behind a commercial licence key that this app does not ship. The\n full open-source product, which is everything above, runs without one.\n", "categories": [ "ai", "developer-tools" @@ -827,7 +901,7 @@ "appliance", "hosted" ], - "manifest": "id: langfuse\nmanifest_version: 1\nname: Langfuse\nversion: \"3.224.0\"\ndescription:\n short: \"See exactly what your LLM apps and agents are doing: traces, costs, prompts, and evaluations in one place.\"\n long: |\n ## Overview\n Langfuse is the control room for anything you build on top of a language\n model. Your app sends a trace of every call it makes, and Langfuse shows you\n the full tree: the prompts that went out, the answers that came back, how\n long each step took, how many tokens it burned, and what it cost. When\n something goes wrong at 2am you can open the exact conversation that broke\n instead of guessing from logs.\n\n ## Features\n - Full traces of LLM calls, chains, agents, and tool use, nested step by step\n - Token counts and cost per trace, per user, per model, on a live dashboard\n - A prompt library with versioning, so you can change a prompt without a redeploy\n - Datasets and experiments to compare two prompts or two models on the same inputs\n - Evaluations, including LLM-as-a-judge scoring, plus manual annotation queues\n - Multimodal traces: images, audio, and files attached to a trace and viewable in the UI\n - Works with the OpenTelemetry standard and with the Python and JS SDKs, plus\n drop-in integrations for LangChain, LlamaIndex, OpenAI, and the Vercel AI SDK\n - malmo provisions and manages the database and cache Langfuse needs, so there\n is nothing to wire up\n\n ## Setting up\n The first account you register after install becomes the administrator, and\n it creates the first organization and project. Inside a project, open\n Settings then API keys to mint the public and secret key pair your\n application will send traces with.\n\n ## Good to know\n - **To receive traces from an app running somewhere else, this app has to be\n reachable by it.** malmo keeps every app owner-only by default, with the box\n login in front, which also blocks your own code from calling the tracing API.\n If your application runs outside this box, make the app public first, and rely\n on Langfuse's own login and API keys for access control.\n - **Sign-up is open by default.** If you do make the app public, turn off new\n sign-ups from the Langfuse settings once your own account exists, otherwise\n anyone who finds the URL can register.\n - **This is a heavy app.** It runs a web tier, a background worker, a ClickHouse\n analytics database, and an S3-compatible object store, alongside malmo's\n managed database and cache. That is several gigabytes to download and a few\n gigabytes of memory to run comfortably, and trace history grows with use.\n - **Email is off until you connect a mail provider.** Without one, user\n invitations, password resets, and notification emails are not sent. Everything\n else works, and you can still add members by having them register and then\n granting them access.\n - **Optional paid features are not included.** Langfuse's enterprise features\n are locked behind a commercial licence key that this app does not ship. The\n full open-source product, which is everything above, runs without one.\nicon: ./icon.png\ncategories: [ai, developer-tools]\nauthor:\n name: Langfuse\n url: https://langfuse.com\n# MIT. The repo is open-core: three ee/ directories carry a commercial Langfuse\n# Enterprise Licence, but they are runtime-entitlement-gated and dormant without a\n# licence key, and ee/LICENSE explicitly carves out the MIT core as usable and runnable\n# on its own. We ship the vendor's published image unmodified. The bundled MinIO sidecar\n# is AGPL-3.0, also shipped unmodified, so its network-use source offer is upstream's;\n# ClickHouse is Apache-2.0. Neither bars either tier. See notes.md.\nlicense: MIT\nlinks:\n homepage: https://langfuse.com\n source: https://github.com/langfuse/langfuse\n support: https://langfuse.com/self-hosting\nchangelog_url: https://github.com/langfuse/langfuse/releases\n\ncompose_file: compose.yml\n# The Caddy proxy, not the web tier: it is the only service that fronts BOTH Langfuse and\n# the media bucket on one origin, which is what makes presigned multimodal media URLs\n# resolve. See compose.yml.\nmain_service: proxy\nmain_port: 8080\n\n# \"traces\" and \"llm\" are what someone reaches for when they cannot recall the product name.\npreferred_slugs: [langfuse, traces, llm]\n\n# GET /api/public/health is unauthenticated and returns 200 only once the web tier is\n# serving. Cold boot is long and front-loaded: the entrypoint applies the Prisma schema and\n# then ~37 ClickHouse migrations before Next.js even starts, and it blocks on ClickHouse\n# and MinIO being healthy first. Measured ~75-90s to first 200 on a warm image cache; 240s\n# leaves room for a slow disk and a first-ever migration run.\nhealth_probe:\n path: /api/public/health\n start_period: 240s\n\n# FORCED by the two bundled sidecars. ClickHouse and MinIO both self-chown their data dir\n# and privilege-drop ONLY when started as root; under cap_drop: ALL that root path dies on\n# `chown: … Operation not permitted`. Started as an arbitrary non-root uid over a\n# pre-chowned bind they skip both and boot clean, which is why upstream's explicit\n# `user: \"101:101\"` on ClickHouse is dropped in favour of the identity malmo pins. malmo\n# pins ONE identity across every container of an app, so the whole stack must tolerate it,\n# and it does: langfuse-web (USER nextjs) and langfuse-worker (USER expressjs) do no\n# root-first setup and no self-chown. Verified booting all five containers as a non-root\n# uid under cap_drop: ALL + no-new-privileges with zero permission errors: ClickHouse\n# healthy and migrated, MinIO healthy and serving, an end-to-end trace ingested and\n# queryable, and a presigned multimodal media round trip through the proxy. The one\n# non-root snag was Caddy's file capabilities, fixed the same way as plane and baserow;\n# see compose.yml.\nservice_user: true\n\n# A Next.js web tier, a Node worker, a ClickHouse analytics database, and a MinIO object\n# store, atop malmo's managed Postgres and Valkey. ClickHouse is the appetite here: it will\n# use whatever memory it is given for query execution and mark caches.\nresources:\n recommended: { memory: 8G, cpu: 4.0 }\n\n# On disk: ./data/clickhouse is the trace and observation store (the one that grows with\n# use), ./data/clickhouse-logs its server logs, ./data/minio the blob store holding raw\n# ingestion events and uploaded media. Application state (users, projects, prompts,\n# datasets) lives in the managed Postgres, which malmo sizes separately. Measured 50MB at\n# first-healthy after a smoke trace and a media round trip, almost all of it ClickHouse's\n# empty-table overhead.\nstorage:\n data_volumes:\n - ./data/clickhouse\n - ./data/clickhouse-logs\n - ./data/minio\n estimated_size: 60MB\n app_managed_user_content: true\n\n# Postgres holds users, projects, prompts, datasets, and API keys; Valkey backs the BullMQ\n# ingestion and evaluation queues plus the API-key cache. Langfuse needs both to boot: with\n# no reachable queue the worker never folds ingested events into ClickHouse and nothing a\n# trace records ever becomes visible. Postgres 16 is the newest managed version Langfuse's\n# Prisma schema supports (it wants 12+).\nservices:\n database:\n type: postgres\n version: \"16\"\n cache:\n type: valkey\n version: \"8\"\n\n# nextauth_secret signs the session cookie and salt hashes the project API keys, so both\n# must be stable and neither may be a published constant (upstream ships the literals\n# \"mysecret\" and \"mysalt\"). encryption_key is the seed the compose entrypoint hashes into\n# the 64-hex-character key Langfuse demands; it encrypts stored LLM-provider credentials\n# and integration secrets at rest. clickhouse_password and minio_password authenticate the\n# app tiers to the two sidecars over the compose network and never leave it.\nsecrets:\n - name: nextauth_secret\n - name: salt\n - name: encryption_key\n - name: clickhouse_password\n - name: minio_password\n\n# Organization and project invitations, password resets, and evaluation/alert\n# notifications. Mapped onto SMTP_CONNECTION_URL + EMAIL_FROM_ADDRESS; unbound, both expand\n# empty and Langfuse simply never sends, with no effect on tracing.\nmail:\n optional: true\n\n# internet: Langfuse calls out to LLM providers on the user's behalf for LLM-as-a-judge\n# evaluations and the prompt playground, and to an external SMTP host for mail. lan: not\n# needed; the brain routes inbound traffic, and reaching a service on the local network is\n# an opt-in power-user case we do not grant by default.\npermissions:\n internet: true\n lan: false\n\nimages:\n caddy:2.11.4:\n digest: sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648\n download_bytes: 23907283\n disk_bytes: 63392256\n clickhouse/clickhouse-server:25.8.28.1:\n digest: sha256:a9d328123ff8a61bf6b16448528b577d59deb85758172e13b09054b0727f8adf\n download_bytes: 229907543\n disk_bytes: 803879424\n langfuse/langfuse-worker:3.224.0:\n digest: sha256:40424009bf016521be86ed2ed015fec6a6a6b2c8214adafb1abe50ee1a757c07\n download_bytes: 332898665\n disk_bytes: 1162161664\n langfuse/langfuse:3.224.0:\n digest: sha256:be09a5cde9c73d600b8b926d816918ec0efaab81adcfc865d0995528df8ce3af\n download_bytes: 348664580\n disk_bytes: 1321409536\n minio/minio:RELEASE.2025-09-07T16-13-09Z:\n digest: sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e\n download_bytes: 62233791\n disk_bytes: 176238080\n", + "manifest": "id: langfuse\nmanifest_version: 1\nname: Langfuse\nversion: \"3.224.0\"\ndescription:\n short: \"See exactly what your LLM apps and agents are doing: traces, costs, prompts, and evaluations in one place.\"\n long: |\n ## Overview\n Langfuse is the control room for anything you build on top of a language\n model. Your app sends a trace of every call it makes, and Langfuse shows you\n the full tree: the prompts that went out, the answers that came back, how\n long each step took, how many tokens it burned, and what it cost. When\n something goes wrong at 2am you can open the exact conversation that broke\n instead of guessing from logs.\n\n ## Features\n - Full traces of LLM calls, chains, agents, and tool use, nested step by step\n - Token counts and cost per trace, per user, per model, on a live dashboard\n - A prompt library with versioning, so you can change a prompt without a redeploy\n - Datasets and experiments to compare two prompts or two models on the same inputs\n - Evaluations, including LLM-as-a-judge scoring, plus manual annotation queues\n - Multimodal traces: images, audio, and files attached to a trace and viewable in the UI\n - Works with the OpenTelemetry standard and with the Python and JS SDKs, plus\n drop-in integrations for LangChain, LlamaIndex, OpenAI, and the Vercel AI SDK\n - malmo provisions and manages the database and cache Langfuse needs, so there\n is nothing to wire up\n\n ## Setting up\n The first account you register after install becomes the administrator, and\n it creates the first organization and project. Inside a project, open\n Settings then API keys to mint the public and secret key pair your\n application will send traces with.\n\n ## Good to know\n - **Apps running somewhere else can send traces, and the app stays private.**\n malmo keeps this app owner-only, so the pages are behind the box login. The\n tracing and API paths are the exception: they answer without it, and the\n project API keys you mint are what guards them. Nothing to switch on, and no\n need to make the whole app public.\n - **Sign-up is open by default.** The sign-up page sits behind the box login, so\n this only matters if you make the whole app public yourself. If you do, turn\n off new sign-ups from the Langfuse settings once your own account exists.\n - **This is a heavy app.** It runs a web tier, a background worker, a ClickHouse\n analytics database, and an S3-compatible object store, alongside malmo's\n managed database and cache. That is several gigabytes to download and a few\n gigabytes of memory to run comfortably, and trace history grows with use.\n - **Email is off until you connect a mail provider.** Without one, user\n invitations, password resets, and notification emails are not sent. Everything\n else works, and you can still add members by having them register and then\n granting them access.\n - **Optional paid features are not included.** Langfuse's enterprise features\n are locked behind a commercial licence key that this app does not ship. The\n full open-source product, which is everything above, runs without one.\nicon: ./icon.png\ncategories: [ai, developer-tools]\nauthor:\n name: Langfuse\n url: https://langfuse.com\n# MIT. The repo is open-core: three ee/ directories carry a commercial Langfuse\n# Enterprise Licence, but they are runtime-entitlement-gated and dormant without a\n# licence key, and ee/LICENSE explicitly carves out the MIT core as usable and runnable\n# on its own. We ship the vendor's published image unmodified. The bundled MinIO sidecar\n# is AGPL-3.0, also shipped unmodified, so its network-use source offer is upstream's;\n# ClickHouse is Apache-2.0. Neither bars either tier. See notes.md.\nlicense: MIT\nlinks:\n homepage: https://langfuse.com\n source: https://github.com/langfuse/langfuse\n support: https://langfuse.com/self-hosting\nchangelog_url: https://github.com/langfuse/langfuse/releases\n\ncompose_file: compose.yml\n# The Caddy proxy, not the web tier: it is the only service that fronts BOTH Langfuse and\n# the media bucket on one origin, which is what makes presigned multimodal media URLs\n# resolve. See compose.yml.\nmain_service: proxy\nmain_port: 8080\n\n# \"traces\" and \"llm\" are what someone reaches for when they cannot recall the product name.\npreferred_slugs: [langfuse, traces, llm]\n\n# GET /api/public/health is unauthenticated and returns 200 only once the web tier is\n# serving. Cold boot is long and front-loaded: the entrypoint applies the Prisma schema and\n# then ~37 ClickHouse migrations before Next.js even starts, and it blocks on ClickHouse\n# and MinIO being healthy first. Measured ~75-90s to first 200 on a warm image cache; 240s\n# leaves room for a slow disk and a first-ever migration run.\nhealth_probe:\n path: /api/public/health\n start_period: 240s\n\n# The app stays owner-only; these two paths still answer without the box login, so an SDK\n# running somewhere else can send traces (os APP_MANIFEST.md # E2, os 0.10.0+). Hosted only.\n#\n# /api/public/* is Langfuse's whole SDK and REST surface: trace ingestion\n# (/api/public/ingestion), the OTel endpoints (/api/public/otel/v1/traces and .../metrics),\n# prompt fetching, datasets, scores, and the media metadata calls. Every route under it\n# authenticates itself with a project or organization API key, checked before it does any\n# work. The two exceptions are /api/public/health and /api/public/ready, which report\n# liveness only and take no input. The UI, the login, sign-up (/api/auth/*), and the tRPC\n# routes the browser uses are NOT under this prefix, so they all keep the box login.\n#\n# /langfuse/media/* is the media prefix of the MinIO bucket, which the proxy path-routes\n# below. A multimodal trace is uploaded by the SDK itself: Langfuse mints a presigned S3 URL\n# against ${MALMO_APP_URL} and the SDK PUTs the file straight to it, so without this the\n# trace arrives and its media does not. MinIO checks the SigV4 signature on every request\n# and refuses an unsigned one, so the path is authenticated by the app in the same sense.\n# The bucket's other prefix (events/) is deliberately left out: only the two Langfuse\n# containers touch it, over the compose network, never through this origin.\naccess:\n public_paths:\n - /api/public/*\n - /langfuse/media/*\n\n# FORCED by the two bundled sidecars. ClickHouse and MinIO both self-chown their data dir\n# and privilege-drop ONLY when started as root; under cap_drop: ALL that root path dies on\n# `chown: … Operation not permitted`. Started as an arbitrary non-root uid over a\n# pre-chowned bind they skip both and boot clean, which is why upstream's explicit\n# `user: \"101:101\"` on ClickHouse is dropped in favour of the identity malmo pins. malmo\n# pins ONE identity across every container of an app, so the whole stack must tolerate it,\n# and it does: langfuse-web (USER nextjs) and langfuse-worker (USER expressjs) do no\n# root-first setup and no self-chown. Verified booting all five containers as a non-root\n# uid under cap_drop: ALL + no-new-privileges with zero permission errors: ClickHouse\n# healthy and migrated, MinIO healthy and serving, an end-to-end trace ingested and\n# queryable, and a presigned multimodal media round trip through the proxy. The one\n# non-root snag was Caddy's file capabilities, fixed the same way as plane and baserow;\n# see compose.yml.\nservice_user: true\n\n# A Next.js web tier, a Node worker, a ClickHouse analytics database, and a MinIO object\n# store, atop malmo's managed Postgres and Valkey. ClickHouse is the appetite here: it will\n# use whatever memory it is given for query execution and mark caches.\nresources:\n recommended: { memory: 8G, cpu: 4.0 }\n\n# On disk: ./data/clickhouse is the trace and observation store (the one that grows with\n# use), ./data/clickhouse-logs its server logs, ./data/minio the blob store holding raw\n# ingestion events and uploaded media. Application state (users, projects, prompts,\n# datasets) lives in the managed Postgres, which malmo sizes separately. Measured 50MB at\n# first-healthy after a smoke trace and a media round trip, almost all of it ClickHouse's\n# empty-table overhead.\nstorage:\n data_volumes:\n - ./data/clickhouse\n - ./data/clickhouse-logs\n - ./data/minio\n estimated_size: 60MB\n app_managed_user_content: true\n\n# Postgres holds users, projects, prompts, datasets, and API keys; Valkey backs the BullMQ\n# ingestion and evaluation queues plus the API-key cache. Langfuse needs both to boot: with\n# no reachable queue the worker never folds ingested events into ClickHouse and nothing a\n# trace records ever becomes visible. Postgres 16 is the newest managed version Langfuse's\n# Prisma schema supports (it wants 12+).\nservices:\n database:\n type: postgres\n version: \"16\"\n cache:\n type: valkey\n version: \"8\"\n\n# nextauth_secret signs the session cookie and salt hashes the project API keys, so both\n# must be stable and neither may be a published constant (upstream ships the literals\n# \"mysecret\" and \"mysalt\"). encryption_key is the seed the compose entrypoint hashes into\n# the 64-hex-character key Langfuse demands; it encrypts stored LLM-provider credentials\n# and integration secrets at rest. clickhouse_password and minio_password authenticate the\n# app tiers to the two sidecars over the compose network and never leave it.\nsecrets:\n - name: nextauth_secret\n - name: salt\n - name: encryption_key\n - name: clickhouse_password\n - name: minio_password\n\n# Organization and project invitations, password resets, and evaluation/alert\n# notifications. Mapped onto SMTP_CONNECTION_URL + EMAIL_FROM_ADDRESS; unbound, both expand\n# empty and Langfuse simply never sends, with no effect on tracing.\nmail:\n optional: true\n\n# internet: Langfuse calls out to LLM providers on the user's behalf for LLM-as-a-judge\n# evaluations and the prompt playground, and to an external SMTP host for mail. lan: not\n# needed; the brain routes inbound traffic, and reaching a service on the local network is\n# an opt-in power-user case we do not grant by default.\npermissions:\n internet: true\n lan: false\n\nimages:\n caddy:2.11.4:\n digest: sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648\n download_bytes: 23907283\n disk_bytes: 63392256\n clickhouse/clickhouse-server:25.8.28.1:\n digest: sha256:a9d328123ff8a61bf6b16448528b577d59deb85758172e13b09054b0727f8adf\n download_bytes: 229907543\n disk_bytes: 803879424\n langfuse/langfuse-worker:3.224.0:\n digest: sha256:40424009bf016521be86ed2ed015fec6a6a6b2c8214adafb1abe50ee1a757c07\n download_bytes: 332898665\n disk_bytes: 1162161664\n langfuse/langfuse:3.224.0:\n digest: sha256:be09a5cde9c73d600b8b926d816918ec0efaab81adcfc865d0995528df8ce3af\n download_bytes: 348664580\n disk_bytes: 1321409536\n minio/minio:RELEASE.2025-09-07T16-13-09Z:\n digest: sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e\n download_bytes: 62233791\n disk_bytes: 176238080\n", "compose": "# Langfuse v3 adapted for malmo's Tier-3 sandbox, from the upstream docker-compose.yml\n# (github.com/langfuse/langfuse, v3.224.0). Upstream ships a 6-service stack; malmo takes\n# two of those as managed services and keeps the two it has no managed type for.\n#\n# malmo changes vs. upstream:\n# - postgres (postgres:17) dropped -\u003e managed Postgres 16 (${MALMO_SERVICE_DATABASE_DSN}).\n# Langfuse needs Postgres 12+; the schema is applied by prisma at boot from the app image.\n# - redis (redis:7, source-available) dropped -\u003e managed Valkey 8. Langfuse takes a single\n# REDIS_CONNECTION_STRING, so the injected redis:// DSN maps straight onto it. BullMQ\n# refuses to start against a server whose INFO version string it cannot parse as a\n# supported Redis, which is what Valkey reports, so LANGFUSE_BULLMQ_SKIP_REDIS_VERSION_CHECK\n# is set. Valkey's default maxmemory-policy is noeviction, which is what BullMQ requires.\n# - clickhouse KEPT as a sidecar. It is the trace/observation store and there is no managed\n# ClickHouse type; per the gate's bundled-engine row the sidecar IS the intended path.\n# Upstream's explicit `user: \"101:101\"` is dropped: malmo pins ONE non-root identity across\n# every container of an app (service_user: true) over pre-chowned relative binds, and the\n# official entrypoint skips its root-first chown/su-exec setup entirely when it is already\n# non-root, so it boots clean under cap_drop: ALL. Pinned to the 25.8 LTS line off\n# upstream's floating :latest.\n# - minio KEPT as a sidecar (no managed S3 type), and it is NOT optional: Langfuse v3's\n# ingestion pipeline writes every event to blob storage before the worker folds it into\n# ClickHouse, so some S3 must always be present. Upstream's cgr.dev/chainguard/minio\n# publishes only a floating :latest; swapped for minio/minio at the immutable RELEASE tag\n# this repo already ships for plane. Upstream's `mkdir -p /data/langfuse` bucket-seeding\n# trick is kept -- Langfuse does not create the bucket itself, and with path-style layout\n# a directory under /data IS the bucket.\n# - proxy (caddy) ADDED, and it is load-bearing. Langfuse hands out PRESIGNED S3 URLs that a\n# client outside the compose network has to fetch directly: SDK media uploads (multimodal\n# traces) and browser media/batch-export downloads. malmo gives an app exactly ONE\n# browser-facing origin, so http://minio:9000 would never resolve for those clients and\n# every multimodal trace would fail to upload. Caddy path-routes the bucket prefix\n# (/langfuse/*) to minio and everything else to langfuse-web, and the S3 *external*\n# endpoint vars are set to ${MALMO_APP_URL} so the presigned URLs are minted against that\n# one origin. SigV4 signs the Host header and the path, not the scheme, and Caddy forwards\n# the original Host untouched, so MinIO recomputes the same signature and accepts it.\n# Same shape as plane's and baserow's proxies.\n# - caddy entrypoint overridden to work around setcap vs cap_drop: ALL. /usr/bin/caddy carries\n# file capabilities (cap_net_bind_service=ep); with an empty bounding set the kernel refuses\n# to execve it AT ALL, for root and non-root alike (\"exec /usr/bin/caddy: operation not\n# permitted\"). Copying the binary drops the xattrs, and the capability was never needed\n# here because the listen address is :8080. Same fix as plane and baserow. The Caddyfile is\n# inlined via a heredoc rather than bind-mounted: a catalog app ships a compose and a\n# manifest, not side files.\n# - ENCRYPTION_KEY is DERIVED, not injected raw. Langfuse validates it as exactly 64\n# hex characters (`openssl rand -hex 32`); malmo's generated secrets are base64url, which\n# can never satisfy that, and a mismatch is a hard boot failure on the zod env parse. The\n# entrypoint wrappers below hash the injected secret to hex with the image's own node\n# binary. sha256 over 32 CSPRNG bytes preserves the full 256 bits, and the derivation is\n# deterministic, so the key is stable across restarts -- which it must be, since it\n# encrypts stored LLM-provider credentials and integration secrets at rest.\n# - NEXTAUTH_SECRET / SALT come from the manifest's `secrets:`. Upstream ships the literal\n# placeholders \"mysecret\" and \"mysalt\"; SALT hashes the API keys, so a published constant\n# would make every self-hosted instance's key hashes interchangeable.\n# - TELEMETRY_ENABLED flipped to \"false\". Upstream defaults it true, which posts periodic\n# usage statistics to Langfuse's servers. A malmo box does not phone home by default.\n# - all host `ports:` mappings dropped (the brain owns ingress and routes to main_port);\n# named volumes -\u003e relative ./data/ binds; `restart: always` dropped (the brain owns\n# restart policy).\n# - LANGFUSE_S3_BATCH_EXPORT_ENABLED left at upstream's false. It is entitlement-gated\n# behind an EE licence key, which we do not ship, so enabling it buys nothing.\n\nx-langfuse-env: \u0026langfuse-env\n # Managed Postgres. Prisma percent-decodes the credential; the injected password is\n # base64url (alnum + - + _) and the database name a sanitized SQL identifier, so neither\n # trips the image entrypoint's unencoded-credential check.\n DATABASE_URL: ${MALMO_SERVICE_DATABASE_DSN}\n\n # Managed Valkey, taken as one connection string. See the header for the version check.\n REDIS_CONNECTION_STRING: ${MALMO_SERVICE_CACHE_DSN}\n LANGFUSE_BULLMQ_SKIP_REDIS_VERSION_CHECK: \"true\"\n\n # ClickHouse sidecar. The migration URL is the native protocol (9000), the query URL is\n # HTTP (8123); both are applied by the image entrypoint on every boot.\n CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000\n CLICKHOUSE_URL: http://clickhouse:8123\n CLICKHOUSE_USER: clickhouse\n CLICKHOUSE_PASSWORD: ${MALMO_SECRET_CLICKHOUSE_PASSWORD}\n CLICKHOUSE_CLUSTER_ENABLED: \"false\"\n\n # Blob storage -\u003e the MinIO sidecar. Every endpoint here is the INTERNAL one; langfuse-web\n # overrides the media endpoint below, and only there. See that service for why.\n LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse\n LANGFUSE_S3_EVENT_UPLOAD_REGION: auto\n LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000\n LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: langfuse\n LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${MALMO_SECRET_MINIO_PASSWORD}\n LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: \"true\"\n LANGFUSE_S3_EVENT_UPLOAD_PREFIX: events/\n LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse\n LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto\n LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000\n LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: langfuse\n LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${MALMO_SECRET_MINIO_PASSWORD}\n LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: \"true\"\n LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/\n LANGFUSE_S3_BATCH_EXPORT_ENABLED: \"false\"\n\n # The public origin. Langfuse bakes it into NextAuth's callback URLs and the sign-in\n # cookie, so it has to be the address the user actually reaches the app on.\n NEXTAUTH_URL: ${MALMO_APP_URL}\n SALT: ${MALMO_SECRET_SALT}\n\n # Hashed to 64 hex chars by the entrypoint wrappers below (see header). Never read\n # directly by Langfuse.\n LANGFUSE_ENCRYPTION_KEY_SEED: ${MALMO_SECRET_ENCRYPTION_KEY}\n\n # Outgoing mail: user invitations, password resets, and eval/alert notifications. Unbound,\n # both expand empty and Langfuse simply never sends -- the rest of the app is unaffected.\n SMTP_CONNECTION_URL: \"${MALMO_MAIL_DSN:-}\"\n EMAIL_FROM_ADDRESS: \"${MALMO_MAIL_FROM:-}\"\n\n TELEMETRY_ENABLED: \"false\"\n LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: \"false\"\n\n# Derives the hex ENCRYPTION_KEY Langfuse's env schema demands from the base64url secret\n# malmo generates, then hands off to the image's own startup untouched: `--` puts the\n# service's `command:` back in \"$@\", and dumb-init + entrypoint.sh (prisma + ClickHouse\n# migrations, then exec) are restated verbatim from the Dockerfile because overriding\n# `entrypoint:` replaces them. node is the image's own runtime, so it is always on PATH.\n#\n# The `command:` on each service is NOT redundant with the image's CMD. Compose clears the\n# image CMD whenever a service sets `entrypoint:`, so without it \"$@\" arrives empty,\n# entrypoint.sh runs both migrations and then `exec` with no arguments, and the container\n# exits 0 looking like a clean successful run. Verified: that is exactly what happens.\n#\n# Every $ below is DOUBLED so it survives compose interpolation and reaches the container\n# shell at runtime; a single $ would be resolved at deploy time against the brain's\n# environment and silently blank the value. tools/check-compose.py renders this file through\n# a real `docker compose config` to catch exactly that.\nx-web-entrypoint: \u0026web-entrypoint\n - /bin/sh\n - -c\n - |\n ENCRYPTION_KEY=$$(node -e 'process.stdout.write(require(\"crypto\").createHash(\"sha256\").update(process.env.LANGFUSE_ENCRYPTION_KEY_SEED || \"\").digest(\"hex\"))')\n export ENCRYPTION_KEY\n unset LANGFUSE_ENCRYPTION_KEY_SEED\n exec dumb-init -- ./web/entrypoint.sh \"$$@\"\n - --\n\nx-worker-entrypoint: \u0026worker-entrypoint\n - /bin/sh\n - -c\n - |\n ENCRYPTION_KEY=$$(node -e 'process.stdout.write(require(\"crypto\").createHash(\"sha256\").update(process.env.LANGFUSE_ENCRYPTION_KEY_SEED || \"\").digest(\"hex\"))')\n export ENCRYPTION_KEY\n unset LANGFUSE_ENCRYPTION_KEY_SEED\n exec dumb-init -- ./worker/entrypoint.sh \"$$@\"\n - --\n\nservices:\n # --- infrastructure sidecars -------------------------------------------------\n clickhouse:\n image: clickhouse/clickhouse-server:25.8.28.1\n environment:\n CLICKHOUSE_DB: default\n CLICKHOUSE_USER: clickhouse\n CLICKHOUSE_PASSWORD: ${MALMO_SECRET_CLICKHOUSE_PASSWORD}\n volumes:\n - ./data/clickhouse:/var/lib/clickhouse\n - ./data/clickhouse-logs:/var/log/clickhouse-server\n # The server writes its pid file and local sockets under /var/run, which is root-owned\n # in the image and not writable by the non-root identity malmo pins.\n tmpfs:\n - /var/run:size=16m\n - /tmp:size=64m\n healthcheck:\n test: [\"CMD-SHELL\", \"wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1\"]\n interval: 10s\n timeout: 5s\n retries: 12\n start_period: 60s\n\n minio:\n image: minio/minio:RELEASE.2025-09-07T16-13-09Z\n # Seeds the bucket before the server starts: with path-style addressing a directory\n # directly under the data root IS a bucket, and Langfuse never issues a CreateBucket.\n entrypoint: [\"/bin/sh\", \"-c\"]\n command:\n - |\n mkdir -p /data/langfuse\n exec minio server --address \":9000\" --console-address \":9001\" /data\n environment:\n MINIO_ROOT_USER: langfuse\n MINIO_ROOT_PASSWORD: ${MALMO_SECRET_MINIO_PASSWORD}\n volumes:\n - ./data/minio:/data\n healthcheck:\n test: [\"CMD-SHELL\", \"mc ready local || exit 1\"]\n interval: 10s\n timeout: 5s\n retries: 12\n start_period: 30s\n\n # --- the app -----------------------------------------------------------------\n langfuse-worker:\n image: langfuse/langfuse-worker:3.224.0\n entrypoint: *worker-entrypoint\n command: [\"node\", \"worker/dist/index.js\"]\n environment: *langfuse-env\n depends_on:\n clickhouse:\n condition: service_healthy\n minio:\n condition: service_healthy\n\n langfuse-web:\n image: langfuse/langfuse:3.224.0\n entrypoint: *web-entrypoint\n # The image CMD branches on NEXT_PUBLIC_LANGFUSE_CLOUD_REGION to add Datadog tracing;\n # that is Langfuse Cloud only, so the self-hosted branch is restated directly.\n command: [\"node\", \"./web/server.js\", \"--keepAliveTimeout\", \"110000\"]\n environment:\n \u003c\u003c: *langfuse-env\n NEXTAUTH_SECRET: ${MALMO_SECRET_NEXTAUTH_SECRET}\n HOSTNAME: \"0.0.0.0\"\n PORT: \"3000\"\n # Media (multimodal trace payloads, dataset attachments) is the one bucket a client\n # OUTSIDE the compose network touches directly: the web tier hands out a presigned S3\n # URL and the SDK or the browser fetches it. So the endpoint the URL is minted against\n # has to be the app's own public origin, which the proxy path-routes back to MinIO.\n #\n # This override is deliberately scoped to langfuse-web and NOT put in the shared\n # anchor. Langfuse has no separate \"external endpoint\" var for media (only batch export\n # has one), so the single endpoint serves both presigning and direct server-side S3\n # calls. Verified against v3.224.1: langfuse-web only ever calls getSignedUrl /\n # getSignedUploadUrl on the media client, while langfuse-worker does the real\n # server-side object work (project + trace deletion, retention cleanup, OTel media\n # upload). Giving the worker the public origin would send those calls out through the\n # box's ingress and its owner-only auth gate; leaving web on the internal origin would\n # mint upload URLs pointing at http://minio:9000, which no external client can resolve.\n LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${MALMO_APP_URL}\n depends_on:\n clickhouse:\n condition: service_healthy\n minio:\n condition: service_healthy\n\n # --- the single browser-facing origin ----------------------------------------\n # Everything is Langfuse except the S3 bucket prefix, which goes to MinIO so that the\n # presigned media URLs Langfuse mints against ${MALMO_APP_URL} actually resolve. The Host\n # header is forwarded unchanged (Caddy's default), which is what keeps the SigV4 signature\n # valid across the hop. This is the app's main_service.\n proxy:\n image: caddy:2.11.4\n entrypoint: [\"/bin/sh\", \"-c\"]\n command:\n - |\n cat \u003e /tmp/Caddyfile \u003c\u003c'CADDYEOF'\n :8080 {\n @bucket path /langfuse/*\n handle @bucket {\n reverse_proxy http://minio:9000 {\n # Media objects are uploaded and downloaded whole; a buffered body would\n # cap them at Caddy's default and stall large multimodal payloads.\n flush_interval -1\n }\n }\n\n handle {\n reverse_proxy http://langfuse-web:3000\n }\n }\n CADDYEOF\n cp /usr/bin/caddy /tmp/caddy\n exec /tmp/caddy run --config /tmp/Caddyfile --adapter caddyfile\n environment:\n XDG_DATA_HOME: /tmp/caddy-data\n XDG_CONFIG_HOME: /tmp/caddy-config\n # No tmpfs on /tmp here, deliberately. The Caddyfile heredoc, the copied caddy binary and\n # Caddy's data/config dirs all live under /tmp, and docker mounts every `tmpfs:` entry\n # noexec by default, so the copied binary cannot be executed off one: the run dies with\n # \"/tmp/caddy: Permission denied\" (and, if the tmpfs is small, an ENOSPC from cp first).\n # The container's own writable layer has neither problem. Same shape as baserow's caddy.\n depends_on:\n - langfuse-web\n - minio\n", "images": { "caddy:2.11.4": { @@ -1479,55 +1553,6 @@ } } }, - { - "id": "uptimepage", - "name": "Uptimepage", - "version": "0.1.0", - "short_description": "Uptime monitoring for the things you host, with a public status page built in.", - "long_description": "## Overview\nUptimepage watches the services you care about and tells you when they go\ndown, instead of paying a monitoring SaaS to do it or trusting one to watch\nits own host. It checks websites, servers, certificates and domains on a\nschedule, records the history, and publishes a status page you can share\nwith whoever depends on those services.\n\n## Features\n- Checks over HTTP, TCP and DNS, plus TLS certificate and domain expiry\n- Response time broken down into DNS, connect, TLS and first byte, kept as history\n- Alerts to Slack, Discord, Telegram, ntfy, Pushover, PagerDuty, webhooks and email\n- A public status page with incident history, uptime and latency charts\n- Incidents you can narrate as they unfold, so readers see what is happening\n- A full REST API and a Terraform provider, so monitors can be managed as code\n\n## Setting up\nThe first owner account is created for you on first boot. Open the app's logs\nfrom its page in malmo and look for the block that begins \"Owner account\nready\"; it contains a single-use sign-in link. The link expires 15 minutes\nafter it is printed, so if it has gone stale, restart the app and a fresh one\nis printed. That same block also carries a full-access API token for the REST\nAPI and the Terraform provider.\n\n## Good to know\nSign-in is by that link only unless you configure more. GitHub and Google\nsign-in are left switched off on purpose: uptimepage has no way to restrict\nwho may register, so turning either on would let any GitHub or Google account\ncreate its own workspace on your instance. Fill in the credentials below only\nif you want that.\n\nInvitation and sign-in emails need a Resend account. Without one they are\nwritten to the app's log rather than sent, which is why the first sign-in\nlink above is read from the log. Outage alerts are separate and do go out\nover normal email once an administrator binds a mail provider in malmo.\n\nThis release carries limits from uptimepage's hosted service, and a\nself-hosted workspace lands on the free tier of those: up to 20 monitors, a\nminimum check interval of 60 seconds, 3 members, and one status page. Custom\ndomains, white labelling and SMS alerts are off. Nothing here reads a\nlicence or phones home, so the caps are the app's own defaults rather than\nanything malmo adds.\n\nBrowser flow checks, which drive a real browser through a login or a\ncheckout, are not available: they need a build of uptimepage that ships a\nbrowser engine, and the published image does not.\n\nMonitoring targets on your own network is switched off, so this watches\nservices reachable over the internet. It keeps every check result it has ever\ntaken, and runs its own analytics database to do it, so it is heavier than\nmost apps here: give it around four gigabytes of memory to be comfortable.\n", - "categories": [ - "developer-tools" - ], - "author": { - "name": "Uptimepage", - "url": "https://uptimepage.dev" - }, - "license": "AGPL-3.0", - "links": { - "homepage": "https://uptimepage.dev", - "source": "https://github.com/uptimepage/uptimepage", - "support": "https://uptimepage.dev/docs" - }, - "changelog_url": "https://github.com/uptimepage/uptimepage/releases", - "footprint": { - "image_download_bytes": 286901438, - "image_disk_bytes": 957699584, - "estimated_state": "45MB" - }, - "icon_file": "icon.svg", - "screenshots": [ - "screenshots/0.png", - "screenshots/1.png" - ], - "environments": [ - "appliance", - "hosted" - ], - "manifest": "id: uptimepage\nmanifest_version: 1\nname: Uptimepage\nversion: \"0.1.0\"\ndescription:\n short: \"Uptime monitoring for the things you host, with a public status page built in.\"\n long: |\n ## Overview\n Uptimepage watches the services you care about and tells you when they go\n down, instead of paying a monitoring SaaS to do it or trusting one to watch\n its own host. It checks websites, servers, certificates and domains on a\n schedule, records the history, and publishes a status page you can share\n with whoever depends on those services.\n\n ## Features\n - Checks over HTTP, TCP and DNS, plus TLS certificate and domain expiry\n - Response time broken down into DNS, connect, TLS and first byte, kept as history\n - Alerts to Slack, Discord, Telegram, ntfy, Pushover, PagerDuty, webhooks and email\n - A public status page with incident history, uptime and latency charts\n - Incidents you can narrate as they unfold, so readers see what is happening\n - A full REST API and a Terraform provider, so monitors can be managed as code\n\n ## Setting up\n The first owner account is created for you on first boot. Open the app's logs\n from its page in malmo and look for the block that begins \"Owner account\n ready\"; it contains a single-use sign-in link. The link expires 15 minutes\n after it is printed, so if it has gone stale, restart the app and a fresh one\n is printed. That same block also carries a full-access API token for the REST\n API and the Terraform provider.\n\n ## Good to know\n Sign-in is by that link only unless you configure more. GitHub and Google\n sign-in are left switched off on purpose: uptimepage has no way to restrict\n who may register, so turning either on would let any GitHub or Google account\n create its own workspace on your instance. Fill in the credentials below only\n if you want that.\n\n Invitation and sign-in emails need a Resend account. Without one they are\n written to the app's log rather than sent, which is why the first sign-in\n link above is read from the log. Outage alerts are separate and do go out\n over normal email once an administrator binds a mail provider in malmo.\n\n This release carries limits from uptimepage's hosted service, and a\n self-hosted workspace lands on the free tier of those: up to 20 monitors, a\n minimum check interval of 60 seconds, 3 members, and one status page. Custom\n domains, white labelling and SMS alerts are off. Nothing here reads a\n licence or phones home, so the caps are the app's own defaults rather than\n anything malmo adds.\n\n Browser flow checks, which drive a real browser through a login or a\n checkout, are not available: they need a build of uptimepage that ships a\n browser engine, and the published image does not.\n\n Monitoring targets on your own network is switched off, so this watches\n services reachable over the internet. It keeps every check result it has ever\n taken, and runs its own analytics database to do it, so it is heavier than\n most apps here: give it around four gigabytes of memory to be comfortable.\nicon: ./icon.svg\nscreenshots: [./screenshots/screenshot-dashboard.png, ./screenshots/screenshot-monitors.png]\ncategories: [developer-tools]\nauthor:\n name: Uptimepage\n url: https://uptimepage.dev\nlicense: AGPL-3.0\nlinks:\n homepage: https://uptimepage.dev\n source: https://github.com/uptimepage/uptimepage\n support: https://uptimepage.dev/docs\nchangelog_url: https://github.com/uptimepage/uptimepage/releases\n\ncompose_file: compose.yml\nmain_service: uptimepage\nmain_port: 8080\npreferred_slugs: [uptimepage, uptime, status]\n\n# /readyz is the honest signal, not /healthz: healthz answers as soon as the HTTP server is\n# up, while readyz 503s (naming the failing store) until BOTH Postgres and ClickHouse are\n# reachable, which is what \"usable\" means here. The app applies its own Postgres and\n# ClickHouse migrations at startup, and ClickHouse itself is the slow pole on a cold first\n# boot, hence the wide start period.\nhealth_probe:\n path: /readyz\n healthy_status: [200]\n start_period: 180s\n\n# FORCED by the ClickHouse sidecar. The official ClickHouse entrypoint self-chowns\n# /var/lib/clickhouse and privilege-drops when started as root, and cap_drop: ALL forbids\n# both (verified: as root under cap_drop: ALL it dies on\n# `chown: changing ownership of '/var/lib/clickhouse/': Operation not permitted`). Under a\n# malmo-allocated non-root uid over pre-chowned binds it skips that path and boots clean.\n# malmo pins ONE identity across every container in an app, so the app image has to\n# tolerate that uid too, and it does: it is a distroless static binary that writes nothing\n# outside its databases (verified booting as an arbitrary uid under cap_drop: ALL and\n# no-new-privileges).\nservice_user: true\n\n# The app itself is a static Rust binary and is not the weight here. ClickHouse is: it is\n# capped at 2 GB of server memory in the compose and unconfigured would take far more. The\n# managed Postgres runs on the same box too. Lower this only from a measurement.\nresources:\n recommended: { memory: 4G, cpu: 2.0 }\n\n# Only ClickHouse is on disk here. Everything a user creates (monitors, orgs, incidents,\n# status pages, tokens) lives in the managed Postgres the brain provisions, which the brain\n# also stores and backs up, so it is not this app's directory and not counted below.\n# ./data/clickhouse is the check-result history behind every uptime and latency rollup.\n# ./data/clickhouse-log is the engine's own server log, which it refuses to start without\n# but which is pure scratch.\nstorage:\n data_volumes:\n - ./data/clickhouse\n cache_volumes:\n - ./data/clickhouse-log\n # Measured with `du -sb` on the instance's data/ right after /readyz first returned 200:\n # clickhouse 39,277,646 + clickhouse-log 1,940,444 = 41,218,090. Almost all of it is\n # ClickHouse's empty-table baseline, not user data.\n estimated_size: 45MB\n\n# Postgres 18, and the version is a hard requirement rather than a preference: ten of the\n# app's migrations declare `DEFAULT uuidv7()`, an 18 built-in, so nothing older can run this\n# app. The two extensions the migrations create (citext, pg_trgm) are trusted contrib, which\n# the app's own role may create because it owns the database. See compose.yml.\nservices:\n database:\n type: postgres\n version: \"18\"\n\n# fingerprint_salt salts the IP and user-agent hashes in login_attempts and sessions, and\n# rotating it trips a startup guard, so it needs exactly the generated-once-then-stable\n# contract these give. credentials_kek wraps the per-check basic_auth and bearer_token\n# credentials, which are stored in plaintext when it is empty, and which the app refuses to\n# boot on if it does not decode to exactly 32 bytes (malmo's 32-byte default is correct;\n# do not set `bytes:` on it). clickhouse_password is the sidecar credential and never leaves\n# the compose network. There is no postgres_password: the brain owns the managed engine's\n# credential and injects the whole DSN.\nsecrets:\n - name: fingerprint_salt\n - name: credentials_kek\n - name: clickhouse_password\n\nmail:\n optional: true\n\n# Outbound only: the whole job is reaching the services being watched. lan stays false and\n# the app's own private-target guard stays on, so this monitors what is reachable over the\n# internet rather than hosts on the user's network.\npermissions:\n internet: true\n lan: false\n\nconfig:\n - app_env: UPTIMEPAGE_EMAIL__PROVIDER\n title: \"Send invitation and sign-in emails\"\n type: enum\n options: [\"log\", \"resend\"]\n default: \"log\"\n description: \u003e\n Leave on log to write invitation and sign-in links into the app's log,\n where you read them yourself. Switch to resend to have them delivered as\n real email, which is what makes inviting a teammate work. Choosing resend\n also requires the API key below; without it the app will not start.\n - app_env: UPTIMEPAGE_EMAIL__RESEND__API_KEY\n title: \"Resend API key\"\n secret: true\n description: \u003e\n From your account at resend.com, under API keys. Required when the setting\n above is resend, ignored otherwise.\n - app_env: UPTIMEPAGE_EMAIL__FROM_ADDRESS\n title: \"Send invitation emails from\"\n description: \u003e\n The address invitation and sign-in emails come from. It has to be an\n address on a domain you have verified with Resend.\n - app_env: UPTIMEPAGE_AUTH__GITHUB__CLIENT_ID\n title: \"GitHub sign-in client ID\"\n description: \u003e\n Optional, and it opens registration to the public: uptimepage cannot\n restrict who signs up, so anyone with a GitHub account could create their\n own workspace on your instance. Leave both GitHub fields empty to keep\n sign-in to the link printed in the app's log. From a GitHub OAuth app, with\n the callback URL set to your app address followed by /auth/github/callback.\n - app_env: UPTIMEPAGE_AUTH__GITHUB__CLIENT_SECRET\n title: \"GitHub sign-in client secret\"\n secret: true\n description: \u003e\n The client secret for the GitHub OAuth app above. Read the note on the\n client ID field before filling these in.\n - app_env: UPTIMEPAGE_AUTH__GOOGLE__CLIENT_ID\n title: \"Google sign-in client ID\"\n description: \u003e\n Optional, and it opens registration the same way the GitHub fields do:\n anyone with a Google account could create their own workspace on your\n instance. From a Google Cloud OAuth client, with the redirect URI set to\n your app address followed by /auth/google/callback.\n - app_env: UPTIMEPAGE_AUTH__GOOGLE__CLIENT_SECRET\n title: \"Google sign-in client secret\"\n secret: true\n description: \u003e\n The client secret for the Google OAuth client above. Read the note on the\n client ID field before filling these in.\n\nimages:\n clickhouse/clickhouse-server:26.3:\n digest: sha256:422be85ae7344058369cdd366ac0efea9daa8428b55c9cf50258e83a7d12fcb3\n download_bytes: 265290337\n disk_bytes: 899427840\n ghcr.io/uptimepage/uptimepage:0.1.0:\n digest: sha256:ecd89fc5c2688af77c5d82dba98ccca864150919359c33be11f1676c1a321b0f\n download_bytes: 21611101\n disk_bytes: 58271744\n", - "compose": "# uptimepage adapted for malmo's Tier-3 sandbox, from the repo-root docker-compose.yml\n# at v0.1.0. Upstream labels that file \"local development only\" (published ports, no auth\n# in front of the API); deployment/docker-compose.yml is its production shape, and most of\n# what that file adds is the reverse proxy and observability plumbing malmo already owns.\n#\n# malmo changes:\n# - postgres routed to a MANAGED service, `services.database` at version 18. This app needs\n# 18 and cannot run on anything older: ten migrations (004, 005, 007, 010, 017, 018, 024,\n# 025, 027, 028) declare `DEFAULT uuidv7()`, and uuidv7() is a PostgreSQL 18 built-in.\n# Because the calls are column DEFAULTs rather than one-time DDL, a shim would have to\n# survive for the life of the instance, so the version requirement is real. Do not lower\n# it: on anything below 18 migration 4 fails and the app never becomes ready.\n# EXTENSIONS: migrations/postgres/004_organizations.up.sql creates citext and pg_trgm.\n# Both are TRUSTED contrib and malmo's per-app role OWNS its database, so the role may\n# create them without superuser (PG13+). SERVICE_PROVISIONING.md # Database extensions\n# lists both as returning CREATE EXTENSION for the app's own role.\n# - clickhouse KEPT as a sidecar. There is no managed ClickHouse type and none is planned,\n# so per the checklist's bundled-engine row the sidecar IS the intended path, not a\n# block. It runs non-root over pre-chowned binds: the official entrypoint chowns and\n# privilege-drops ONLY when started as root, so the malmo-allocated identity from\n# `service_user: true` skips both paths. Verified booting under\n# `--user 10001:10001 --cap-drop ALL --security-opt no-new-privileges`: the entrypoint\n# still creates the CLICKHOUSE_USER + CLICKHOUSE_DB (it writes users.d/ inside the\n# image, not the bind), and both binds mount clean.\n# - ClickHouse memory caps come from upstream's deployment/clickhouse-config.xml, but as\n# server-setting ARGS rather than a mounted file, so this app ships no config artifact.\n# NOTE the bare `--` separator: the image entrypoint forwards args to clickhouse-server\n# after `--config-file`, and settings passed without it are rejected outright\n# (`Unknown option specified: max_server_memory_usage`). Verified applied via\n# system.server_settings. Upstream sizes these for a 4 GB box; a malmo box is not a\n# 64 GB analytics host, and unconfigured ClickHouse will happily take 8+ GB.\n# - The second app replica (uptimepage_green, a YAML-anchor merge of blue in\n# deployment/) is upstream's zero-downtime deploy dance, not a requirement. One service.\n# - caddy dropped: malmo terminates TLS and routes. Dropping it also dodges the\n# file-capability-exec trap that cost us plane -- upstream's is an xcaddy build, and\n# stock caddy images setcap cap_net_bind_service on the binary, which cap_drop: ALL\n# makes unexecutable.\n# - alloy (binds /var/run/docker.sock) and node-exporter (binds / and /proc) dropped:\n# both are profiles: [\"metrics\"], off by default, and Grafana-Cloud-only.\n# - agent dropped (profiles: [\"agent\"], the multi-region probe). The default all-in-one\n# mode has the control plane probe its own region (scheduler.enabled = true).\n# - loadtest dropped (profiles: [\"loadtest\"]); it is the only service carrying sysctls.\n# - Host `ports:` mappings dropped throughout. main_port: 8080 is taken from the\n# container side; 9090 (Prometheus metrics) stays internal, which matters -- it carries\n# no auth of its own.\n# - Named volumes -\u003e relative ./data/ binds.\n# - build: -\u003e the published image, pinned to the immutable 0.1.0 release tag. Not\n# `latest`/`main`: CI publishes those as floating tags off the default branch.\n#\n# Two adaptations worth reading before changing anything:\n#\n# - THE BOOTSTRAP SIDECAR. Upstream's first-owner step is a CLI command the operator runs\n# on the host (`docker compose exec uptimepage uptimepage bootstrap-owner --email ...`),\n# which is the `host-only-admin` trap: hosted gives the operator no shell, and this\n# image is distroless so there is not even a shell to exec into. src/main.rs dispatches\n# on plain argv[1] (no clap) and src/bootstrap.rs::run_owner is written and documented\n# as idempotent on the account and the org, so the same image runs as a one-shot service\n# instead. It prints the sign-in link to STDOUT (verified in the v0.1.0 source and at\n# boot), which malmo surfaces to the instance owner as an app log.\n# `restart: on-failure` is NOT a stray. Its first job is the one that matters: it keeps\n# the brain from forcing restart: unless-stopped onto this service (APP_LIFECYCLE.md\n# # admission override, signal (a)), which would turn a completed seed into a restart\n# loop that never reaches a terminal state. It doubles as the retry primitive for\n# ordering: the app image is distroless and can carry no docker HEALTHCHECK, so\n# `depends_on: uptimepage` waits for the container to START, not to be usable, and a\n# bootstrap that arrives too early simply exits non-zero and is retried.\n# Ordering against the MIGRATIONS turned out not to need it: connect_pool runs the\n# migration chain itself, under sqlx's advisory lock, so whichever of the two services\n# gets there first migrates and the other waits. Observed at boot -- the bootstrap\n# container logged the full partition chain and then \"postgres ready\" before seeding.\n# On the sidecar this also covered Postgres coming up; with managed Postgres the brain\n# has already waited for the engine before either container starts.\n#\n# - OAUTH IS DELIBERATELY LEFT UNCONFIGURED. uptimepage has no signup gate anywhere in\n# the tree (there is no signup_enabled / allow_signup), and docs/authentication.md is\n# explicit that an OAuth callback auto-creates a signup org for a new sign-up. On a\n# hosted box with a public URL, configured OAuth means any GitHub or Google user can\n# register on the owner's instance and get their own org. Tenant isolation is strong\n# (CI-enforced org_id predicates, a Postgres trigger, an isolation integration test),\n# so that is resource abuse rather than data leakage, but it is not what a self-hoster\n# expects. Upstream already 404s an incompletely-configured provider\n# (AUTH_METHOD_UNAVAILABLE), so leaving the client credentials unset closes the door for\n# free and the owner still gets in via the bootstrap link. The manifest's config: fields\n# let a user open it on purpose, and say so in their descriptions.\n\nx-app-env: \u0026app-env\n # Managed Postgres 18 (manifest `services.database`). The brain builds the whole DSN and\n # injects it, so nothing here assembles a URL out of parts or escapes a password.\n UPTIMEPAGE_STORAGE__POSTGRES__URL: ${MALMO_SERVICE_DATABASE_DSN}\n\n # ClickHouse sidecar. Reached over its HTTP interface on the app network.\n UPTIMEPAGE_STORAGE__CLICKHOUSE__URL: http://clickhouse:8123\n UPTIMEPAGE_STORAGE__CLICKHOUSE__DATABASE: monitor\n UPTIMEPAGE_STORAGE__CLICKHOUSE__USER: uptimepage\n UPTIMEPAGE_STORAGE__CLICKHOUSE__PASSWORD: ${MALMO_SECRET_CLICKHOUSE_PASSWORD}\n\n # Salts IP / user-agent hashes in login_attempts + sessions. Load-bearing in both\n # directions: an empty value leaves the hashes unsalted, and ROTATING it trips a startup\n # guard that refuses to boot without UPTIMEPAGE_AUTH_ACCEPT_SALT_ROTATION=1. That is\n # exactly the generate-once-then-hold-stable contract MALMO_SECRET_* gives.\n UPTIMEPAGE_AUTH__FINGERPRINT_SALT: ${MALMO_SECRET_FINGERPRINT_SALT}\n\n # 32-byte KEK wrapping the per-check basic_auth / bearer_token credentials. When empty\n # the app logs a warning and stores those credentials PLAINTEXT in check_spec, so this\n # is security-relevant rather than decorative. The brain emits base64url; verified\n # accepted -- security/crypto.rs decodes through decode_base64_flexible, then asserts\n # 32 bytes, which a 32-byte MALMO_SECRET_* satisfies.\n UPTIMEPAGE_SECURITY__CREDENTIALS_KEK_BASE64: ${MALMO_SECRET_CREDENTIALS_KEK}\n\n # Every sign-in link, status-page URL and OAuth redirect is built from this.\n UPTIMEPAGE_AUTH__PUBLIC_BASE_URL: ${MALMO_APP_URL}\n\n # Plain HTTP has to work: \u003cslug\u003e.local is always installed as a route even on a box with\n # the secure-URLs toggle on (MALMO_NETWORK.md), so a Secure-only cookie would silently\n # fail to stick for anyone who types the .local name. The cost is that the session cookie\n # carries no Secure attribute on hosted either; recorded as a limitation in status.yml.\n UPTIMEPAGE_AUTH__SESSION__COOKIE_SECURE: \"false\"\n\n # Docker's private ranges: malmo fronts every app with Caddy, and without this every\n # ip_hash in the database collapses to the proxy's address, making the abuse indexes and\n # the magic-link ip_hint useless. security.trusted_proxies is one of only two\n # list-parse keys the config loader accepts from the environment (comma-separated).\n UPTIMEPAGE_SECURITY__TRUSTED_PROXIES: \"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16\"\n\n # Single-tenant posture. subdomain_public_routes needs a wildcard cert malmo does not\n # hand an app, and PANICS at boot on an empty base_domain; marketing.enabled half-set\n # fails its own boot invariants and upstream says to leave it off for self-host.\n UPTIMEPAGE_TENANCY__PATH_BASED_PUBLIC_ROUTES: \"true\"\n UPTIMEPAGE_TENANCY__SUBDOMAIN_PUBLIC_ROUTES: \"false\"\n UPTIMEPAGE_MARKETING__ENABLED: \"false\"\n\n # Outage alerting over SMTP, wired to whichever provider the admin binds in malmo.\n # This is [notifications.email], the ALERT channel -- NOT [email], the transactional one\n # (invitations, magic-link sign-in), whose provider enum is resend|log|memory and has no\n # SMTP member at all, so MALMO_MAIL_* cannot drive it. Unbound, enabled stays \"false\" and\n # the channel no-ops instead of failing target opt-ins at send time.\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__ENABLED: \"${MALMO_MAIL_HOST:+true}\"\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__SMTP_HOST: \"${MALMO_MAIL_HOST:-}\"\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__SMTP_PORT: \"${MALMO_MAIL_PORT:-587}\"\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__SMTP_USER: \"${MALMO_MAIL_USER:-}\"\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__SMTP_PASSWORD: \"${MALMO_MAIL_PASSWORD:-}\"\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__FROM: \"${MALMO_MAIL_FROM:-}\"\n # starttls true = plain TCP upgraded via STARTTLS; false = implicit TLS on smtp_port.\n # MALMO_MAIL_USE_TLS is the brain's boolean projection of exactly that distinction, and\n # the app compares it as a real bool (not the \"1\"/\"0\" string vocabulary that bit plane).\n UPTIMEPAGE_NOTIFICATIONS__EMAIL__STARTTLS: \"${MALMO_MAIL_USE_TLS:-true}\"\n\n # Derived from the app's own URL rather than asked for on the install form. Setting them\n # unconditionally is safe: OauthClientConfig::is_configured() requires client_id AND\n # client_secret AND redirect_url, so a provider stays off (404 AUTH_METHOD_UNAVAILABLE)\n # until the user fills in the credentials on purpose. See the OAuth note in the header.\n UPTIMEPAGE_AUTH__GITHUB__REDIRECT_URL: ${MALMO_APP_URL}/auth/github/callback\n UPTIMEPAGE_AUTH__GOOGLE__REDIRECT_URL: ${MALMO_APP_URL}/auth/google/callback\n\n UPTIMEPAGE_OBSERVABILITY__LOG_FORMAT: json\n UPTIMEPAGE_OBSERVABILITY__LOG_LEVEL: info\n\nservices:\n clickhouse:\n image: clickhouse/clickhouse-server:26.3\n environment:\n CLICKHOUSE_DB: monitor\n CLICKHOUSE_USER: uptimepage\n CLICKHOUSE_PASSWORD: ${MALMO_SECRET_CLICKHOUSE_PASSWORD}\n # See the header note: the bare `--` is required, not decoration.\n command:\n - \"--\"\n - \"--max_server_memory_usage=2000000000\"\n - \"--mark_cache_size=536870912\"\n - \"--uncompressed_cache_size=0\"\n volumes:\n # TWO binds, both required. The server refuses to start without a writable log dir,\n # and it is not under the datadir.\n - ./data/clickhouse:/var/lib/clickhouse\n - ./data/clickhouse-log:/var/log/clickhouse-server\n # Not in malmo's admission reject set (that covers privileged, cap_add, network_mode,\n # a numeric user:, host ports). ClickHouse opens a lot of files per part.\n ulimits:\n nofile:\n soft: 262144\n hard: 262144\n healthcheck:\n # /ping over the HTTP interface needs no credentials, which keeps the ClickHouse\n # password out of `docker inspect` -- clickhouse-client would put it there.\n test: [\"CMD\", \"wget\", \"-q\", \"-O-\", \"http://127.0.0.1:8123/ping\"]\n interval: 10s\n timeout: 5s\n retries: 20\n start_period: 40s\n\n uptimepage:\n image: ghcr.io/uptimepage/uptimepage:0.1.0\n environment: *app-env\n # Only ClickHouse is gated here now. Managed Postgres needs no depends_on: the brain\n # provisions it and waits for it to report ready before it starts the app's containers\n # (SERVICE_PROVISIONING.md), and it is not a service in this file to depend on.\n depends_on:\n clickhouse:\n condition: service_healthy\n\n # One-shot: creates the owner account + org and prints a single-use sign-in link to\n # stdout. Idempotent -- re-running reuses the account and the org and only mints a fresh\n # link and token, so this is safe on every boot. See the header note for why\n # `restart: on-failure` is the ordering primitive here.\n bootstrap:\n image: ghcr.io/uptimepage/uptimepage:0.1.0\n restart: on-failure\n command: [\"bootstrap-owner\", \"--email\", \"owner@uptimepage.malmo\", \"--org-name\", \"My Org\"]\n environment: *app-env\n depends_on:\n - uptimepage\n", - "images": { - "clickhouse/clickhouse-server:26.3": { - "digest": "sha256:422be85ae7344058369cdd366ac0efea9daa8428b55c9cf50258e83a7d12fcb3", - "download_bytes": 265290337, - "disk_bytes": 899427840 - }, - "ghcr.io/uptimepage/uptimepage:0.1.0": { - "digest": "sha256:ecd89fc5c2688af77c5d82dba98ccca864150919359c33be11f1676c1a321b0f", - "download_bytes": 21611101, - "disk_bytes": 58271744 - } - } - }, { "id": "vaultwarden", "name": "Vaultwarden", diff --git a/internal/catalog/wire_test.go b/internal/catalog/wire_test.go index 3a517bb..31ec199 100644 --- a/internal/catalog/wire_test.go +++ b/internal/catalog/wire_test.go @@ -213,10 +213,11 @@ func TestNoUnmodeledFields(t *testing.T) { } // TestExternalCostsSurviveTheDigest is the byte-fidelity proof for the newest -// field on the wire. No published app declares a cost yet, so the pinned fixture -// cannot exercise it — this builds a snapshot that does, the way the control -// plane marshals one, and checks the box reproduces the digest over it and -// projects it onto the detail page. +// field on the wire. It builds a snapshot that declares a cost, the way the +// control plane marshals one, and checks the box reproduces the digest over it +// and projects it onto the detail page. The pinned fixture now carries a +// published app that declares costs, but the case is still constructed here so +// the proof does not depend on which apps happen to be published. // // The digest is computed over json.Marshal of the app array, so a mirror that // declared ExternalCosts in a different position than the control plane's App