diff --git a/.dockerignore b/.dockerignore
index 3dcf8027f..6aa65db2f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,6 +7,7 @@
**/node_modules
**/.next
**/dist
+**/build
**/release-dist
apps/desktop/out
apps/desktop/.vite
@@ -16,10 +17,34 @@ apps/desktop/.vite
.gitignore
.github
-# Secrets / local env — MUST NOT enter the build context or image
-.env
-.env.*
-!.env.example
+# Claude worktrees — full duplicate checkouts of this repo (multi-GB), each
+# carrying its own tracked env files. Nothing builds from them.
+.claude/worktrees
+
+# Secrets / local env — MUST NOT enter the build context or image.
+#
+# The `**/` prefixes are load-bearing: a pattern without one is matched only
+# against the CONTEXT-ROOT-relative path, so the previous `.env` / `.env.*`
+# covered a root-level `.env` and nothing deeper. Every per-app env file was
+# therefore in the context of every image. Two consequences, both real:
+#
+# • `docker build -f apps/api/Dockerfile .` from a working checkout shipped
+# the operator's own apps/api/.env — DB URL, auth secret, provider keys —
+# into openship-api, because Dockerfile:10 is `COPY apps/ ./apps/` and the
+# runtime stage copies apps/api forward. Same exposure via
+# apps/dashboard/Dockerfile. CI escaped it only because a clean checkout
+# has no untracked .env.
+# • apps/email/client/.env.development reached the webmail builder, where
+# `node` is bun and bun auto-loads it whenever NODE_ENV is unset — which is
+# how GH-567 froze `http://localhost:3000` into the published client
+# bundle. (Also fixed at the source in apps/email/scripts/build-release.ts;
+# this is the second lock on the same door.)
+#
+# No Dockerfile copies a `.env*` out of the context — every env COPY is a
+# `--from=builder` — so excluding them cannot break a build.
+**/.env
+**/.env.*
+!**/.env.example
# Caches + logs
**/.turbo
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc9b989f0..287d9f70f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,8 +4,11 @@ on:
pull_request: {}
push:
branches: [main]
- # A tag is what actually ships; the e2e-docker job runs on tags (see its
- # `if` gate) so a release is proven restorable before it goes out.
+ # Tags run typecheck + tests here for the record; nothing in THIS file gates a
+ # release, because workflows are not ordered against one another. The checks that
+ # BLOCK a publish — this same suite plus the real-daemon E2E matrix, which used to
+ # live in this file and only ever ran alongside the release it claimed to gate —
+ # are in release-gate.yml, which Release and Docker images both list in `needs:`.
tags: ["v*"]
workflow_dispatch: {}
@@ -86,35 +89,28 @@ jobs:
# script (@repo/core, @repo/adapters, @repo/db [PGlite — no external DB],
# apps/api, apps/dashboard). Packages resolve to src, so no build needed.
# apps/api excludes test/e2e/** here — those need a daemon and run in the
- # e2e-docker job below.
+ # e2e-docker job in release-gate.yml, where they gate the publish.
- name: Run tests
run: bun run test
- # Rollback and restore against a REAL Docker daemon. This is the only job that
- # proves those paths work at all; every other test in the repo mocks the runtime.
+ # The webmail server's own suite, which nothing else runs.
#
- # MANUAL by design: runs only on a manual `workflow_dispatch` and on release
- # tag pushes (v*) — never on main pushes or PRs, which stay fast (typecheck +
- # tests). The tag run is the release-restorability gate.
+ # GH-220: apps/email/server/test/{sanitize,from-header,list-snippet}.test.ts had no
+ # runner at all. They are `bun:test` files, and the root `test` script is
+ # `turbo run test --filter=!@repo/email` → vitest, so turbo never reached them; the
+ # server is not a root workspace member either (workspaces is apps/* + packages/*,
+ # which matches apps/email but not its subdirectories), so its deps are not installed
+ # by the root install. Net effect: 35 assertions were green on someone's laptop and
+ # unreachable from every pipeline — including sanitize.test.ts, which pins the fix for
+ # the CSS url()/@import read-receipt leak (GHSA-3hcp-c4c7-6m8p) and asserts the read
+ # pane stays inert. That is exactly the kind of test that must not rot.
#
- # `RUN_DOCKER_E2E=1` is what makes it honest: without it the suite skips when no
- # daemon answers, which is exactly how these cases sat green-and-unrun for months.
- # With it, an unreachable daemon fails in `beforeAll` instead of reporting skipped.
- #
- # `fast` is every daemon-level and full-cycle case (~5 min); `heavy` is
- # rollback-build-restore alone (~225s cold, and fileParallelism is off, so it
- # holds the whole suite up). Both run here — see E2E_SCOPE in
- # apps/api/vitest.e2e.config.ts.
- e2e-docker:
- name: E2E (real Docker, ${{ matrix.scope }})
- # Manual + release tags only. Never on main pushes or PRs.
- if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }}
+ # Its own job rather than a turbo target: these need `bun test` (not vitest) and a
+ # separate install rooted in apps/email/server. Independent and parallel, so it cannot
+ # slow the jobs above.
+ webmail-server-test:
+ name: Test webmail server
runs-on: ubuntu-latest
- timeout-minutes: 30
- strategy:
- fail-fast: false
- matrix:
- scope: [fast, heavy]
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -128,22 +124,18 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
- key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
+ key: ${{ runner.os }}-bun-webmail-${{ hashFiles('apps/email/server/bun.lock') }}
restore-keys: |
+ ${{ runner.os }}-bun-webmail-
${{ runner.os }}-bun-
- - name: Install dependencies
- run: bun install --frozen-lockfile
+ # Not --frozen-lockfile: the committed lockfile is regenerated by
+ # scripts/build-release.ts for the dist, so it can legitimately lag the manifest
+ # here. Resolving fresh is fine for a test-only install.
+ - name: Install webmail server dependencies
+ working-directory: apps/email/server
+ run: bun install
- # Fail here rather than inside vitest, so "the runner lost Docker" is
- # distinguishable at a glance from "a rollback assertion broke".
- - name: Check the Docker daemon
- run: |
- docker info
- docker version
-
- - name: Run real-daemon E2E
- env:
- RUN_DOCKER_E2E: "1"
- E2E_SCOPE: ${{ matrix.scope }}
- run: bun run --cwd apps/api test:e2e
+ - name: Run webmail server tests
+ working-directory: apps/email/server
+ run: bun test
diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml
index 6bd30611b..8a403d9c5 100644
--- a/.github/workflows/docker-images.yml
+++ b/.github/workflows/docker-images.yml
@@ -33,6 +33,13 @@ permissions:
packages: write
jobs:
+ # Typecheck + tests. `merge-images` depends on this, so a red test cannot become a
+ # pullable image tag. See .github/workflows/release-gate.yml for why this is a
+ # called workflow rather than a trigger.
+ gate:
+ name: Release gate
+ uses: ./.github/workflows/release-gate.yml
+
# Native per-arch builds (no QEMU — reuses the ubuntu-24.04-arm runner). Each
# arch is pushed to GHCR BY DIGEST; merge-images assembles the manifest lists.
build-images:
@@ -112,9 +119,98 @@ jobs:
if-no-files-found: error
retention-days: 1
+ # The update path itself: the PREVIOUS release's stack, with rows in its database,
+ # recreated onto the api this run just built — `up -d --force-recreate api`, the same
+ # command `openship update` issues.
+ #
+ # Positioned between the build and the manifest publish on purpose. `build-images`
+ # pushes by digest under no tag, so at this point the new image exists but nothing
+ # pulls it yet; this job pulls it BY DIGEST, which makes the thing under test the exact
+ # bytes `merge-images` is about to tag. Testing `:latest` after publishing it would be
+ # testing what operators already got.
+ update-e2e:
+ name: E2E (update from previous release)
+ needs: build-images
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ steps:
+ - name: Checkout
+ # Tags, and all of them: the test resolves which release to upgrade FROM by
+ # walking `git tag`. A shallow clone has none, and it fails rather than guess.
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache bun install
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # This runner is amd64, so it needs the amd64 half of the build. The artifact holds
+ # one empty file NAMED for the digest (see "Export digest" above).
+ - name: Resolve the new api image digest
+ id: newimage
+ uses: actions/download-artifact@v8
+ with:
+ name: digests-api-amd64
+ path: /tmp/api-digest
+
+ - name: Build the image reference
+ id: ref
+ run: |
+ set -euo pipefail
+ digest="$(ls /tmp/api-digest | head -1)"
+ if [ -z "$digest" ]; then
+ echo "No digest artifact — build-images did not export one for api/amd64." >&2
+ exit 1
+ fi
+ echo "image=ghcr.io/${{ github.repository_owner }}/openship-api@sha256:${digest}" >> "$GITHUB_OUTPUT"
+
+ # Fail here rather than inside vitest, so "the runner lost Docker" is
+ # distinguishable at a glance from "the update broke".
+ - name: Check the Docker daemon
+ run: |
+ docker info
+ docker version
+
+ - name: Run the update E2E
+ env:
+ RUN_DOCKER_E2E: "1"
+ E2E_SCOPE: update
+ OPENSHIP_E2E_NEW_API_IMAGE: ${{ steps.ref.outputs.image }}
+ OPENSHIP_E2E_IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}
+ run: bun run --cwd apps/api test:e2e
+
merge-images:
name: Publish ${{ matrix.image }} (manifest)
- needs: build-images
+ # `gate` as well as the builds: this job is where images become PULLABLE (the
+ # per-arch builds above push by digest only, under no tag), so it is the point a
+ # failing test has to stop. `openship update` pulls these tags — an unreachable
+ # gate result here is the difference between a bad release sitting unreferenced
+ # in the registry and every operator's stack recreating onto it.
+ #
+ # Gated on the manual path too. That path never moves `:latest`, but it does
+ # publish tags a box can be pointed at, and the wiring stays simpler than a
+ # conditional that has to be right about which publishes are "only tests".
+ needs: [gate, build-images, update-e2e]
runs-on: ubuntu-24.04
strategy:
fail-fast: false
diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml
new file mode 100644
index 000000000..18d73304c
--- /dev/null
+++ b/.github/workflows/release-gate.yml
@@ -0,0 +1,125 @@
+name: Release gate
+
+# The tests that must pass before anything ships: the unit/integration suite, and the
+# real-daemon E2E matrix. If either goes red, the release does not publish.
+#
+# Why this exists: `Release` and `Docker images` both trigger on the same `v*.*.*` tag
+# push as `CI`, and GitHub does not order workflows against one another. So up to and
+# including v0.6.5, the GitHub release, the npm CLI and the GHCR images all published
+# *in parallel with* the test run and completed regardless of its result — `publish`
+# needed only the build jobs, and `merge-images` only `build-images`. The e2e suite
+# described itself as "the release-restorability gate" while being nothing of the kind.
+# Nothing in the repo could fail an upload.
+#
+# `workflow_call` rather than a trigger: listing a job in `needs:` is the only way one
+# workflow can block on another's result. `workflow_dispatch` is kept so the E2E matrix
+# can still be run on demand, which is what CI's copy of it was for.
+#
+# `apps/api` IS typechecked here, because nothing else in a release does it. The build
+# jobs only look like they would: `build` is `tsup --format esm` with no `--dts` and
+# `build-release` is a bun compile, and both strip types without checking them. A live
+# example while this was being written — `packages/db/src/dump.ts` referencing a schema
+# export that no longer existed — compiled clean and would have shipped.
+#
+# The dashboard's typecheck deliberately stays in CI and out of this gate: it is a
+# grep-filtered scan that tolerates pre-existing fumadocs errors, which is not something
+# a release should hang on.
+
+on:
+ workflow_call: {}
+ workflow_dispatch: {}
+
+jobs:
+ test:
+ name: Typecheck + tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ # `latest`, matching what CI has always run these suites on. Deliberately NOT
+ # switched to the pinned .bun-version here: these two jobs now block releases,
+ # and changing their runtime in the same move that made them blocking is how you
+ # get a gate whose first red run nobody can attribute. Worth revisiting as its
+ # own change — a gate arguably should be reproducible.
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache bun install
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ # Covers packages/* too: they resolve to source, so an error in @repo/db surfaces
+ # here. This is the only typecheck between a tag and a published image.
+ - name: Typecheck apps/api
+ run: bun run --cwd apps/api lint
+
+ # `turbo run test` across every package with a test script. Includes the two
+ # migration suites in packages/db: migrate-chain (the chain applied to a
+ # POPULATED database, plus a self-check proving it can fail) and
+ # migrations-additive (the static ADD COLUMN ... NOT NULL scan). Those are the
+ # coverage for "the update crash-looped on migrations".
+ - name: Run tests
+ run: bun run test
+
+ # Rollback and restore against a REAL Docker daemon. This is the only job that proves
+ # those paths work at all; every other test in the repo mocks the runtime. It lived in
+ # CI, where it ran alongside the release it claimed to gate — it is here now so a
+ # failure actually stops the publish.
+ #
+ # `RUN_DOCKER_E2E=1` is what makes it honest: without it the suite skips when no
+ # daemon answers, which is exactly how these cases sat green-and-unrun for months.
+ # With it, an unreachable daemon fails in `beforeAll` instead of reporting skipped.
+ #
+ # `fast` is every daemon-level and full-cycle case (~5 min); `heavy` is
+ # rollback-build-restore alone (~225s cold, and fileParallelism is off, so it holds
+ # the whole suite up). Both run here — see E2E_SCOPE in apps/api/vitest.e2e.config.ts.
+ e2e-docker:
+ name: E2E (real Docker, ${{ matrix.scope }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ scope: [fast, heavy]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache bun install
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ # Fail here rather than inside vitest, so "the runner lost Docker" is
+ # distinguishable at a glance from "a rollback assertion broke".
+ - name: Check the Docker daemon
+ run: |
+ docker info
+ docker version
+
+ - name: Run real-daemon E2E
+ env:
+ RUN_DOCKER_E2E: "1"
+ E2E_SCOPE: ${{ matrix.scope }}
+ run: bun run --cwd apps/api test:e2e
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ed99399a..0099dfaff 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,6 +15,13 @@ permissions:
contents: write
jobs:
+ # Typecheck + tests. `publish` and `publish-npm` depend on this, so a red test
+ # stops the release instead of publishing alongside it. Runs on the manual
+ # dispatch too — that path publishes npm, which is immutable.
+ gate:
+ name: Release gate
+ uses: ./.github/workflows/release-gate.yml
+
build-openship:
name: Build openship release artifact
if: github.event_name == 'push'
@@ -546,9 +553,15 @@ jobs:
# mismatch. `always()` keeps the manual npm-only path working: on a
# workflow_dispatch the `publish` job is skipped (push-only), and we publish
# anyway; on a tag push we require `publish` to have succeeded.
- needs: [publish]
+ #
+ # `gate` is required on BOTH paths, and is the one condition with no escape
+ # hatch: the tag path inherits it through `publish`, and the dispatch path —
+ # which skips `publish` entirely — would otherwise put an untested,
+ # unreplaceable version on npm.
+ needs: [gate, publish]
if: >-
always() &&
+ needs.gate.result == 'success' &&
(github.event_name == 'workflow_dispatch' || needs.publish.result == 'success')
# OIDC trusted publishing: npm verifies this workflow's identity instead of
# a long-lived token. id-token:write lets the runner mint the OIDC token.
@@ -652,7 +665,7 @@ jobs:
publish:
name: Publish GitHub release
if: github.event_name == 'push'
- needs: [build-openship, build-email, build-dashboard, build-cli-payload, build-desktop, build-desktop-macos]
+ needs: [gate, build-openship, build-email, build-dashboard, build-cli-payload, build-desktop, build-desktop-macos]
runs-on: ubuntu-24.04
steps:
- name: Checkout
diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts
index ee3a88aa8..885682c07 100644
--- a/apps/api/src/lib/audit.ts
+++ b/apps/api/src/lib/audit.ts
@@ -19,7 +19,7 @@
import type { Context } from "hono";
import { repos } from "@repo/db";
-import { resolveCallSource, type AuditSource } from "./call-source";
+import { resolveCallClientId, resolveCallSource, type AuditSource } from "./call-source";
export interface AuditContext {
organizationId: string;
@@ -28,6 +28,9 @@ export interface AuditContext {
userAgent?: string | null;
/** Where the action came in from. Filled by `auditContextFrom`. */
source?: AuditSource | null;
+ /** Which client of that surface — `oauth:` / `pat:`. Only
+ * MCP dispatch sets it; see call-source.ts. */
+ sourceClientId?: string | null;
}
export interface AuditEventInput {
@@ -39,6 +42,9 @@ export interface AuditEventInput {
/** Overrides the context's source. For emitters with no request to read
* (crons, Better Auth hooks, webhook deliveries). */
source?: AuditSource | null;
+ /** Overrides the context's client id. For the MCP endpoint itself, which knows
+ * the calling client before any sub-request has carried the signed header. */
+ sourceClientId?: string | null;
}
export const audit = {
@@ -56,6 +62,7 @@ export const audit = {
ipAddress: ctx.ipAddress ?? null,
userAgent: ctx.userAgent ?? null,
source: event.source ?? ctx.source ?? null,
+ sourceClientId: event.sourceClientId ?? ctx.sourceClientId ?? null,
});
} catch (err) {
console.error("[audit] failed to record event", event.eventType, err);
@@ -79,5 +86,6 @@ export function auditContextFrom(
ipAddress: c.var.clientIp,
userAgent: c.req.header("user-agent") ?? null,
source: resolveCallSource(c),
+ sourceClientId: resolveCallClientId(c),
};
}
diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts
index 9df7ba5d2..e2d84e811 100644
--- a/apps/api/src/lib/auth.ts
+++ b/apps/api/src/lib/auth.ts
@@ -175,7 +175,17 @@ export const auth = betterAuth({
sendVerificationEmail: smtpEnabled
? async ({ user, url }: { user: User; url: string; token: string }) => {
const email = verifyEmailTemplate(user, url);
- await sendMail({ to: user.email, ...email });
+ const delivered = await sendMail({ to: user.email, ...email });
+ // `requireEmailVerification` blocks sign-in until the address is confirmed,
+ // so a silently-dropped verification mail is an account that can never be
+ // used. Fail the request instead of creating one.
+ if (!delivered) {
+ throw new APIError("SERVICE_UNAVAILABLE", {
+ message:
+ "Could not send the verification email — this instance has no working " +
+ "email transport. Configure SMTP in Settings → Email.",
+ });
+ }
}
: undefined,
},
@@ -405,7 +415,17 @@ export const auth = betterAuth({
// types are not enabled, so they fall through and send nothing.
if (type === "email-verification") {
const tmpl = verifyOtpEmailTemplate(otp, { expiresMinutes: 10 });
+<<<<<<< HEAD
await sendMail({ to: email, ...tmpl });
+=======
+ if (!(await sendMail({ to: email, ...tmpl }))) {
+ throw new APIError("SERVICE_UNAVAILABLE", {
+ message:
+ "Could not send the verification code — this instance has no working " +
+ "email transport. Configure SMTP in Settings → Email.",
+ });
+ }
+>>>>>>> a52e2566 (patch v0.6.6)
return;
}
// Password reset. This replaced the link flow (`sendResetPassword` in the
@@ -431,7 +451,18 @@ export const auth = betterAuth({
});
}
const tmpl = resetPasswordOtpEmail(otp, { expiresMinutes: 10 });
- await sendMail({ to: email, ...tmpl });
+ // Check the RESULT as well as the pre-flight above. `canSendMail()` reads a
+ // transport cache with a 60s TTL, so it can say yes for a config that has
+ // since been changed or broken — and being told to check your inbox while
+ // locked out is the worst place to be optimistic.
+ if (!(await sendMail({ to: email, ...tmpl }))) {
+ throw new APIError("SERVICE_UNAVAILABLE", {
+ message:
+ "Could not send the reset code — this instance has no working email " +
+ "transport. Configure SMTP in Settings → Email, or reset the password " +
+ "from the server with `openship reset-admin`.",
+ });
+ }
}
},
}),
@@ -532,7 +563,7 @@ export const auth = betterAuth({
const settings = await repos.instanceSettings.get();
const source = settings?.invitationMailSource === "cloud" ? "cloud" : "platform";
- await sendMail({
+ const delivered = await sendMail({
to: data.email,
preferSource: source,
// organizationId is required by lib/mail.ts when
@@ -542,6 +573,18 @@ export const auth = betterAuth({
organizationId: data.organization.id,
...email,
});
+ // An invite that cannot be delivered must not report success: the invitee
+ // has a pending row and no way to learn about it, and the inviter believes
+ // it went out. `sendMail` only warns on an empty chain, so this is the only
+ // place that can tell. Throwing surfaces it on the invite request itself.
+ if (!delivered) {
+ throw new APIError("SERVICE_UNAVAILABLE", {
+ message:
+ `Could not email the invitation to ${data.email} — this instance has ` +
+ `no working email transport. Configure SMTP in Settings → Email and ` +
+ `invite again.`,
+ });
+ }
}
: undefined,
diff --git a/apps/api/src/lib/call-source.ts b/apps/api/src/lib/call-source.ts
index 75d4280a8..5f5c3658e 100644
--- a/apps/api/src/lib/call-source.ts
+++ b/apps/api/src/lib/call-source.ts
@@ -13,6 +13,10 @@
* generated at boot and never sent anywhere: only a request originating INSIDE
* this process can know it. A forged header fails the check and the source is
* derived from the credential instead.
+ *
+ * The dispatcher signs a second claim the same way — which CLIENT of that surface
+ * (`oauth:` / `pat:`) — because with two assistants connected
+ * under one user, "mcp" alone can't tell you which one to revoke.
*/
import { AsyncLocalStorage } from "node:async_hooks";
@@ -28,6 +32,21 @@ export function isAuditSource(value: unknown): value is AuditSource {
}
const CALL_SOURCE_HEADER = "x-openship-call-source";
+const CALL_CLIENT_HEADER = "x-openship-call-client";
+
+/**
+ * Shape of an attributable client id: the canonical principal id the auth layer
+ * mints (`oauth:` / `pat:`). Bounded and character-checked
+ * because the value is persisted on audit_event and rendered in the audit UI —
+ * the nonce proves it came from us, not that we assembled it from something sane.
+ */
+const CLIENT_ID_PATTERN = /^(?:oauth|pat):[A-Za-z0-9_.\-]{1,128}$/;
+
+/** True for a well-formed source-client id. Also the query-param validator for
+ * the audit filter, so what can be stored and what can be filtered on agree. */
+export function isAuditClientId(value: unknown): value is string {
+ return typeof value === "string" && CLIENT_ID_PATTERN.test(value);
+}
/**
* Process-local secret. Regenerated on every boot: an in-flight forged header
@@ -40,21 +59,54 @@ export function internalSourceHeader(source: AuditSource): Record {
+ return { [CALL_CLIENT_HEADER]: `${principalId}.${PROCESS_NONCE}` };
+}
+
/** Constant-time, because the nonce is the whole gate: leak it and `mcp` is claimable. */
function nonceMatches(candidate: string): boolean {
if (candidate.length !== PROCESS_NONCE.length) return false;
return timingSafeEqual(Buffer.from(candidate), Buffer.from(PROCESS_NONCE));
}
-/** The claimed source, but only if the claim came from this process. */
-function trustedClaim(c: Context): AuditSource | null {
- const raw = c.req.header(CALL_SOURCE_HEADER);
+/**
+ * The payload of a nonce-signed header, or null if the signature isn't ours.
+ * The nonce is hex, so the LAST dot is always the separator no matter what the
+ * payload contains.
+ */
+function signedPayload(raw: string | undefined): string | null {
if (!raw) return null;
const sep = raw.lastIndexOf(".");
if (sep <= 0) return null;
- const claimed = raw.slice(0, sep);
if (!nonceMatches(raw.slice(sep + 1))) return null;
- return isAuditSource(claimed) ? claimed : null;
+ return raw.slice(0, sep);
+}
+
+/** The claimed source, but only if the claim came from this process. */
+function trustedClaim(c: Context): AuditSource | null {
+ const claimed = signedPayload(c.req.header(CALL_SOURCE_HEADER));
+ return claimed && isAuditSource(claimed) ? claimed : null;
+}
+
+/**
+ * Which client of the surface made this request — `oauth:` /
+ * `pat:` — or null when nobody trustworthy said.
+ *
+ * Deliberately NOT derived from the credential as a fallback: the surfaces that
+ * have one client per request (a browser, the CLI) gain nothing from the column,
+ * and guessing here would put a value in it that no dispatcher stands behind.
+ */
+export function resolveCallClientId(c: Context): string | null {
+ const claimed = signedPayload(c.req.header(CALL_CLIENT_HEADER));
+ return claimed && CLIENT_ID_PATTERN.test(claimed) ? claimed : null;
}
/**
diff --git a/apps/api/src/lib/domain-claims.ts b/apps/api/src/lib/domain-claims.ts
new file mode 100644
index 000000000..9af337240
--- /dev/null
+++ b/apps/api/src/lib/domain-claims.ts
@@ -0,0 +1,72 @@
+/**
+ * "May this project ROUTE a hostname it does not own?"
+ *
+ * The generic question the general routing paths should be asking.
+ *
+ * `domain.owner_type` is already a general concept — `project` (the default),
+ * `webhook`, `mail` — so the data model has always allowed a row that belongs to
+ * something other than a project. What the code lacked was a matching question. Both
+ * general paths (`lib/routing-domains.ts` and `modules/domains/domain.service.ts`)
+ * instead hardcoded a call to the MAIL predicate, each with its own copy of the
+ * mail-specific reasoning, in the middle of logic that is otherwise subsystem-blind.
+ *
+ * That is the smell this file removes. A general path now asks one general question,
+ * and each subsystem that owns hostnames answers for itself. When `webhook`-owned rows
+ * need the same treatment it becomes an entry below, not a third `if` in a third
+ * general path.
+ *
+ * WHAT "TRUE" MEANS, and why it is not just tidiness: the caller registers the vhost
+ * and creates NO domain row. `domain.project_id` cascades on project delete, so
+ * stamping the routing project onto a foreign-owned row would make deleting that
+ * project delete the owner's record — for mail, that is the mail host's only
+ * certificate-renewal row, and the failure surfaces as silent TLS expiry ~90 days
+ * later with nothing to restore it (#566).
+ */
+
+import type { Domain } from "@repo/db";
+
+import { mailHostRoutableByProject } from "./mail-host-claim";
+
+/**
+ * One subsystem's answer for the hostnames it owns.
+ *
+ * Contract: NEVER throw. These are consulted on the way to a conflict error, and a
+ * failed lookup must leave the caller's existing refusal in place rather than replace
+ * it with a 500. Each implementation is responsible for that.
+ */
+type ForeignHostClaim = (
+ hostname: string,
+ projectId: string,
+ row?: Domain | null,
+) => Promise;
+
+/**
+ * Every subsystem that can hand a project routing rights over a hostname it does not
+ * own. One entry today; the point is that adding the second one touches this list and
+ * nothing else.
+ */
+const FOREIGN_HOST_CLAIMS: ForeignHostClaim[] = [mailHostRoutableByProject];
+
+/**
+ * True when some subsystem grants `projectId` the right to route `hostname` while
+ * owning no row for it.
+ *
+ * Call this BEFORE the cross-project ownership refusal and whether or not a row
+ * exists: a claim can legitimately apply to a hostname with no row at all (mail's
+ * `recordMailCertDomain` is best-effort), and inside an `if (row)` branch the caller
+ * would fall through and MINT a project-owned row for the very hostname the claim
+ * exists to protect.
+ *
+ * @param row The already-fetched domain row for this hostname, when the caller has
+ * one — passed through so a claim can inspect it without a second query.
+ */
+export async function routableWithoutOwnership(
+ hostname: string,
+ projectId: string,
+ row?: Domain | null,
+): Promise {
+ for (const claim of FOREIGN_HOST_CLAIMS) {
+ if (await claim(hostname, projectId, row)) return true;
+ }
+ return false;
+}
diff --git a/apps/api/src/lib/env-reveal.ts b/apps/api/src/lib/env-reveal.ts
new file mode 100644
index 000000000..15b96a8cb
--- /dev/null
+++ b/apps/api/src/lib/env-reveal.ts
@@ -0,0 +1,56 @@
+import { AppError } from "@repo/core";
+
+/**
+ * #336 per-key env reveal — the one place that turns "the whole env map" into
+ * "exactly the keys the caller named".
+ *
+ * All three reveal sources (a service row, an upload session, a container's
+ * `docker inspect`) hand back a full map, so without this every eye-press shipped
+ * every secret of that service to the browser: 32 plaintext values in the network
+ * response, in memory and in the devtools log to see one.
+ *
+ * `keys` is REQUIRED and non-empty on purpose — there is no request shape that
+ * means "give me everything". A caller must already know a key's name to see its
+ * value, and the audit row (`auditAfter.revealedEnvKeys`) records exactly which
+ * secrets were disclosed instead of an unqualified "revealed env".
+ */
+
+/** Bound on one request. Generous for a real service, cheap abuse guard. */
+export const MAX_REVEAL_KEYS = 500;
+const MAX_KEY_LENGTH = 512;
+
+/** Validate a client-sent `keys` list. Throws AppError(400) — the global error
+ * handler renders it; callers don't need their own branch. */
+export function parseRevealKeys(input: unknown): string[] {
+ if (!Array.isArray(input) || input.length === 0) {
+ throw new AppError("keys must be a non-empty array of env var names", 400);
+ }
+ if (input.length > MAX_REVEAL_KEYS) {
+ throw new AppError(`keys accepts at most ${MAX_REVEAL_KEYS} names per request`, 400);
+ }
+ const seen = new Set();
+ for (const key of input) {
+ if (typeof key !== "string" || key.length === 0 || key.length > MAX_KEY_LENGTH) {
+ throw new AppError("keys must contain only non-empty env var names", 400);
+ }
+ seen.add(key);
+ }
+ return [...seen];
+}
+
+/**
+ * Return ONLY the requested keys that actually exist in `env`. `hasOwn` rather
+ * than `key in env`: `keys` is client-controlled, and `in` would happily resolve
+ * `constructor` / `toString` off the prototype and answer with a function.
+ */
+export function pickRevealed(
+ env: Record | null | undefined,
+ keys: string[],
+): Record {
+ const revealed: Record = {};
+ if (!env) return revealed;
+ for (const key of keys) {
+ if (Object.hasOwn(env, key)) revealed[key] = env[key];
+ }
+ return revealed;
+}
diff --git a/apps/api/src/lib/host-channel-banner.ts b/apps/api/src/lib/host-channel-banner.ts
index a28da8bf3..73b7dfb2f 100644
--- a/apps/api/src/lib/host-channel-banner.ts
+++ b/apps/api/src/lib/host-channel-banner.ts
@@ -28,6 +28,7 @@ const TITLES: Partial> = {
unreachable: "HOST CONTROL UNREACHABLE",
not_configured: "HOST CONTROL NOT CONFIGURED",
key_unreadable: "HOST CONTROL KEY UNREADABLE",
+ auth_rejected: "HOST CONTROL KEY REFUSED",
};
/** One blocked item, wrapped with a hanging indent so a long one still reads as a
diff --git a/apps/api/src/lib/loopback-publish.ts b/apps/api/src/lib/loopback-publish.ts
index 9dbe3f912..649714a85 100644
--- a/apps/api/src/lib/loopback-publish.ts
+++ b/apps/api/src/lib/loopback-publish.ts
@@ -34,3 +34,44 @@ export function withLoopbackPublish(
const kept = portSpecs.filter((spec) => specContainerPort(spec) !== containerPort);
return [...kept, `127.0.0.1:${hostPort}:${containerPort}`];
}
+
+/**
+ * Republish EVERY routed container port on its own pinned loopback host port.
+ *
+ * A service can own several routes (minio's console + `s3` API), and each needs a
+ * DISTINCT host port or the edge cannot tell them apart.
+ */
+export function withLoopbackPublishAll(
+ portSpecs: readonly string[],
+ /** routed container port → the loopback host port pinned for it. */
+ pinned: ReadonlyMap,
+): string[] {
+ let out = [...portSpecs];
+ for (const [containerPort, hostPort] of pinned) {
+ out = withLoopbackPublish(out, containerPort, hostPort);
+ }
+ return out;
+}
+
+/**
+ * The host port a route's upstream should dial for `port`.
+ *
+ * The pinned map is authoritative. `resultHostPort` — a single scalar read back
+ * off the daemon — is only meaningful for the PRIMARY routed port: applying it to
+ * a secondary port is what made every extra subdomain proxy to the first route's
+ * port (minio's `s3` host served the console). Undefined means "no host port",
+ * which sends the caller to container-IP addressing instead of a wrong guess.
+ */
+export function upstreamHostPortFor(args: {
+ port: number;
+ pinned: ReadonlyMap;
+ primaryPort?: number;
+ resultHostPort?: number | null;
+ sameService: boolean;
+}): number | undefined {
+ const { port, pinned, primaryPort, resultHostPort, sameService } = args;
+ return (
+ pinned.get(port) ??
+ (sameService && port === primaryPort && resultHostPort ? resultHostPort : undefined)
+ );
+}
diff --git a/apps/api/src/lib/mail-host-claim.ts b/apps/api/src/lib/mail-host-claim.ts
new file mode 100644
index 000000000..ef63da7de
--- /dev/null
+++ b/apps/api/src/lib/mail-host-claim.ts
@@ -0,0 +1,83 @@
+/**
+ * May this project route `mail.` — the mail server's own hostname?
+ *
+ * The mail install records `mail. ` as a domain row with `ownerType='mail'` and
+ * `project_id = NULL`, purely so the renewal sweep can find the certificate
+ * (`recordMailCertDomain`). Nothing owns it in the project sense, and the two hijack
+ * guards both compare `owner.projectId !== projectId` — which `NULL` never satisfies. So
+ * no project could ever claim a mail-owned hostname, including the webmail the mail
+ * module itself deploys onto that exact host (issue #566). The install died with "already
+ * connected to another project"; the deploy skipped the route and left the container
+ * running with no vhost.
+ *
+ * Deploying webmail on `mail.` is the sanctioned configuration — same box, same
+ * certificate, no second DNS record, no port conflict (mail is on 25/465/587/143/993,
+ * webmail on 443). This is the one predicate that says so.
+ *
+ * ROUTE IT, OWN NOTHING. A caller that gets `true` registers the vhost and creates NO
+ * domain row: the mail row keeps `ownerType='mail', project_id=NULL`. That is not
+ * tidiness, it is the whole safety argument. `domain.project_id` cascades on project
+ * delete, so stamping this project onto the mail row would make deleting the webmail
+ * delete the mail host's only renewal record — silent IMAP/SMTP TLS expiry about 90 days
+ * later, with no hook anywhere to restore it.
+ *
+ * THE LINK IS THE AUTHORIZATION. `mail_servers.webmail_project_id` must already point at
+ * the claiming project; a webmail-shaped project is not enough, because "some project in
+ * this org that looks like a webmail" would let an ordinary service edit take over the
+ * mail hostname. The webmail installer therefore stamps that link BEFORE it applies the
+ * mail-host route (see `runWebmailInstall`), which is also what keeps the box correct:
+ * `startWebmailDeploy` refuses `mail.` on any server other than the mail server
+ * itself, so the only project that can hold the link is one deploying onto that box.
+ */
+
+import { mailHostBaseDomain } from "@repo/core";
+import { repos } from "@repo/db";
+import type { Domain } from "@repo/db";
+
+import { MAIL_DOMAIN_OWNER } from "./domain-ssl";
+
+/**
+ * True when `hostname` is the mail host of a mail server whose webmail IS this project.
+ *
+ * Never throws: it is consulted on the way to an error, and a failed lookup must leave
+ * the caller's existing refusal in place rather than replace it with a 500.
+ *
+ * @param row The already-fetched domain row for this hostname, when the caller has one.
+ * Pass it to avoid a second query. Its absence is legitimate — `recordMailCertDomain`
+ * is best-effort, so the mail row can be missing, and this must still answer `true`
+ * there or the caller mints a project-owned row for the mail host and the next mail
+ * install refuses to record the certificate against it.
+ */
+export async function mailHostRoutableByProject(
+ hostname: string,
+ projectId: string,
+ row?: Domain | null,
+): Promise {
+ try {
+ // `mailHostBaseDomain` (@repo/core) rather than a local `/^mail\./` strip: the mail
+ // host label is defined once, and the build and the parse derive from the same
+ // constant. A private regex here is how a prefix change makes this predicate
+ // silently answer `false` and webmail-on-the-mail-host stops routing with no error.
+ const base = mailHostBaseDomain(hostname);
+ if (!base) return false;
+
+ // A row that exists must be the mail install's own. Another project's row is a real
+ // conflict, and a project-owned row for this host means someone already minted what
+ // this predicate exists to prevent — either way, not ours to route.
+ if (row && (row.ownerType !== MAIL_DOMAIN_OWNER || row.projectId !== null)) return false;
+
+ const mail = await repos.mailServer.findByDomain(base);
+ if (!mail || mail.webmailProjectId !== projectId) return false;
+
+ // Org authority comes from the SERVER row, never from the link alone: the link is a
+ // pointer the mail module writes, and a cross-org pointer must not authorize a route.
+ const [server, project] = await Promise.all([
+ repos.server.get(mail.serverId),
+ repos.project.findById(projectId),
+ ]);
+ if (!server?.organizationId || !project) return false;
+ return server.organizationId === project.organizationId;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/api/src/lib/mail.ts b/apps/api/src/lib/mail.ts
index 4863df385..10706a44b 100644
--- a/apps/api/src/lib/mail.ts
+++ b/apps/api/src/lib/mail.ts
@@ -399,12 +399,33 @@ async function getTransportChain(
if (preferSource !== "platform" && envTransport) {
chain.push({ transport: envTransport, from: envFrom, source: "env" });
}
+ // Last resort for the "platform" preference: env is normally dropped so a branded
+ // invite can't quietly go out from a generic sender — but that only holds while a
+ // branded sender EXISTS. On a box whose only mail config is env SMTP the chain above
+ // is empty, and dropping env there doesn't downgrade the sender, it loses the mail
+ // entirely (sendMail's empty-chain path is a silent no-op). An invite delivered from
+ // the generic sender beats an invite that never arrives.
+ if (chain.length === 0 && envTransport) {
+ chain.push({ transport: envTransport, from: envFrom, source: "env" });
+ }
return chain;
}
-/** Send an email. No-ops with a warning when no transport is available; fails
- * over across the transport chain when a send throws. */
-export async function sendMail(opts: SendMailOptions): Promise {
+/**
+ * Send an email. Fails over across the transport chain when a send throws.
+ *
+ * RETURNS whether anything actually accepted the message. This used to be `void`,
+ * and the empty-chain path still only warns rather than throwing — which is right
+ * for the fire-and-forget callers, but it meant a caller could not TELL. That is how
+ * an email notification channel ended up marked `verified` and every delivery marked
+ * `sent` on a box with no transport at all: the send "succeeded" because it silently
+ * did nothing.
+ *
+ * So: callers that must react check the boolean (see the email notification worker),
+ * and callers that genuinely don't care keep ignoring it exactly as before. A hard
+ * throw here would have changed behaviour for all eight call sites at once.
+ */
+export async function sendMail(opts: SendMailOptions): Promise {
const preferSource = opts.preferSource ?? "auto";
// Cloud relay branch — only meaningful on a local self-hosted instance.
@@ -416,7 +437,7 @@ export async function sendMail(opts: SendMailOptions): Promise {
"[mail] preferSource=cloud requires organizationId - skipping email to",
opts.to,
);
- return;
+ return false;
}
// cloud-client is dual-side (local outbound → SaaS) with no local-
// only side effects on import, so static import is fine. Cargo-cult
@@ -434,8 +455,9 @@ export async function sendMail(opts: SendMailOptions): Promise {
console.warn(
`[mail] cloud invitation relay failed for org=${opts.organizationId}: ${result.error}`,
);
+ return false;
}
- return;
+ return true;
}
const chain = await getTransportChain(preferSource);
@@ -444,7 +466,7 @@ export async function sendMail(opts: SendMailOptions): Promise {
`[mail] no transport configured (preferSource=${preferSource}) - skipping email to`,
opts.to,
);
- return;
+ return false;
}
// Try each transport in priority order; fail over to the next on a send
@@ -461,7 +483,7 @@ export async function sendMail(opts: SendMailOptions): Promise {
html: opts.html,
...(opts.text ? { text: opts.text } : {}),
});
- return;
+ return true;
} catch (err) {
lastErr = err;
const more = i < chain.length - 1;
diff --git a/apps/api/src/lib/notification-categories.ts b/apps/api/src/lib/notification-categories.ts
index 7c73371b1..cd97d5f68 100644
--- a/apps/api/src/lib/notification-categories.ts
+++ b/apps/api/src/lib/notification-categories.ts
@@ -32,6 +32,9 @@ export const CATEGORY_GROUPS = [
{ id: "jobs", label: "Jobs" },
{ id: "domains", label: "Domains & SSL" },
{ id: "members", label: "Members" },
+ // Self-hosted-only, and dropped from `listCategories` under CLOUD_MODE — the mirror
+ // image of `billing` below. Placed before it so the cloud-only group stays last.
+ { id: "mail", label: "Mail" },
{ id: "billing", label: "Billing" },
] as const satisfies readonly { id: string; label: string }[];
@@ -211,6 +214,24 @@ export const CATEGORIES: readonly NotificationCategory[] = [
defaultEnabled: false,
},
+ // Self-hosted-only: fed by the mail engine, so `listCategories` drops this group
+ // under CLOUD_MODE — the inverse of the billing block below. It stays in the
+ // registry regardless, because `findCategory` supplies the title and first body
+ // line of every alert already stored against it.
+ //
+ // defaultEnabled MUST stay false. The dispatcher's fallback fans a default-enabled
+ // category to every org member's verified email channel with no opt-in, and for a
+ // PER-MESSAGE event that is both a flood and a mail loop: the notification mail
+ // would land on the same engine, inside a watched domain, and capture itself.
+ {
+ id: "mail.inbound_received",
+ group: "mail",
+ label: "Inbound email received",
+ description:
+ "A message arrived at a mailbox or domain one of your inbound rules watches.",
+ defaultEnabled: false,
+ },
+
// Cloud-only: both are fed by Stripe/Oblien, so `listCategories` drops the whole
// group outside CLOUD_MODE rather than showing toggles that can never fire. They
// stay in the registry regardless — `findCategory` still has to render a message
@@ -290,6 +311,11 @@ const EVENT_TYPE_TO_CATEGORY: Record = {
"invitation.sent": "invitation.sent",
"invitation.created": "invitation.sent",
+ // Mail (self-hosted engine). Without this entry `notification.emit` returns with no
+ // log and no delivery row, so the rules UI would look like it works while nothing
+ // is ever sent.
+ "mail.inbound_received": "mail.inbound_received",
+
// Billing
"billing.payment_failed": "billing.alert",
"billing.invoice_overdue": "billing.alert",
diff --git a/apps/api/src/lib/notification-workers.ts b/apps/api/src/lib/notification-workers.ts
index b786c1a3f..47a088a70 100644
--- a/apps/api/src/lib/notification-workers.ts
+++ b/apps/api/src/lib/notification-workers.ts
@@ -191,12 +191,27 @@ async function sendEmail(
}
const { title, body } = renderMessage(delivery);
- await sendMail({
+ const delivered = await sendMail({
to: config.address,
subject: `[Openship] ${title}`,
text: body,
html: `${escapeHtml(body)} `,
});
+ // THROW when nothing could carry it. `sendMail` only warns on an empty transport
+ // chain, so ignoring its result meant this worker returned normally and the delivery
+ // was marked SENT having delivered nothing. Worse, `POST /channels/:id/test` flips a
+ // channel to `verified` whenever the send doesn't throw — so an instance with no SMTP
+ // produced a channel that looked verified and working and had never delivered once.
+ //
+ // Throwing is exactly right here: this worker's contract is "throw = retry", so the
+ // alert is retried and the reason lands in the delivery row's lastError, and the test
+ // endpoint's catch now refuses to verify the channel.
+ if (!delivered) {
+ throw new Error(
+ "No email transport is configured on this instance, so the message could not be " +
+ "sent. Configure SMTP in Settings → Email.",
+ );
+ }
}
async function sendWebhook(
diff --git a/apps/api/src/lib/routing-domains.ts b/apps/api/src/lib/routing-domains.ts
index 78d3f79b5..d32d0cc57 100644
--- a/apps/api/src/lib/routing-domains.ts
+++ b/apps/api/src/lib/routing-domains.ts
@@ -8,6 +8,7 @@ import { acmeIssueLockKey, LOCAL_ACME_SCOPE, resolveSslPatch, sslIssueLockKey }
import { resolveRouteRedirect } from "./domain-redirect";
import { createProvisionLock } from "./provision-lock";
import { generateToken } from "./domain-token";
+import { routableWithoutOwnership } from "./domain-claims";
export interface PlannedRouteDomain {
hostname: string;
@@ -629,6 +630,19 @@ export async function ensureRouteDomainRecord(opts: {
// cert). Refuse loudly here, before any patch or create, so neither the update
// nor the create path can claim a hostname this project doesn't own.
const owner = await repos.domain.findByHostname(route.hostname);
+
+ // A hostname owned by another SUBSYSTEM that still grants this project routing rights
+ // — see lib/domain-claims for who those are and why they own no project row.
+ // Deliberately the GENERAL question: this function is otherwise subsystem-blind, and
+ // the per-subsystem reasoning belongs with the claim rather than inline in the deploy
+ // path. A true return still registers the vhost; it only declines to record a row.
+ //
+ // Asked BEFORE the ownership branch and whether or not a row exists: a claim can apply
+ // to a hostname with no row at all, and inside the `owner &&` branch this would fall
+ // through to `findOrCreate` and MINT a project-owned row for the very host the claim
+ // exists to protect.
+ if (await routableWithoutOwnership(route.hostname, projectId, owner)) return null;
+
if (owner && owner.projectId !== projectId) {
throw new ConflictError(
`Hostname ${route.hostname} is routed by another project and cannot be claimed here.`,
diff --git a/apps/api/src/lib/server-container-session.ts b/apps/api/src/lib/server-container-session.ts
index 883d781ab..d616680ac 100644
--- a/apps/api/src/lib/server-container-session.ts
+++ b/apps/api/src/lib/server-container-session.ts
@@ -100,6 +100,61 @@ export function getActiveContainerApplySession(
return null;
}
+/**
+ * A session without its transport — what a progress READER needs (steps, status,
+ * outcome) and nothing it must not hold (the subscriber set, the log ring, the
+ * done promise).
+ */
+export interface ContainerApplySnapshot {
+ id: string;
+ serverId: string;
+ component: ContainerComponent;
+ status: ContainerSessionStatus;
+ steps: ContainerStep[];
+ startedAt: number;
+ finishedAt?: number;
+ result?: ContainerApplyResult;
+ error?: string;
+}
+
+function snapshot(s: ContainerApplySession): ContainerApplySnapshot {
+ return {
+ id: s.id,
+ serverId: s.serverId,
+ component: s.component,
+ status: s.status,
+ steps: s.steps.map((step) => ({ ...step })),
+ startedAt: s.startedAt,
+ ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
+ ...(s.result ? { result: { ...s.result } } : {}),
+ ...(s.error ? { error: s.error } : {}),
+ };
+}
+
+/**
+ * Every apply this process is running, plus the ones that JUST settled — the read
+ * behind a fleet-wide progress surface.
+ *
+ * One pass over the store instead of a scan per (server, component), because a
+ * fleet view asks about every box at once. `settledWithinMs` includes finished
+ * sessions for that long after the fact: the outcome is the only place a "done"
+ * beat can come from — the drift row clears its `behind` and its in-progress flag
+ * in the same write, so a reader watching the row alone can only ever see the work
+ * disappear, never that it succeeded.
+ */
+export function listContainerApplySessions(opts?: {
+ settledWithinMs?: number;
+}): ContainerApplySnapshot[] {
+ const window = opts?.settledWithinMs ?? 0;
+ const cutoff = Date.now() - window;
+ const out: ContainerApplySnapshot[] = [];
+ for (const s of sessions.values()) {
+ if (s.status === "running") out.push(snapshot(s));
+ else if (window > 0 && (s.finishedAt ?? 0) >= cutoff) out.push(snapshot(s));
+ }
+ return out.sort((a, b) => a.startedAt - b.startedAt);
+}
+
export function createContainerApplySession(
serverId: string,
component: ContainerComponent,
diff --git a/apps/api/src/lib/startup/infra-reconcile.ts b/apps/api/src/lib/startup/infra-reconcile.ts
index a7d50908b..8c2411a5e 100644
--- a/apps/api/src/lib/startup/infra-reconcile.ts
+++ b/apps/api/src/lib/startup/infra-reconcile.ts
@@ -19,6 +19,12 @@
* auto-apply, when `autoUpdateInfra` is on — never holds up boot. Both the
* advisory (toggle off) and the auto-update (toggle on) fall out of that one
* call. Self-hosted + desktop only (CLOUD_MODE never runs startup hooks).
+ *
+ * One thing here runs on EVERY boot, not once per version: clearing stale
+ * in-progress flags. An apply lives in this process — its session, logs and step
+ * model are in memory — so a flag that survived a restart describes a run that no
+ * longer exists, and every surface that renders it would show a permanent
+ * "Updating…" for a swap nobody is performing.
*/
import { safeErrorMessage } from "@repo/core";
@@ -32,6 +38,14 @@ export function registerInfraReconcile(): void {
id: "infra:reconcile",
modes: ["desktop", "selfhosted"],
run: async () => {
+ // Every boot, before the version gate: no apply can be running yet, so any
+ // in-progress flag is a leftover from the process that died holding it.
+ await repos.serverContainerStatus
+ .clearAllInProgress()
+ .catch((err) =>
+ console.warn(`[infra-reconcile] in-progress reset failed: ${safeErrorMessage(err)}`),
+ );
+
const current = readApiVersion();
const settings = await repos.instanceSettings.get().catch(() => undefined);
if (settings?.lastSeenVersion === current) return;
diff --git a/apps/api/src/lib/startup/self-server.ts b/apps/api/src/lib/startup/self-server.ts
index 622bcd72b..a8fc28d03 100644
--- a/apps/api/src/lib/startup/self-server.ts
+++ b/apps/api/src/lib/startup/self-server.ts
@@ -38,6 +38,7 @@
* the target list and the deploy wizard to surface — an annotation, never a gate.
*/
import { env } from "../../config/env";
+import { hostChannelAccount } from "@repo/core";
import type { HostChannelCode } from "@repo/adapters";
import { repos, type Server } from "@repo/db";
import { boxOwningOrgId } from "../box-org";
@@ -131,12 +132,13 @@ async function register(opts?: EnsureLocalServerOptions): Promise
const organizationId = await localServerOwnerOrg();
if (!organizationId) return null;
- // The account the host channel actually logs in as. The CLI writes
- // OPENSHIP_HOST_SSH_USER when it provisions the container→host channel; the default
- // matches `hostChannelUser()`, so a bare install with no channel agrees too. A row
- // claiming `root` while the channel dials someone else is issue #489 — and since
- // these fields are display-only (below), nothing else would ever correct it.
- const desiredSshUser = process.env.OPENSHIP_HOST_SSH_USER?.trim() || "root";
+ // The account the host channel actually logs in as. Through the SHARED resolver, not a
+ // local copy of the expression: the row is display-only, so nothing downstream would
+ // ever correct it, and a row claiming one account while the channel dials another is
+ // #489 and then #527 — where the operator was shown `admin@…`, went to fix it, and the
+ // dial went on using `root` regardless. Same function the dial uses (hostChannelUser in
+ // adapters), so the two cannot drift.
+ const desiredSshUser = hostChannelAccount(process.env);
const existing = await repos.server.findLocal(organizationId);
if (existing) {
diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts
index 62c4bf766..2faade503 100644
--- a/apps/api/src/middleware/error-handler.ts
+++ b/apps/api/src/middleware/error-handler.ts
@@ -32,6 +32,12 @@ export function handleApiError(err: unknown, c: Context) {
if (err instanceof AppError) {
const { message, code, statusCode } = err;
+ // A 5xx is a SERVER fault and must leave a trace, even when it arrives as a typed
+ // AppError carrying its own message. `AppError`'s statusCode defaults to 500, so
+ // a bare `new AppError(msg)` used to answer 500 and log NOTHING — the "500 with no
+ // actionable information in the logs" of GH-562. 4xx stays quiet on purpose: those
+ // are client outcomes, and logging them turns ordinary validation into noise.
+ if (statusCode >= 500) console.error(`[API ERROR] ${requestTag(c)}`, err);
return c.json(
{ error: message, code },
// 502/503 included: an AppError can legitimately mean "an upstream we
@@ -50,6 +56,26 @@ export function handleApiError(err: unknown, c: Context) {
return c.json({ error: "Invalid JSON body", code: "INVALID_JSON" }, 400);
}
- console.error("[UNHANDLED ERROR]", err);
+ // Log the route with it. `[UNHANDLED ERROR] Error: doveadm pw returned …` on its own
+ // doesn't say WHICH request produced it, which is most of the work of diagnosing a
+ // 500 from a log file. The response body stays deliberately generic — an unknown
+ // error's message can carry internals we don't hand to a client.
+ console.error(`[UNHANDLED ERROR] ${requestTag(c)}`, err);
return c.json({ error: "Internal server error" }, 500);
}
+
+/**
+ * `METHOD /path` for a log line. Query string omitted: it can carry tokens.
+ *
+ * Exported for the handlers that answer a 5xx THEMSELVES (the mail funnels), so every
+ * server-fault line in the log reads the same and is greppable by route. The try/catch
+ * below is load-bearing for those callers: a handler under test can be driven with a
+ * context that has no `method`/`url`, and this must not throw from inside a catch block.
+ */
+export function requestTag(c: Context): string {
+ try {
+ return `${c.req.method} ${new URL(c.req.url).pathname}`;
+ } catch {
+ return c.req.method;
+ }
+}
diff --git a/apps/api/src/modules/apps/app-install.service.ts b/apps/api/src/modules/apps/app-install.service.ts
index d22896a9b..eebcb18cd 100644
--- a/apps/api/src/modules/apps/app-install.service.ts
+++ b/apps/api/src/modules/apps/app-install.service.ts
@@ -33,6 +33,7 @@ import {
import { getRuntimeCatalog, getTemplateForOrg, listOrgCustomApps } from "./catalog-source";
import { repos } from "@repo/db";
import { env } from "../../config";
+import { decrypt, encrypt } from "../../lib/encryption";
import type { RequestContext } from "../../lib/request-context";
import { isLocalHostRow } from "../../lib/box-org";
import { parseServicePort } from "../../lib/deployable-service";
@@ -524,7 +525,7 @@ export async function installApp(
// lets a service env embed a generated secret it can't otherwise interpolate
// (e.g. a full `postgres://user:PASSWORD@db/…` connection URL).
const inlineConfig = (s: string): string =>
- s.replace(/\{\{\s*config:([A-Za-z0-9_]+)\s*\}\}/g, (_m, k) => resolved.get(k) ?? "");
+ s.replace(CONFIG_TOKEN_RE, (_m, k) => resolved.get(k) ?? "");
// Resolve template files per service.
const filesByService = new Map();
@@ -566,7 +567,9 @@ export async function installApp(
const rowByName = new Map(existingRows.map((s) => [s.name, s]));
// Only services this call CREATED get config/secret env written. Re-writing a
// generated secret onto an adopted row would rotate it (Convex's
- // INSTANCE_SECRET invalidates the admin key), so an existing row keeps its own.
+ // INSTANCE_SECRET invalidates the admin key), so an existing row keeps its own —
+ // and `ensureGeneratedAppSecrets` below is what fills the gap that leaves when the
+ // first attempt never got as far as writing them.
const createdServices = new Set();
// Seed the compose service rows — or, on an adopted draft, re-apply the chosen
@@ -608,6 +611,9 @@ export async function installApp(
environment: plainEnv,
volumes: svc.volumes ? [...svc.volumes] : [],
command: svc.command,
+ // Structured argv bypasses the `sh -c` wrap resolveComposeCmd applies to a
+ // bare `command`, which some images' entrypoints cannot survive.
+ commandArgv: svc.commandArgv ? [...svc.commandArgv] : undefined,
restart: svc.restart,
advanced: {
...(svc.healthcheck ? { healthcheck: svc.healthcheck } : {}),
@@ -615,6 +621,7 @@ export async function installApp(
? { files: filesByService.get(svc.name) }
: {}),
...(svc.build ? { build: resolveBuild(svc.build) } : {}),
+ ...(svc.stopGracePeriod ? { stopGracePeriod: svc.stopGracePeriod } : {}),
},
// Routing is exactly what the operator chose — never the template's
// `exposed` flag turned into a hostname.
@@ -651,5 +658,223 @@ export async function installApp(
}
}
+ // AFTER the writes above, never before: `setServiceEnvVars` REPLACES a service's
+ // whole production scope, so anything backfilled first would be wiped. This is what
+ // makes the installer's guarantee unconditional rather than "if the first attempt ran
+ // to completion".
+ await ensureGeneratedAppSecrets(project.id, template);
+
return { kind: "template", projectId: project.id, slug: project.slug };
}
+
+// ─── Generated secrets: reuse-or-mint ────────────────────────────────────────
+
+/** Scope the installer writes generated config values to. */
+const GENERATED_ENV_SCOPE = "production";
+
+/** The substitution form `inlineConfig` uses. Shared so the two cannot drift. */
+const CONFIG_TOKEN_RE = /\{\{\s*config:([A-Za-z0-9_]+)\s*\}\}/g;
+
+/**
+ * Config keys the template SUBSTITUTES into some other string — a sibling service's
+ * `DATABASE_URL`, a mounted `redis.conf`, a build arg.
+ *
+ * Those copies are written once, at install, from the value resolved then. Minting a
+ * fresh value for such a key later would leave the env row saying A while every inlined
+ * copy still says B: an app that cannot reach its own database, with nothing logged.
+ * So for these keys a missing row is reported, never invented.
+ */
+function inlinedConfigKeys(template: AppTemplate): ReadonlySet {
+ const keys = new Set();
+ const scan = (text: string | undefined | null) => {
+ if (!text) return;
+ for (const [, key] of text.matchAll(CONFIG_TOKEN_RE)) keys.add(key);
+ };
+ // The four places `inlineConfig` is applied, and only those.
+ for (const svc of template.services ?? []) {
+ for (const value of Object.values(svc.environment ?? {})) scan(value);
+ scan(svc.build?.dockerfile);
+ for (const file of svc.build?.files ?? []) scan(file.content);
+ }
+ for (const file of template.files ?? []) scan(file.content);
+ return keys;
+}
+
+/**
+ * Make every `generate:` config field the template declares EXIST on the project,
+ * minting only what is genuinely missing.
+ *
+ * `installApp` writes those values for the services one call creates, which made the
+ * guarantee conditional on a single uninterrupted pass: an adopted draft keeps whatever
+ * the first attempt wrote, and a later attempt skips the write for rows it did not
+ * create. Webmail then deploys a container with no `SESSION_ENCRYPTION_KEY`, which its
+ * image treats as fatal — a crash loop the deploy reported as success (issue #566).
+ *
+ * Idempotent and non-destructive, in that order:
+ * - PRESENCE is decided from the RAW stored keys, never from decrypted values.
+ * `decryptEnvMap` drops whatever it cannot decrypt, so after a BETTER_AUTH_SECRET
+ * rotation every secret would read as absent — and `mergeEnvVars` deletes before it
+ * inserts, which would overwrite the only copy of a database password and turn a
+ * recoverable misconfiguration into an unopenable volume.
+ * - a key the template inlines elsewhere is never minted (see above).
+ * - a `generateGroup` is resolved ACROSS services and only ever gets one value. Two
+ * fields in a group are a password that must MATCH (ghost's `ghostdb` spans
+ * ghost-db and ghost), so a per-service map would repair one half of the pair with a
+ * value the other half does not know. Where one member is already stored we reuse
+ * ITS value; where that copy cannot be decrypted the whole group is left alone.
+ *
+ * Returns the keys it wrote, for the caller's log.
+ */
+export async function ensureGeneratedAppSecrets(
+ projectId: string,
+ template: AppTemplate,
+): Promise {
+ const generated = (template.configFields ?? []).filter((f) => f.generate);
+ if (generated.length === 0) return [];
+
+ const inlined = inlinedConfigKeys(template);
+ const rows = await repos.service.listByProject(projectId);
+ const rowByName = new Map(rows.map((r) => [r.name, r]));
+ const secretKeysByService = new Map(
+ (template.services ?? []).map((s) => [s.name, new Set(s.secretEnv ?? [])] as const),
+ );
+
+ // Every service's stored scope, read before anything is decided: a group spans
+ // services, so what to do about one field can depend on another service's rows.
+ const byService = groupByService(generated);
+ const storedByService = new Map>();
+ for (const serviceName of byService.keys()) {
+ const row = rowByName.get(serviceName);
+ if (row) {
+ storedByService.set(
+ serviceName,
+ await repos.project.getEnvMap(projectId, GENERATED_ENV_SCOPE, row.id),
+ );
+ }
+ }
+ // PROJECT scope counts as present. An operator may hold a generated value at project
+ // level (`service_id IS NULL`), and the deploy layers service env ABOVE project env —
+ // so minting a service-scoped value here would SHADOW theirs and silently rotate the
+ // secret out from under a running app. Never mint over a value that is already in use,
+ // wherever it is kept.
+ const storedAtProject = await repos.project.getEnvMap(projectId, GENERATED_ENV_SCOPE, null);
+
+ const { groupValue, blockedGroups } = resolveGeneratedGroups(
+ generated,
+ storedByService,
+ storedAtProject,
+ );
+
+ const written: string[] = [];
+ for (const [serviceName, fields] of byService) {
+ const row = rowByName.get(serviceName);
+ const stored = storedByService.get(serviceName);
+ if (!row || !stored) continue;
+ const upserts: { key: string; value: string; isSecret: boolean }[] = [];
+
+ for (const field of fields) {
+ if (Object.hasOwn(stored, field.key) || Object.hasOwn(storedAtProject, field.key)) continue;
+ const group = field.generateGroup ?? field.jwtSecretGroup;
+ if (group && blockedGroups.has(group)) {
+ console.warn(
+ `[apps] ${template.id}: ${serviceName}.${field.key} is missing, but its group "${group}" already has a value elsewhere that cannot be read — leaving it alone rather than writing a value the rest of the group would not match.`,
+ );
+ continue;
+ }
+ if (inlined.has(field.key)) {
+ console.warn(
+ `[apps] ${template.id}: ${serviceName}.${field.key} is missing and is inlined elsewhere in the template — not minting a value that its existing copies would contradict.`,
+ );
+ continue;
+ }
+ const value = mintGenerated(field, groupValue);
+ if (!value) continue;
+ upserts.push({
+ key: field.key,
+ value: encrypt(value),
+ isSecret: !!field.secret || !!secretKeysByService.get(serviceName)?.has(field.key),
+ });
+ written.push(field.key);
+ }
+
+ if (upserts.length > 0) {
+ await repos.project.mergeEnvVars(projectId, GENERATED_ENV_SCOPE, upserts, [], row.id);
+ }
+ }
+
+ if (written.length > 0) {
+ console.warn(
+ `[apps] ${template.id}: backfilled generated config on ${projectId}: ${written.join(", ")}`,
+ );
+ }
+ return written;
+}
+
+/**
+ * Seed each `generateGroup` from whatever is already stored, before anything is minted.
+ *
+ * A group is one shared value across services. Three outcomes per group:
+ * - a stored member we can decrypt → its plaintext becomes the group's value, so the
+ * missing members are REPAIRED to match it instead of rotating the pair;
+ * - a stored member we cannot decrypt → the group is blocked, because any value we
+ * minted would silently disagree with the copy that is already in use;
+ * - nothing stored → left unset, and the first field that needs it mints one.
+ */
+function resolveGeneratedGroups(
+ fields: AppConfigField[],
+ storedByService: Map>,
+ storedAtProject: Record,
+): { groupValue: Map; blockedGroups: Set } {
+ const groupValue = new Map();
+ const blockedGroups = new Set();
+ for (const field of fields) {
+ const group = field.generateGroup;
+ if (!group) continue;
+ const raw = storedByService.get(field.service)?.[field.key] ?? storedAtProject[field.key];
+ if (!raw || groupValue.has(group)) continue;
+ try {
+ groupValue.set(group, decrypt(raw));
+ } catch {
+ blockedGroups.add(group);
+ }
+ }
+ // A group whose value we recovered is not blocked, whichever member we read it from.
+ for (const group of groupValue.keys()) blockedGroups.delete(group);
+ return { groupValue, blockedGroups };
+}
+
+function groupByService(fields: AppConfigField[]): Map {
+ const out = new Map();
+ for (const field of fields) {
+ const list = out.get(field.service) ?? [];
+ list.push(field);
+ out.set(field.service, list);
+ }
+ return out;
+}
+
+/**
+ * One generated value, using the same rules as the installer's `valueFor`.
+ *
+ * `groupValue` is shared across services and may already carry a value recovered from a
+ * stored member, which is what makes a repaired half of a pair match the other half. A
+ * `generate:"jwt"` field can only be signed with a secret this map knows: with no
+ * recovered and no minted secret it yields "" and is left for the operator's reinstall
+ * rather than signed with a key nothing else has.
+ */
+function mintGenerated(field: AppConfigField, groupValue: Map): string {
+ if (field.generate === "secret") {
+ if (!field.generateGroup) return generateSecret();
+ const existing = groupValue.get(field.generateGroup);
+ if (existing) return existing;
+ const secret = generateSecret();
+ groupValue.set(field.generateGroup, secret);
+ return secret;
+ }
+ if (field.generate === "jwt") {
+ const secret = field.jwtSecretGroup ? groupValue.get(field.jwtSecretGroup) : undefined;
+ if (!secret || !field.jwtRole) return "";
+ return signHs256Jwt(secret, field.jwtRole);
+ }
+ return "";
+}
diff --git a/apps/api/src/modules/audit/audit.routes.ts b/apps/api/src/modules/audit/audit.routes.ts
index c4506ec79..d9b5d4c9a 100644
--- a/apps/api/src/modules/audit/audit.routes.ts
+++ b/apps/api/src/modules/audit/audit.routes.ts
@@ -8,7 +8,8 @@
*
* Filters on the list: `category` (expanded to its event types through the
* shared taxonomy — a category is not a column), `eventType`, `actorUserId`,
- * `source`, `resourceType`, `resourceId`, `from`/`to`, and `q`.
+ * `source`, `sourceClientId` (one MCP client, not just "an assistant"),
+ * `resourceType`, `resourceId`, `from`/`to`, and `q`.
*
* `q` is deliberately more than an `event_type LIKE`: rows store ids, so
* searching "api-gateway" resolves the term against project/server/domain names
@@ -35,7 +36,7 @@ import { secureRouter } from "../../lib/secure-router";
import { getRequestContext } from "../../lib/request-context";
import { checkPermissionOnResource } from "../../lib/permission";
import { audit, auditContextFrom } from "../../lib/audit";
-import { isAuditSource } from "../../lib/call-source";
+import { isAuditClientId, isAuditSource } from "../../lib/call-source";
const r = secureRouter(new Hono(), { module: "audit", basePath: "/api/audit" });
@@ -68,6 +69,7 @@ async function filtersFromQuery(c: Context, organizationId: string) {
const category = c.req.query("category");
const eventType = c.req.query("eventType");
const source = c.req.query("source");
+ const sourceClientId = c.req.query("sourceClientId");
const q = c.req.query("q")?.trim();
return {
@@ -82,6 +84,10 @@ async function filtersFromQuery(c: Context, organizationId: string) {
resourceType: c.req.query("resourceType") || undefined,
resourceId: c.req.query("resourceId") || undefined,
source: source && isAuditSource(source) ? source : undefined,
+ // Shape-checked with the same predicate the writer uses, so a filter can only
+ // name something the column could hold. A malformed value degrades to
+ // unfiltered, matching how an unknown category behaves above.
+ sourceClientId: isAuditClientId(sourceClientId) ? sourceClientId : undefined,
from: parseDate(c.req.query("from")),
to: parseDate(c.req.query("to")),
q: q || undefined,
@@ -145,6 +151,35 @@ async function attachResourceNames(rows: AuditRow[]): Promise` → the registered MCP
+ * app's name, `pat:` → the token's name.
+ *
+ * Two batched lookups at most, in parallel, same as the resource resolver above.
+ * An unresolvable id (client deleted, token revoked and pruned) stays nameless
+ * and the UI falls back to the raw id: a row attributed to something that no
+ * longer exists is still evidence, and dropping it would be worse.
+ */
+async function resolveClientNames(ids: string[]): Promise> {
+ const names = new Map();
+ if (ids.length === 0) return names;
+
+ const oauthIds: string[] = [];
+ const patIds: string[] = [];
+ for (const id of ids) {
+ if (id.startsWith("oauth:")) oauthIds.push(id.slice("oauth:".length));
+ else if (id.startsWith("pat:")) patIds.push(id.slice("pat:".length));
+ }
+
+ const [apps, tokens] = await Promise.all([
+ oauthIds.length ? repos.oauth.listApplicationsByClientIds(oauthIds).catch(() => []) : [],
+ patIds.length ? repos.personalAccessToken.listNamesByIds(patIds).catch(() => []) : [],
+ ]);
+ for (const a of apps) names.set(`oauth:${a.clientId}`, a.name);
+ for (const t of tokens) names.set(`pat:${t.id}`, t.name);
+ return names;
+}
+
r.get("/", { tag: "audit:read" }, async (c: Context) => {
const ctx = getRequestContext(c);
const cursor = c.req.query("cursor");
@@ -168,9 +203,13 @@ r.get("/", { tag: "audit:read" }, async (c: Context) => {
const actorIds = Array.from(
new Set(result.rows.map((r) => r.actorUserId).filter((id): id is string => !!id)),
);
- const [actors, resourceNames] = await Promise.all([
+ const clientIds = Array.from(
+ new Set(result.rows.map((r) => r.sourceClientId).filter((id): id is string => !!id)),
+ );
+ const [actors, resourceNames, clientNames] = await Promise.all([
repos.user.findManyByIds(actorIds),
attachResourceNames(result.rows),
+ resolveClientNames(clientIds),
]);
const actorById = new Map(actors.map((u) => [u.id, { id: u.id, email: u.email, name: u.name }]));
@@ -181,6 +220,9 @@ r.get("/", { tag: "audit:read" }, async (c: Context) => {
row.resourceType && row.resourceId
? resourceNames.get(`${row.resourceType}:${row.resourceId}`) ?? null
: null,
+ // "Claude Desktop", not "oauth:4f2a…" — the actor a reader cares about when
+ // the human in the row only authorized the agent months ago.
+ sourceClientName: row.sourceClientId ? clientNames.get(row.sourceClientId) ?? null : null,
}));
if ("pageInfo" in result) {
@@ -205,11 +247,14 @@ r.get("/facets", { tag: "audit:read" }, async (c: Context) => {
const ctx = getRequestContext(c);
const orgId = ctx.organizationId;
const filters = await filtersFromQuery(c, orgId);
- const { eventTypes, source, ...shared } = filters;
-
- const [byEventType, bySource, actorIds, settings, canManage] = await Promise.all([
- repos.auditEvent.countByEventType(orgId, { ...shared, source }),
- repos.auditEvent.countBySource(orgId, { ...shared, eventTypes }),
+ const { eventTypes, source, sourceClientId, ...shared } = filters;
+
+ const [byEventType, bySource, byClient, actorIds, settings, canManage] = await Promise.all([
+ repos.auditEvent.countByEventType(orgId, { ...shared, source, sourceClientId }),
+ repos.auditEvent.countBySource(orgId, { ...shared, eventTypes, sourceClientId }),
+ // Counted without its own filter, like every other facet — picking one agent
+ // must not zero out the others and trap the filter on that choice.
+ repos.auditEvent.countBySourceClient(orgId, { ...shared, eventTypes, source }),
repos.auditEvent.distinctActors(orgId, { from: filters.from, to: filters.to }),
repos.auditSettings.get(orgId),
checkPermissionOnResource(ctx, { resourceType: "audit", resourceId: "*", action: "write" }),
@@ -225,7 +270,10 @@ r.get("/facets", { tag: "audit:read" }, async (c: Context) => {
if (category) categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + count);
}
- const actors = await repos.user.findManyByIds(actorIds);
+ const [actors, clientNames] = await Promise.all([
+ repos.user.findManyByIds(actorIds),
+ resolveClientNames(byClient.map((row) => row.sourceClientId)),
+ ]);
return c.json({
total,
@@ -236,6 +284,11 @@ r.get("/facets", { tag: "audit:read" }, async (c: Context) => {
count: categoryCounts.get(cat.id) ?? 0,
})),
sources: bySource.map((row) => ({ source: row.source, count: row.count })),
+ clients: byClient.map((row) => ({
+ id: row.sourceClientId,
+ name: clientNames.get(row.sourceClientId) ?? null,
+ count: row.count,
+ })),
actors: actors.map((u) => ({ id: u.id, name: u.name, email: u.email, image: u.image })),
settings,
canManage,
diff --git a/apps/api/src/modules/backups/PENDING.md b/apps/api/src/modules/backups/PENDING.md
index c005bf027..bf7bbdba5 100644
--- a/apps/api/src/modules/backups/PENDING.md
+++ b/apps/api/src/modules/backups/PENDING.md
@@ -142,8 +142,8 @@ executor, which is precisely why #434 survived a green run: neither fake could h
and neither had a container to miss. So the guarantees above are pinned by one
real-daemon test, `../../test/e2e/backup-volume-roundtrip.e2e.test.ts` — capture a
volume, empty the volume, restore, assert the tree and the bytes. It runs in the
-opt-in suite (`bun run --cwd apps/api test:e2e`, `RUN_DOCKER_E2E=1` in CI's
-`e2e-docker` job) and it is the only test that drives `backupOrchestrator.execute`
+opt-in suite (`bun run --cwd apps/api test:e2e`, `RUN_DOCKER_E2E=1` in the release
+gate's `e2e-docker` job) and it is the only test that drives `backupOrchestrator.execute`
and `restoreOrchestrator.beginPrepare`/`apply` through real archives, a real
`alpine:3` helper and a real destination on disk. It asserts the sha256 recorded at
capture is the one prepare recomputes from the destination's bytes
@@ -172,11 +172,13 @@ itself the regression test for the removed cast.
**CI triggers, split by cost.** `e2e-docker` is a two-scope matrix (`E2E_SCOPE` in
`apps/api/vitest.e2e.config.ts`): `fast` — every daemon-level and full-cycle case,
-~5 min — runs on every PR and push; `heavy` — `rollback-build-restore` alone, ~225s
-cold because it pulls a Node base image and runs a real build, with
-`fileParallelism: false` holding the suite up meanwhile — runs on `workflow_dispatch`,
-the nightly `schedule`, and `v*` tags, so a release is proven restorable before it
-ships. `ci.yml` also gained a `concurrency` group (PR pushes cancel their
+~5 min; `heavy` — `rollback-build-restore` alone, ~225s cold because it pulls a Node
+base image and runs a real build, with `fileParallelism: false` holding the suite up
+meanwhile. Both scopes live in `release-gate.yml` and run on `workflow_dispatch` and on
+every publish, because `Release` and `Docker images` list that gate in `needs:` — so a
+release really is proven restorable before it ships. (It was not, for as long as the job
+sat in `ci.yml`: a separate workflow on the same tag push is not ordered against the
+publish, so the suite finished whenever it finished, alongside the release.) `ci.yml` also gained a `concurrency` group (PR pushes cancel their
predecessor; main and tags run to completion) and a Docker Hub login guarded on a
secret that does not exist yet — hosted runners share outbound IPs, so anonymous
pulls hit `toomanyrequests` as a function of strangers' traffic and it reads as a
diff --git a/apps/api/src/modules/deployments/compose/deploy.service.ts b/apps/api/src/modules/deployments/compose/deploy.service.ts
index d378b473a..7db17253c 100644
--- a/apps/api/src/modules/deployments/compose/deploy.service.ts
+++ b/apps/api/src/modules/deployments/compose/deploy.service.ts
@@ -24,6 +24,7 @@ import {
composeNamespaceRef,
ownsNetworkEndpoint,
UNLIMITED_RESOURCES,
+ safeErrorMessage,
type ComposeAdvanced,
type ProxySettings,
} from "@repo/core";
@@ -33,7 +34,7 @@ import {
BuildLogger,
DockerRuntime,
allocateHostPort,
- elevatedExecutor,
+ rootOrDegrade,
resolveEnvironment,
runDeployPipeline,
type CommandExecutor,
@@ -94,7 +95,10 @@ import { compileProjectRoutingFields } from "../../../lib/project-routing-fields
import { buildCompositeRegistration, buildDomainFanoutRegistrations } from "./composite-route";
import { newerThanRestoredRelease, serviceKind } from "./project-services";
import { buildUpstreamUrl, resolveRouteStrategy } from "../../../lib/upstream-url";
-import { withLoopbackPublish } from "../../../lib/loopback-publish";
+import {
+ withLoopbackPublishAll,
+ upstreamHostPortFor,
+} from "../../../lib/loopback-publish";
export interface ComposeDeployResult {
/** `reconciling` when at least one service's outcome is UNKNOWN because the
@@ -790,6 +794,33 @@ export async function deployComposeServices(
localHost?: boolean;
},
): Promise {
+ // Generated app secrets, BEFORE any env is read below. A catalog app whose install died
+ // part-way keeps a service row with the generated values missing, and the installer only
+ // writes them for services it created — so webmail reached the container with no
+ // SESSION_ENCRYPTION_KEY and crash-looped (#566). The install path repairs that, but
+ // Redeploy / Start / a webhook push all arrive HERE without passing through it, and that
+ // Redeploy button is the natural next click after seeing a bouncing container.
+ //
+ // Idempotent (a stored value is reused, never rotated) and best-effort: a repair we
+ // could not make must not fail a deploy that would otherwise run.
+ if (project.appTemplateId) {
+ try {
+ const template = await getTemplateForOrg(project.organizationId, project.appTemplateId);
+ if (template) {
+ // Dynamic: a static import would pull the app-install service — and through it the
+ // whole services/auth layer — into every module that imports this one. Same reason
+ // mail.service.ts imports deployment-runtime lazily.
+ const { ensureGeneratedAppSecrets } = await import("../../apps/app-install.service");
+ await ensureGeneratedAppSecrets(project.id, template);
+ }
+ } catch (err) {
+ logger.log(
+ `Could not check this app's generated secrets: ${safeErrorMessage(err)}\n`,
+ "warn",
+ );
+ }
+ }
+
const services = await repos.service.listByProject(project.id);
const enabled = services.filter((s) => s.enabled);
@@ -1145,19 +1176,21 @@ export async function deployComposeServices(
// how the edge writes its own root-owned config. Resolved lazily and cached, so
// privilege detection is skipped entirely for deploys that ship no config files
// and never re-run per service.
+ // Through the shared gate rather than an inline copy of it. This was the only
+ // `elevatedExecutor` call site in apps/*, and it re-derived the decision that
+ // `rootOrDegrade` exists to own: same sudo arm, but it also REPORTS when the login can
+ // neither be root nor sudo, instead of silently handing back the plain executor and
+ // letting the write fail later as "No such file". Still lazy and still cached, so a
+ // deploy that ships no config files never probes privileges.
let hostConfigWriter: Promise | null = null;
const resolveHostConfigWriter = (executor: CommandExecutor): Promise => {
- hostConfigWriter ??= (async () => {
- try {
- const env = await resolveEnvironment(executor);
- if (!env.isRoot && env.canSudo) return elevatedExecutor(executor);
- } catch {
- // Privilege probe failed — fall back to the plain executor. A root or
- // already-writable target still succeeds; a locked-down one fails loudly
- // at write time, exactly as it did before this guard.
- }
- return executor;
- })();
+ hostConfigWriter ??= rootOrDegrade(executor, {
+ purpose: "Writing generated app config files to the host",
+ consequence:
+ "A config file the app needs may be missing, so the service can start misconfigured " +
+ "or fail outright.",
+ report: (message) => logger.log(`${message}\n`, "warn"),
+ });
return hostConfigWriter;
};
@@ -1329,12 +1362,12 @@ export async function deployComposeServices(
status: "failed",
error: message,
});
- await repos.service.createServiceDeployment({
+ await repos.service.markServiceDeploymentFailed({
deploymentId: dep.id,
serviceId: svc.id,
serviceName: svc.name,
- status: "failure",
imageRef: opts?.builtImages?.get(svc.id) ?? svc.image ?? null,
+ errorMessage: message,
});
results.push({
serviceId: svc.id,
@@ -1393,12 +1426,12 @@ export async function deployComposeServices(
status: "failed",
error: buildFailure,
});
- await repos.service.createServiceDeployment({
+ await repos.service.markServiceDeploymentFailed({
deploymentId: dep.id,
serviceId: svc.id,
serviceName: svc.name,
- status: "failure",
imageRef: svc.image ?? null,
+ errorMessage: buildFailure,
});
results.push({
serviceId: svc.id,
@@ -1428,11 +1461,11 @@ export async function deployComposeServices(
status: "failed",
error: message,
});
- await repos.service.createServiceDeployment({
+ await repos.service.markServiceDeploymentFailed({
deploymentId: dep.id,
serviceId: svc.id,
serviceName: svc.name,
- status: "failure",
+ errorMessage: message,
});
results.push({
serviceId: svc.id,
@@ -1575,12 +1608,12 @@ export async function deployComposeServices(
status: "failed",
error: message,
});
- await repos.service.createServiceDeployment({
+ await repos.service.markServiceDeploymentFailed({
deploymentId: dep.id,
serviceId: svc.id,
serviceName: svc.name,
- status: "failure",
imageRef: image ?? null,
+ errorMessage: message,
});
results.push({ serviceId: svc.id, serviceName: svc.name, status: "failed", error: message });
unavailableServiceNames.add(svc.name);
@@ -1721,50 +1754,77 @@ export async function deployComposeServices(
);
}
- // loopback-port routing (compose parity, mirrors single-app): republish the
- // PRIMARY routed container port on `127.0.0.1:` so the edge
- // reaches it on loopback and it isn't network-exposed. We OWN the pinned
- // port (reuse the carried one, else allocate avoiding this deploy's picks),
- // so the route resolves to it deterministically — no reading it back from
- // the daemon's ambiguous first-binding. Port-only bindings the user declared
- // for direct access are preserved. Cloud handles exposure itself; bare/no-
- // executor can't publish → skip (route falls back to container-IP/loopback).
+ // loopback-port routing (compose parity, mirrors single-app): republish EVERY
+ // routed container port on its OWN `127.0.0.1:` so the edge
+ // reaches each on loopback and none is network-exposed. We OWN the pinned
+ // ports (reuse the carried one for the primary, else allocate avoiding this
+ // deploy's picks), so each route resolves deterministically — no reading it
+ // back from the daemon's ambiguous first-binding. Port-only bindings the user
+ // declared for direct access are preserved. Cloud handles exposure itself;
+ // bare/no-executor can't publish → skip (route falls back to container-IP).
+ //
+ // ONE HOST PORT PER ROUTED PORT, not one per service: a service can carry
+ // several routes (minio's console + `s3` API, convex's API + `http`), and
+ // pinning only `proxyRoutes[0]` while `resolveTargetUrl` returned that single
+ // port for every route made each extra subdomain silently proxy to the FIRST
+ // route's port. minio's s3 host served the console; convex's http host served
+ // the 3210 API. Only the primary port is persisted (`service_deployment`
+ // holds one), which is why the extras are re-pinned and re-registered on
+ // every deploy rather than carried.
const composeRouteStrategy = resolveRouteStrategy(project.routeStrategy);
- const routedContainerPort = proxyRoutes[0]?.targetPort;
+ const routedContainerPorts = [
+ ...new Set(
+ proxyRoutes
+ .map((r) => r.targetPort)
+ .filter((p): p is number => typeof p === "number" && p > 0),
+ ),
+ ];
+ const primaryRoutedPort = routedContainerPorts[0];
+ /** routed container port → the loopback host port WE pinned for it. */
+ const pinnedHostPortByContainerPort = new Map();
let servicePinnedHostPort: number | undefined;
if (
composeRouteStrategy === "loopback-port" &&
runtime.name !== "cloud" &&
- routedContainerPort !== undefined &&
+ primaryRoutedPort !== undefined &&
// A container with no endpoint of its own publishes nothing — allocating a
// host port would burn it and pin a route to an upstream that never binds.
!hasNoRoutableAddress &&
opts?.executor
) {
- const carried = previousByServiceId.get(svc.id)?.hostPort;
- if (carried) {
- servicePinnedHostPort = carried;
- } else {
- const allocation = await allocateHostPort(opts.executor, { avoid: usedHostPorts });
- servicePinnedHostPort = allocation.port;
- // "Couldn't read occupancy" is not "nothing is listening" — without this the
- // bind failure that follows blames Docker for an unreachable host (#490).
- if (!allocation.scanned) {
- logger.log(
- `Couldn't read live port occupancy on the target, so ${allocation.port} for ` +
- `${svc.name} avoids only ports this deploy already took. If publishing it fails ` +
- `as "already allocated", check that Openship can reach this host ` +
- `(Servers → this box).\n`,
- "warn",
- );
+ for (const containerPort of routedContainerPorts) {
+ // Only the primary reuses the carried port: it is the one persisted, so
+ // it is the only one whose previous value is knowable.
+ const carried =
+ containerPort === primaryRoutedPort
+ ? previousByServiceId.get(svc.id)?.hostPort
+ : undefined;
+ let hostPort: number;
+ if (carried) {
+ hostPort = carried;
+ } else {
+ const allocation = await allocateHostPort(opts.executor, { avoid: usedHostPorts });
+ hostPort = allocation.port;
+ // "Couldn't read occupancy" is not "nothing is listening" — without this the
+ // bind failure that follows blames Docker for an unreachable host (#490).
+ if (!allocation.scanned) {
+ logger.log(
+ `Couldn't read live port occupancy on the target, so ${allocation.port} for ` +
+ `${svc.name} avoids only ports this deploy already took. If publishing it fails ` +
+ `as "already allocated", check that Openship can reach this host ` +
+ `(Servers → this box).\n`,
+ "warn",
+ );
+ }
}
+ usedHostPorts.add(hostPort);
+ pinnedHostPortByContainerPort.set(containerPort, hostPort);
}
- usedHostPorts.add(servicePinnedHostPort);
- serviceRuntimeConfig.ports = withLoopbackPublish(
+ serviceRuntimeConfig.ports = withLoopbackPublishAll(
serviceRuntimeConfig.ports,
- routedContainerPort,
- servicePinnedHostPort,
+ pinnedHostPortByContainerPort,
);
+ servicePinnedHostPort = pinnedHostPortByContainerPort.get(primaryRoutedPort);
}
let deployedContainerId: string | undefined;
@@ -1795,10 +1855,18 @@ export async function deployComposeServices(
? async (containerId, port) => {
const strategy = resolveRouteStrategy(project.routeStrategy);
const sameSvc = serviceResult?.containerId === containerId;
- // Prefer the port WE pinned+published this deploy (deterministic);
- // fall back to the port reported by the deploy result otherwise.
- const hostPort =
- servicePinnedHostPort ?? (sameSvc ? serviceResult?.hostPort : undefined);
+ // Prefer the port WE pinned+published for THIS container port
+ // (deterministic); fall back to the port the deploy result
+ // reported. That fallback is a single scalar read off the daemon,
+ // so it is only meaningful for the primary route — applying it to
+ // a secondary port is the collapse this map exists to prevent.
+ const hostPort = upstreamHostPortFor({
+ port,
+ pinned: pinnedHostPortByContainerPort,
+ primaryPort: primaryRoutedPort,
+ resultHostPort: serviceResult?.hostPort,
+ sameService: sameSvc,
+ });
// loopback-port → the service's published host port; else the
// container IP (cached from the deploy result when we can).
if (strategy === "loopback-port" && hostPort) {
@@ -2076,12 +2144,12 @@ export async function deployComposeServices(
error: message,
});
- await repos.service.createServiceDeployment({
+ await repos.service.markServiceDeploymentFailed({
deploymentId: dep.id,
serviceId: svc.id,
serviceName: svc.name,
- status: "failure",
imageRef: image,
+ errorMessage: message,
});
results.push({
diff --git a/apps/api/src/modules/deployments/deployment.schema.ts b/apps/api/src/modules/deployments/deployment.schema.ts
index 4cc12040d..dc45f7855 100644
--- a/apps/api/src/modules/deployments/deployment.schema.ts
+++ b/apps/api/src/modules/deployments/deployment.schema.ts
@@ -67,6 +67,12 @@ const BuildServiceInput = Type.Object({
environment: Type.Record(Type.String(), Type.String()),
volumes: Type.Array(Type.String()),
command: Type.Optional(Type.String()),
+ // #332: the string above is a lossy join for a list command, so it was not
+ // possible to express `["sh","-c","a && b"]` on this route at all — and these
+ // entries are persisted (requestBuildAccess → syncFromCompose), so a client
+ // replaying its service list re-split the stored argv. The repo now keeps an
+ // unchanged string from disturbing argv; this lets a client be explicit.
+ commandArgv: Type.Optional(Type.Array(Type.String())),
restart: Type.Optional(Type.String()),
exposed: Type.Optional(Type.Boolean()),
exposedPort: Type.Optional(Type.String()),
diff --git a/apps/api/src/modules/deployments/rollback/PENDING.md b/apps/api/src/modules/deployments/rollback/PENDING.md
index 8e66efc6f..62d30e079 100644
--- a/apps/api/src/modules/deployments/rollback/PENDING.md
+++ b/apps/api/src/modules/deployments/rollback/PENDING.md
@@ -69,8 +69,10 @@ forward-compatibility placeholder).
Real-daemon E2Es under `apps/api/test/e2e/`. They run only in the opt-in suite
(`bun run --cwd apps/api test:e2e`) — `bun run test` excludes the directory — and
-CI runs them in the `e2e-docker` job with `RUN_DOCKER_E2E=1`, which turns "no
-daemon" into a FAILURE instead of a skip. Locally the socket is resolved the way
+the release gate (`release-gate.yml`) runs them in the `e2e-docker` job with
+`RUN_DOCKER_E2E=1`, which turns "no daemon" into a FAILURE instead of a skip — and,
+since `Release` and `Docker images` both list that gate in `needs:`, into a blocked
+publish. Locally the socket is resolved the way
the product resolves it (explicit → `DOCKER_HOST` → active `docker context` →
`/var/run/docker.sock`), so Colima / Rancher / Podman need no env at all; a skip
prints the reason. See `apps/api/test/helpers/docker-e2e.ts`.
diff --git a/apps/api/src/modules/domains/domain.service.ts b/apps/api/src/modules/domains/domain.service.ts
index 6dd31fce6..1fb524658 100644
--- a/apps/api/src/modules/domains/domain.service.ts
+++ b/apps/api/src/modules/domains/domain.service.ts
@@ -30,6 +30,7 @@ import { resolveRecords } from "../../lib/dns-resolver";
import { resolveProjectServerHost, resolveLocalServerHost, resolveInstancePublicIp, isLoopbackHost } from "../../lib/server-target";
import { reconcileProjectRoutes } from "../../lib/route-apply.service";
import { generateToken } from "../../lib/domain-token";
+import { routableWithoutOwnership } from "../../lib/domain-claims";
import { untrackedSiteFor } from "../../lib/edge-orphans.service";
import type { UntrackedEdgeSite } from "@repo/core";
import { publicEndpointHostname, resolveServicePublicEndpoints } from "../../lib/public-endpoints";
@@ -409,6 +410,15 @@ export async function ensurePendingServiceDomain(opts: {
// must neither create (collision) nor touch theirs (cross-tenant write) —
// surface it as a conflict (matches addDomain) instead of silently skipping.
const foreign = await repos.domain.findByHostname(hostname);
+
+ // Same general question the deploy path asks — see lib/domain-claims. A hostname
+ // another subsystem owns can still be routable by this project, and the claim decides
+ // that, not this function. Asked even when there is NO row, for the reason documented
+ // there.
+ if (await routableWithoutOwnership(hostname, opts.projectId, foreign)) {
+ return { created: false, domainId: null };
+ }
+
if (foreign) {
throw new ConflictError(
`The domain "${hostname}" is already connected to another project.`,
diff --git a/apps/api/src/modules/jobs/job.registry.ts b/apps/api/src/modules/jobs/job.registry.ts
index 5fb79cca7..301fa7dfa 100644
--- a/apps/api/src/modules/jobs/job.registry.ts
+++ b/apps/api/src/modules/jobs/job.registry.ts
@@ -316,6 +316,44 @@ export const SYSTEM_JOB_DEFS: SystemJobDef[] = [
return { servers: r.servers, behind: r.behind, updated: r.updated };
},
},
+ {
+ key: "mail:inbound-watch",
+ label: "Inbound mail rules",
+ // Reads each armed collector folder, matches captured mail against the operator's
+ // inbound rules, emits notifications, and DELETES what it handled — the deletion is
+ // both the cursor and the prune that keeps a second full copy of every watched
+ // message from accumulating under /var/vmail (a bind mount with no quota of its own).
+ //
+ // Every minute: this is the feature's latency, and a rule that says "tell me when
+ // support@ gets mail" is not useful at a six-hour cadence. A server with no rules
+ // costs one indexed query and no SSH at all. Self-hosted + desktop only — the mail
+ // module does not exist in the cloud runtime.
+ defaultCron: "* * * * *",
+ available: () => platform().target !== "cloud",
+ run: async () => {
+ const { runInboundWatch } = await import("../mail/inbound/watch");
+ return runInboundWatch();
+ },
+ },
+ {
+ key: "mail:inbound-reconcile",
+ label: "Inbound mail capture drift",
+ // Brings each engine's armed BCC rows back in line with the rules that exist.
+ // Separate from the one-minute read sweep above because it costs SSH per mail
+ // server and repairs nothing latency-sensitive: a `scope: "all"` rule picking up
+ // a domain added after it was written, an arm/disarm that failed mid-write, or a
+ // collector mailbox an operator deleted by hand.
+ //
+ // Deliberately NOT the defence against an unpruned collector filling /var/vmail —
+ // the write path disarms what it orphans (GH-559). This is the backstop, so a
+ // slow off-peak cadence is right. Off the :00/:30 marks.
+ defaultCron: "17 */2 * * *",
+ available: () => platform().target !== "cloud",
+ run: async () => {
+ const { runInboundReconcile } = await import("../mail/inbound/watch");
+ return runInboundReconcile();
+ },
+ },
{
key: "services:health-watch",
label: "Container health watch",
diff --git a/apps/api/src/modules/mail/admin/admin.controller.ts b/apps/api/src/modules/mail/admin/admin.controller.ts
index 6bf84811d..105b6f5db 100644
--- a/apps/api/src/modules/mail/admin/admin.controller.ts
+++ b/apps/api/src/modules/mail/admin/admin.controller.ts
@@ -50,7 +50,7 @@ import { getMailServerStats } from "./stats.service";
import { scanDns } from "./dns-scan.service";
import { sendTestEmail, TestEmailError } from "./test-email.service";
import { AppError, isRelayProviderId, safeErrorMessage } from "@repo/core";
-import { handleApiError } from "../../../middleware/error-handler";
+import { handleApiError, requestTag } from "../../../middleware/error-handler";
import {
getComponentLogs,
restartAllComponents,
@@ -862,5 +862,11 @@ function errorJson(c: Context, err: unknown) {
// The SSH+psql layer throws plain Error for any non-shape error
// (connection failure, SQL syntax, validation). 500 is the right default;
// typed errors above are caught and mapped to 4xx individually.
+ //
+ // Logged HERE because we answer the response ourselves: `app.onError` only sees
+ // errors that were never caught, so every mail-admin 500 left the API log with
+ // nothing but hono's `--> … 500` (the second half of GH-562). The AppError branch
+ // above logs through `handleApiError`, so no path logs twice.
+ console.error(`[MAIL ADMIN ERROR] ${requestTag(c)}`, err);
return c.json({ error: message }, 500);
}
diff --git a/apps/api/src/modules/mail/admin/backup-plan.ts b/apps/api/src/modules/mail/admin/backup-plan.ts
index 941056ff7..c6a961816 100644
--- a/apps/api/src/modules/mail/admin/backup-plan.ts
+++ b/apps/api/src/modules/mail/admin/backup-plan.ts
@@ -18,7 +18,14 @@
*/
import { HOST_STATE_DIR } from "@repo/adapters";
-import { HOST_AMAVIS_CONF_CANDIDATES } from "../mail-engine";
+import {
+ HOST_AMAVIS_CONF_CANDIDATES,
+ mailDaemonReloadCommand,
+ mailEngineCommand,
+ mailPgDumpToStdout,
+ mailPsqlFromStdin,
+ type MailEngineFlavor,
+} from "../mail-engine";
/** The four tables that hold accounts / domains / aliases / admins. */
const ACCOUNT_TABLES = ["domain", "mailbox", "forwardings", "domain_admins"] as const;
@@ -67,6 +74,7 @@ export interface MailBackupPayload {
export function buildMailBackupPayload(
domain: string,
flags: MailBackupFlags,
+ flavor: MailEngineFlavor,
): MailBackupPayload {
const tableArgs = ACCOUNT_TABLES.map((t) => `-t ${t}`).join(" ");
const truncateList = ACCOUNT_TABLES.join(", ");
@@ -77,7 +85,7 @@ export function buildMailBackupPayload(
'tmp="$(mktemp -d)"',
"trap 'rm -rf \"$tmp\"' EXIT",
// Accounts + auth — always. Plain-SQL data-only dump (COPY blocks).
- `sudo -u postgres pg_dump -d vmail --data-only --no-owner --no-privileges ${tableArgs} > "$tmp/vmail.data.sql"`,
+ `${mailPgDumpToStdout(flavor, `--data-only --no-owner --no-privileges ${tableArgs}`)} > "$tmp/vmail.data.sql"`,
// What's inside — read by the UI / hand-restore.
`printf '%s' '${info.replace(/'/g, "'\\''")}' > "$tmp/mail-backup.json"`,
flags.keys
@@ -95,12 +103,16 @@ export function buildMailBackupPayload(
// is a disaster-recovery archive which is silently not one. Same shape as the
// amavis bug this file's own header documents.
//
- // `sudo -n` needs no new capability: `sudo -u postgres pg_dump` above already
- // makes sudo a hard requirement, and under `set -e` a host without it fails
- // there — so reaching this line means sudo works, and a `test` that says "no"
- // now means genuinely absent rather than merely unreadable. That is what makes
- // dropping `|| true` safe: absence still skips, and only a real failure to
- // collect a secret the operator explicitly asked for stops the backup.
+ // The `sudo -n` reads below only distinguish "absent" from "unreadable" if sudo
+ // is known to WORK — otherwise a failing `test` reads as absence and skips, and
+ // we are back to an archive stamped `keys: true` with no keys in it.
+ //
+ // That used to be guaranteed for free: `sudo -u postgres pg_dump` ran first, so
+ // under `set -e` a box without sudo never reached here. Routing the dump through
+ // the DB sidecar for containerized engines (GH-563) removed the guarantee on
+ // exactly the topology most installs now use, so the probe is explicit. It is
+ // the whole reason dropping `|| true` on the copies is safe.
+ "sudo -n true",
'if sudo -n test -d /var/lib/dkim; then sudo -n cp -a /var/lib/dkim "$tmp/keys/dkim"; fi',
// Whichever of the known locations this box actually uses, with the source
// path recorded alongside so the restore puts it back where amavis reads it.
@@ -137,8 +149,8 @@ export function buildMailBackupPayload(
// stdin = the tar.zst artifact.
'zstd -d | tar -x -C "$tmp"',
// Data-only restore: wipe the target's account tables, then load.
- `sudo -u postgres psql -d vmail -v ON_ERROR_STOP=1 -c 'TRUNCATE ${truncateList} CASCADE;'`,
- 'sudo -u postgres psql -d vmail -v ON_ERROR_STOP=1 -f "$tmp/vmail.data.sql"',
+ `printf '%s' 'TRUNCATE ${truncateList} CASCADE;' | ${mailPsqlFromStdin(flavor)}`,
+ `${mailPsqlFromStdin(flavor)} < "$tmp/vmail.data.sql"`,
// DKIM keys + amavis config (if the archive carried them).
'if [ -d "$tmp/keys/dkim" ]; then mkdir -p /var/lib/dkim && cp -a "$tmp/keys/dkim/." /var/lib/dkim/ || true; fi',
// Where to put it is decided by THIS box, not by the box the archive came from.
@@ -176,11 +188,16 @@ export function buildMailBackupPayload(
` cp -a "$tmp/${AMAVIS_IN_ARCHIVE_LEGACY}" "$dest" || true`,
`fi`,
// Maildirs (if included). Ownership must be vmail:vmail for Dovecot.
- 'if [ -d "$tmp/vmail1" ]; then cp -a "$tmp/vmail1" /var/vmail/ && chown -R vmail:vmail /var/vmail/vmail1 || true; fi',
+ // /var/vmail is a bind mount, so the COPY is a host operation either way - but the
+ // ownership has to be applied where the `vmail` user exists, which on a containerized
+ // box is the engine, not the host (GH-563). No `|| true` on the chown: maildirs left
+ // root-owned are maildirs Dovecot cannot read, and swallowing that produced a restore
+ // that reported success and served nothing.
+ `if [ -d "$tmp/vmail1" ]; then cp -a "$tmp/vmail1" /var/vmail/ && ${mailEngineCommand(flavor, "chown -R vmail:vmail /var/vmail/vmail1")}; fi`,
// Recompute per-domain counters (app-managed, not DB triggers).
- "sudo -u postgres psql -d vmail -c \"UPDATE domain d SET mailboxes=(SELECT count(*) FROM mailbox m WHERE m.domain=d.domain), aliases=(SELECT count(*) FROM forwardings f WHERE f.domain=d.domain AND f.is_alias) WHERE d.domain IS NOT NULL;\" || true",
+ `printf '%s' "UPDATE domain d SET mailboxes=(SELECT count(*) FROM mailbox m WHERE m.domain=d.domain), aliases=(SELECT count(*) FROM forwardings f WHERE f.domain=d.domain AND f.is_alias) WHERE d.domain IS NOT NULL;" | ${mailPsqlFromStdin(flavor)} || true`,
// Reload daemons so the restored data + keys take effect.
- "systemctl reload postfix dovecot 2>/dev/null || true; systemctl restart amavis 2>/dev/null || true",
+ `${mailDaemonReloadCommand(flavor)} || true`,
].join("\n");
return {
diff --git a/apps/api/src/modules/mail/admin/components.service.ts b/apps/api/src/modules/mail/admin/components.service.ts
index 76a6cb461..eadfe5426 100644
--- a/apps/api/src/modules/mail/admin/components.service.ts
+++ b/apps/api/src/modules/mail/admin/components.service.ts
@@ -25,22 +25,43 @@
*
* Fixes applied below:
* • Return the instant the supervisor accepts the job (`--no-block` on
- * systemd; supervisorctl behaves that way already); the daemon cycles in
- * the background and Health tab polling reflects the new state seconds later.
+ * systemd; supervisorctl behaves that way already), then re-probe once so the
+ * answer carries the daemon's OBSERVED state and not the acknowledgement — a
+ * daemon that FATALs on its next breath must not come back as "restarted".
+ * A daemon that dies later still belongs to the Health tab's 10 s poll.
* • Wrap commands as `( … ; echo __EXIT=$?__ ) 2>&1`. The subshell
* always exits 0 (echo succeeds), so the SSH layer always sees a
* clean close. We parse the real exit code from stdout.
- * • Cap remote-side execution with `timeout N` for log tails so a
- * stuck journal can't tie up the SSH session.
+ * • Cap remote-side execution with `timeout N` for log tails so a stuck
+ * read can't tie up the SSH session.
*/
-import { safeErrorMessage } from "@repo/core";
+import { AppError, safeErrorMessage } from "@repo/core";
import { MAIL_COMPONENTS } from "../mail-health.service";
-import { mailUnitActionCommand, mailUnitLogsCommand, runMailCommand } from "../mail-engine";
+import {
+ mailUnitActionCommand,
+ mailUnitLogsRead,
+ mailUnitProbeCommand,
+ parseMailUnitProbe,
+ runMailCommand,
+ type MailEngineFlavor,
+ type MailUnitState,
+} from "../mail-engine";
export class UnknownComponentError extends Error {}
+/**
+ * A daemon the supervisor refused to bring up. 409, not 500: nothing here is broken
+ * except the daemon, and the panel offers logs + retry off the code.
+ */
+export class MailComponentActionError extends AppError {
+ constructor(message: string) {
+ super(message, 409, "MAIL_COMPONENT_ACTION_FAILED");
+ this.name = "MailComponentActionError";
+ }
+}
+
export type ComponentAction = "restart" | "start" | "stop";
const ACTIONS: readonly ComponentAction[] = ["restart", "start", "stop"];
@@ -66,14 +87,14 @@ function resolveUnit(key: string): string {
*/
async function execWithExitMarker(
serverId: string,
- build: (flavor: "container" | "host" | "none") => string,
+ build: (flavor: MailEngineFlavor) => string,
timeoutMs: number,
-): Promise<{ output: string; code: number }> {
+): Promise<{ flavor: MailEngineFlavor; output: string; code: number }> {
// Wrapping subshell: real command runs, exit marker is emitted on stdout,
// subshell always returns 0 so ssh2 closes cleanly.
- const { output: raw } = await runMailCommand(
+ const { flavor, output: raw } = await runMailCommand(
serverId,
- (flavor) => `( ${build(flavor)} ; echo __EXIT=$?__ ) 2>&1`,
+ (f) => `( ${build(f)} ; echo __EXIT=$?__ ) 2>&1`,
{ timeout: timeoutMs, requireRunning: false },
);
const match = raw.match(/__EXIT=(\d+)__\s*$/);
@@ -81,19 +102,30 @@ async function execWithExitMarker(
// No marker means the wrapping shell never reached the echo - either
// killed mid-flight or output was truncated. Treat as failure with the
// raw output as the error body.
- return { output: raw.trim(), code: -1 };
+ return { flavor, output: raw.trim(), code: -1 };
}
const code = Number(match[1]);
const output = raw.replace(/__EXIT=\d+__\s*$/, "").trim();
- return { output, code };
+ return { flavor, output, code };
}
export interface ComponentActionResult {
key: string;
unit: string;
action: ComponentAction;
- /** Trimmed combined stdout+stderr from systemctl. Empty on a clean run. */
+ /** Trimmed combined stdout+stderr from the supervisor. Empty on a clean run. */
output: string;
+ /**
+ * What the daemon was doing a moment after the supervisor accepted the job.
+ *
+ * "Accepted" is not "running": `supervisorctl restart` returns as soon as the
+ * program survives `startsecs`, so a ClamAV that FATALs on its next breath used to
+ * come back as an unqualified success and the UI toasted "ClamAV restarted".
+ * Absent when the state was still TRANSITIONAL or the probe itself failed — the
+ * caller then keeps its optimistic wording rather than crying wolf over a daemon
+ * that is simply still starting.
+ */
+ settled?: MailUnitState;
}
export async function runComponentAction(
@@ -110,24 +142,92 @@ export async function runComponentAction(
(flavor) => mailUnitActionCommand(flavor, key, unit, action),
20_000,
);
- if (code !== 0) {
- throw new Error(output || `${action} ${unit} failed (exit ${code})`);
+ if (!NOT_INSTALLED.test(output) && (code !== 0 || FATAL_REFUSAL.test(output))) {
+ throw new MailComponentActionError(output || `${action} ${unit} failed (exit ${code})`);
+ }
+ return { key, unit, action, output, settled: await settledState(serverId, key, unit) };
+}
+
+/**
+ * "This box doesn't ship that daemon." supervisord says `ERROR (no such process)`,
+ * systemd says `not-found` / `not loaded`. Tested BEFORE the refusal grading in both
+ * the single and the bulk path, so the two can never grade identical output
+ * differently.
+ */
+const NOT_INSTALLED = /not[-\s]?found|not loaded|no such process/i;
+
+/**
+ * Refusals supervisorctl prints on stdout while still exiting 0 — it propagates an
+ * exit code for its OWN failures, not the program's, so `code !== 0` alone reported a
+ * restart that never happened as a success.
+ *
+ * `already started` / `not running` are deliberately absent: they mean the box is
+ * already in the state we asked for, so they ride back in `output` for the panel to
+ * show instead of failing the call.
+ */
+const FATAL_REFUSAL = /abnormal termination|spawn error|unknown error|not authorized/i;
+
+/** Long enough for a program that dies inside supervisord's `startsecs` to reach BACKOFF. */
+const SETTLE_MS = 1_500;
+
+/**
+ * Re-read the daemon's state after the supervisor accepted the job.
+ *
+ * Returns `undefined` — "accepted, not confirmed" — for a probe we could not take AND
+ * for a state that is still in motion. `systemctl --no-block restart dovecot` returns
+ * instantly and the unit sits in `activating` for 30-90 s, which is the normal case
+ * that forced `--no-block` in the first place; reporting that as a disagreement would
+ * cry wolf on every healthy slow restart.
+ *
+ * It also cannot catch a daemon that dies LATER — clamd loading a large signature set
+ * can survive `startsecs` and be OOM-killed at t+10 s — which is why the caller
+ * downgrades its toast rather than declaring success, and the Health tab's 10 s poll
+ * delivers the verdict.
+ */
+async function settledState(
+ serverId: string,
+ key: string,
+ unit: string,
+): Promise {
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
+ try {
+ const { flavor, output } = await runMailCommand(
+ serverId,
+ (f) => mailUnitProbeCommand(f, key, unit),
+ { timeout: 15_000, requireRunning: false },
+ );
+ const state = parseMailUnitProbe(flavor, key, unit, output);
+ if (state.status === "activating" || state.status === "deactivating") return undefined;
+ if (state.status === "unknown") return undefined;
+ return state;
+ } catch {
+ return undefined;
}
- return { key, unit, action, output };
}
export interface ComponentLogs {
key: string;
unit: string;
- /** Newest-last journal lines. */
+ /** Newest-last log lines. */
lines: string[];
+ /**
+ * The read we performed, in display form: `docker exec openship-mail tail -n 300
+ * /var/log/supervisor/clamav-daemon.log` on the container engine, `journalctl -u …`
+ * only on a legacy host.
+ *
+ * Carried with the payload on purpose. The drawer used to print a hardcoded
+ * journalctl header, naming a log the container engine has not got — and it must
+ * not be re-derived client-side either, which would be a second, unverified guess
+ * at this box's topology.
+ */
+ source: string;
}
/**
- * Tail recent log lines for a component. We cap the request size
- * server-side so a misbehaving client can't ask for a million lines and
- * tie up the SSH session, and use a remote-side `timeout` so a hung
- * journal can't sit on the SSH channel either.
+ * Tail recent log lines for a component, and report which log they came from. We cap
+ * the request size server-side so a misbehaving client can't ask for a million lines
+ * and tie up the SSH session, and use a remote-side `timeout` so a hung read can't
+ * sit on the SSH channel either.
*/
export async function getComponentLogs(
serverId: string,
@@ -136,16 +236,13 @@ export async function getComponentLogs(
): Promise {
const unit = resolveUnit(key);
const n = clampLines(requested);
- const { output } = await execWithExitMarker(
- serverId,
- (flavor) => mailUnitLogsCommand(flavor, key, unit, n),
- 15_000,
- );
+ const read = (flavor: MailEngineFlavor) => mailUnitLogsRead(flavor, key, unit, n);
+ const { flavor, output } = await execWithExitMarker(serverId, (f) => read(f).command, 15_000);
const lines = output
.split("\n")
.map((l) => l.replace(/\r$/, ""))
.filter((l) => l.length > 0);
- return { key, unit, lines };
+ return { key, unit, lines, source: read(flavor).source };
}
function clampLines(requested: number | undefined): number {
@@ -176,6 +273,10 @@ export interface BulkRestartResult {
* orchestrates actual timing; "restart all" just kicks each unit and trusts it.
* iRedMail's stack tolerates concurrent restarts well in practice — postgres +
* postfix + dovecot all settle within seconds.
+ *
+ * No per-unit re-probe here, unlike the single-component path: nine units would put a
+ * 13-second floor on one click. This path stays accept-only and the Health tab's poll
+ * is the confirmation; only its OUTPUT grading is shared with the single path.
*/
export async function restartAllComponents(
serverId: string,
@@ -188,10 +289,11 @@ export async function restartAllComponents(
(flavor) => mailUnitActionCommand(flavor, comp.key, comp.unit, "restart"),
20_000,
);
- if (code === 0) {
+ // Not-installed is graded FIRST: supervisord's `ERROR (no such process)` is
+ // this flavor's "this box doesn't ship that daemon", which is a no-op.
+ if (NOT_INSTALLED.test(output)) {
results.push({ key: comp.key, unit: comp.unit, ok: true });
- } else if (/not[-\s]?found|not loaded/i.test(output)) {
- // Unit isn't installed on this host - treat as a no-op.
+ } else if (code === 0 && !FATAL_REFUSAL.test(output)) {
results.push({ key: comp.key, unit: comp.unit, ok: true });
} else {
results.push({
diff --git a/apps/api/src/modules/mail/admin/dns-scan.service.ts b/apps/api/src/modules/mail/admin/dns-scan.service.ts
index 9989f1bfa..20d89d22a 100644
--- a/apps/api/src/modules/mail/admin/dns-scan.service.ts
+++ b/apps/api/src/modules/mail/admin/dns-scan.service.ts
@@ -44,7 +44,7 @@ const resolveTxt = publicResolver.resolveTxt.bind(publicResolver);
const reverse = publicResolver.reverse.bind(publicResolver);
import { sshManager } from "../../../lib/ssh-manager";
import { readState } from "../mail-state";
-import { relayedDomainsFor, safeErrorMessage } from "@repo/core";
+import { relayedDomainsFor, safeErrorMessage, mailHostname } from "@repo/core";
export type DnsCheckStatus = "pass" | "warn" | "fail" | "unknown";
@@ -175,14 +175,63 @@ export async function scanDns(serverId: string, domain?: string): Promise= 0xfc00 && head <= 0xfdff;
+ }
+ const [a = 0, b = 0] = ip.split(".").map(Number);
+ if (a === 198 && (b === 18 || b === 19)) return true;
+ if (a >= 240) return true;
+ return false;
+}
+
// ─── Per-record checks ───────────────────────────────────────────────────────
async function checkA(domain: string, exp?: ExpectedRecord): Promise {
if (!exp?.value) return null;
- const name = exp.name || `mail.${domain}`;
+ const name = exp.name || mailHostname(domain);
try {
const ips = await resolve4(name);
const match = ips.includes(exp.value);
+ // A synthetic answer says nothing about the published zone, so report "we could not
+ // look" rather than "your record is wrong".
+ if (!match && ips.length > 0 && ips.every(looksSyntheticAddress)) {
+ return {
+ key: "a",
+ label: "A record",
+ description: `Points the mail server hostname (${name}) at the VPS public IP.`,
+ queriedName: name,
+ recordType: "A",
+ status: "unknown",
+ expected: exp.value,
+ actual: ips.join(", "),
+ message:
+ `DNS could not be verified from here: ${name} resolved to ${ips.join(", ")}, ` +
+ `which is a synthetic address from a local DNS interceptor (a fake-IP VPN or ` +
+ `proxy such as Clash or sing-box), not a published record. Re-run the scan with ` +
+ `that proxy off, or check the record from another network.`,
+ };
+ }
return {
key: "a",
label: "A record",
@@ -205,7 +254,7 @@ async function checkA(domain: string, exp?: ExpectedRecord): Promise {
if (!exp?.value) return null;
- const name = exp.name || `mail.${domain}`;
+ const name = exp.name || mailHostname(domain);
try {
const ips = await resolve6(name);
const match = ips.some((ip) => normaliseIpv6(ip) === normaliseIpv6(exp.value!));
@@ -512,7 +561,7 @@ async function checkPtr(
relayedAll = false,
): Promise {
if (!aRecord?.value) return null;
- const expectedHost = trimDot(`mail.${domain}`);
+ const expectedHost = trimDot(mailHostname(domain));
const base = {
key: "ptr",
label: "PTR (reverse DNS)",
diff --git a/apps/api/src/modules/mail/admin/domain-dns.service.ts b/apps/api/src/modules/mail/admin/domain-dns.service.ts
index dada88ac1..3285b938c 100644
--- a/apps/api/src/modules/mail/admin/domain-dns.service.ts
+++ b/apps/api/src/modules/mail/admin/domain-dns.service.ts
@@ -25,6 +25,7 @@ import { readState, mutateState } from "../mail-state";
import type { DnsRecordSet, AdditionalDomainDns } from "../mail-state";
import { buildSpfValue } from "../mail.service";
import { withSpfInclude } from "./outbound-relay.service";
+import { mailHostname } from "@repo/core";
// ─── Record set construction ─────────────────────────────────────────────────
@@ -53,7 +54,7 @@ export function buildDomainDnsRecords(
ipv6?: string | null,
opts?: { spfInclude?: string },
): DnsRecordSet {
- const mailHost = `mail.${installDomain}`;
+ const mailHost = mailHostname(installDomain);
// `spfInclude` is the outbound relay's provider token when one is active for
// this domain — a no-op when absent, so direct-send domains are unaffected.
const spfValue = withSpfInclude(buildSpfValue(ipv4, ipv6), opts?.spfInclude);
diff --git a/apps/api/src/modules/mail/admin/maildir.ts b/apps/api/src/modules/mail/admin/maildir.ts
index ffbde2750..c805d42c3 100644
--- a/apps/api/src/modules/mail/admin/maildir.ts
+++ b/apps/api/src/modules/mail/admin/maildir.ts
@@ -19,8 +19,7 @@
* `userdb` query returns them and the LDA places mail in the right path.
*/
-import type { CommandExecutor } from "@repo/adapters";
-import { sshManager } from "../../../lib/ssh-manager";
+import { mailEngineCommand, runMailCommand, type MailTarget } from "../mail-engine";
export const STORAGE_BASE = "/var/vmail";
export const STORAGE_NODE = "vmail1";
@@ -76,35 +75,54 @@ export function generateMaildir(
}
/**
- * Create the Maildir directory tree on the target VPS:
+ * The maildir root INSIDE the mailbox home, i.e. what Dovecot actually opens.
*
- * /var/vmail/vmail1//{cur,new,tmp}
+ * `mail_location = maildir:%Lh/Maildir/:INDEX=%Lh/Maildir/` (engine/samples/dovecot/
+ * dovecot.conf:64) and the userdb query sets `home` to
+ * `//` (dovecot-sql.conf:19) — so the
+ * tree lives one level BELOW the home, not at it. This used to create
+ * `/{cur,new,tmp}`, a tree Dovecot never reads: harmless only because the LDA
+ * autocreates the real one on first delivery, which is also why nothing caught it.
+ */
+const MAILDIR_SUBDIR = "Maildir";
+
+/**
+ * Create the Maildir directory tree where the mail engine lives:
+ *
+ * /var/vmail/vmail1//Maildir/{cur,new,tmp}
*
* Owned by `vmail:vmail` (the system user iRedMail's installer creates) so
* Postfix/Dovecot can write into it. Mode `0700` per Dovecot's expectation.
*
+ * Runs through `runMailCommand`, so it lands wherever `/var/vmail` and the `vmail`
+ * user actually are. Running it bare on the host executor is half of #562: a
+ * container-flavor host has no `vmail` user, so `chown` failed, the `&&` chain
+ * failed, and mailbox creation 500'd after already inserting the DB rows.
+ *
+ * The whole chain is handed to ONE `sh -c` rather than joined with `&&` at the top
+ * level, because `mailEngineCommand` prefixes `docker exec ` — an
+ * unwrapped `a && b` would run `a` in the engine and `b` on the HOST. Same wrapping
+ * precedent as mail.service.ts:91 and :863.
+ *
* Idempotent: `mkdir -p` is fine if the path already exists, and `chown`
* over an existing tree is harmless.
*/
export async function createMaildirOnDisk(
- serverIdOrExec: string | CommandExecutor,
+ serverIdOrExec: MailTarget,
layout: MaildirLayout,
): Promise {
- const fullPath = `${layout.storagebasedirectory}/${layout.storagenode}/${layout.maildir}`;
// Trailing slash already in maildir field; we don't need to add it again.
- const cmd = [
- `mkdir -p ${shellQuote(fullPath + "cur")}`,
- `mkdir -p ${shellQuote(fullPath + "new")}`,
- `mkdir -p ${shellQuote(fullPath + "tmp")}`,
- `chown -R vmail:vmail ${shellQuote(fullPath)}`,
- `chmod -R 0700 ${shellQuote(fullPath)}`,
+ const home = maildirHome(layout);
+ const root = `${home}${MAILDIR_SUBDIR}`;
+ const script = [
+ `mkdir -p ${shellQuote(`${root}/cur`)} ${shellQuote(`${root}/new`)} ${shellQuote(`${root}/tmp`)}`,
+ `chown -R vmail:vmail ${shellQuote(home)}`,
+ `chmod -R 0700 ${shellQuote(home)}`,
].join(" && ");
- if (typeof serverIdOrExec === "string") {
- await sshManager.withExecutor(serverIdOrExec, (exec) => exec.exec(cmd));
- } else {
- await serverIdOrExec.exec(cmd);
- }
+ await runMailCommand(serverIdOrExec, (flavor) =>
+ mailEngineCommand(flavor, `sh -c ${shellQuote(script)}`),
+ );
}
/**
@@ -116,22 +134,28 @@ export async function createMaildirOnDisk(
* has already validated the mailbox exists.
*/
export async function removeMaildirOnDisk(
- serverIdOrExec: string | CommandExecutor,
+ serverIdOrExec: MailTarget,
layout: MaildirLayout,
): Promise {
- const fullPath = `${layout.storagebasedirectory}/${layout.storagenode}/${layout.maildir}`;
+ const fullPath = maildirHome(layout);
// Guard: refuse to rm -rf anything that's not under /var/vmail/
if (!fullPath.startsWith(`${STORAGE_BASE}/`)) {
throw new Error(
`Refusing to remove maildir outside ${STORAGE_BASE}/: ${fullPath}`,
);
}
- const cmd = `rm -rf ${shellQuote(fullPath)}`;
- if (typeof serverIdOrExec === "string") {
- await sshManager.withExecutor(serverIdOrExec, (exec) => exec.exec(cmd));
- } else {
- await serverIdOrExec.exec(cmd);
- }
+ // Removes the home, so the `Maildir/` subtree inside it goes with it — no need to
+ // know the layout below. Flavor-routed for the same reason as the create path:
+ // `/var/vmail` is a volume in the engine, not a host directory.
+ await runMailCommand(serverIdOrExec, (flavor) =>
+ mailEngineCommand(flavor, `rm -rf ${shellQuote(fullPath)}`),
+ );
+}
+
+/** The mailbox home — what the userdb query returns as `home`. Keeps the one path
+ * concatenation in a single place so create and remove cannot disagree. */
+function maildirHome(layout: MaildirLayout): string {
+ return `${layout.storagebasedirectory}/${layout.storagenode}/${layout.maildir}`;
}
function shellQuote(s: string): string {
diff --git a/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts b/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts
index 4269669d8..054baf88c 100644
--- a/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts
+++ b/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts
@@ -1,4 +1,19 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/** Repo root, found by marker so moving this file cannot silently no-op a check. */
+function repoRoot(): string {
+ let dir = dirname(fileURLToPath(import.meta.url));
+ for (let i = 0; i < 12; i++) {
+ if (existsSync(join(dir, "apps", "email", "engine"))) return dir;
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ throw new Error("repo root not found - fix the marker walk in this test");
+}
// Stub the state I/O (readState/mutateState) so the test isolates the service's
// command construction + DNS patching from the on-VPS JSON plumbing. The
@@ -477,6 +492,47 @@ describe("relay TLS is scoped to the hop, not the server", () => {
});
}
+ /**
+ * GH-392: a global `smtp_tls_security_level=encrypt` also governs the
+ * Postfix→Amavis content-filter hop, because `smtp-amavis` is an smtp CLIENT
+ * service. Amavisd's :10024 offers no STARTTLS, so saving a relay with the
+ * DEFAULT scope took INBOUND mail down entirely — every message deferring with
+ * "TLS is required, but was not offered". The exemption has to be applied in
+ * both scopes: "selected" leaves the global at `may` today, but an operator may
+ * have hardened it by hand, and a box built before the master.cf fix carries
+ * its own copy on the /etc/postfix bind mount.
+ */
+ for (const scope of ["all", "selected"] as const) {
+ test(`[${scope}] exempts the amavis content filter from the global TLS level`, async () => {
+ const { exec, execCalls } = makeExec();
+ await configureOutboundRelay(exec, {
+ ...base,
+ scope,
+ ...(scope === "selected" ? { domains: ["example.com"] } : {}),
+ });
+ expect(execCalls.join("\n")).toContain(
+ "postconf -P 'smtp-amavis/unix/smtp_tls_security_level=none'",
+ );
+ });
+ }
+
+ test("the amavis exemption is pinned at the transport for new installs", async () => {
+ // The runtime repair above only reaches boxes where a relay is saved. New
+ // installs must be correct from the first boot, so the override lives in the
+ // transport definition too - and this asserts it sits inside the
+ // `smtp-amavis` entry, not merely somewhere in the file (the two pre-existing
+ // `=none` lines belong to the reinject smtpd services).
+ const masterCf = readFileSync(
+ join(repoRoot(), "apps/email/engine/samples/postfix/master.cf"),
+ "utf-8",
+ );
+ const entry = masterCf.slice(
+ masterCf.indexOf("smtp-amavis unix"),
+ masterCf.indexOf("# smtp port used by Amavisd"),
+ );
+ expect(entry).toContain("-o smtp_tls_security_level=none");
+ });
+
test("merges into an operator's existing policy map instead of clobbering it", async () => {
const { exec, execCalls } = makeExec();
// Pretend the operator already pins a destination of their own.
diff --git a/apps/api/src/modules/mail/admin/outbound-relay.service.ts b/apps/api/src/modules/mail/admin/outbound-relay.service.ts
index 263a23218..d517cabb0 100644
--- a/apps/api/src/modules/mail/admin/outbound-relay.service.ts
+++ b/apps/api/src/modules/mail/admin/outbound-relay.service.ts
@@ -38,6 +38,15 @@
* smtp_sasl_password_maps=hash:/etc/postfix/sasl_passwd
* smtp_sasl_security_options=noanonymous
* smtp_tls_security_level=encrypt|may
+ * postconf -P smtp-amavis/unix/smtp_tls_security_level=none
+ *
+ * That last line is the third way a global TLS level goes wrong, and the one
+ * that bites hardest: `smtp-amavis` is an smtp CLIENT service inheriting
+ * smtp_tls_security_level, and amavisd's :10024 offers no STARTTLS, so a global
+ * `encrypt` stops INBOUND mail entirely (GH-392) — not just outbound. The
+ * exemption is pinned at the transport for new installs (see
+ * apps/email/engine/samples/postfix/master.cf) and re-applied here so boxes
+ * built before that fix are repaired on the next save.
* smtp_tls_policy_maps=hash:… (selected only, merged)
* postmap + postfix reload
*
@@ -515,8 +524,26 @@ export async function configureOutboundRelay(
"smtp_sasl_security_options=noanonymous",
];
+ // 3a) Exempt the content filter from whatever global TLS level we are about to
+ // set. `smtp-amavis` is an smtp CLIENT service, so it inherits
+ // smtp_tls_security_level, and amavisd's :10024 offers no STARTTLS: a
+ // global `encrypt` defers EVERY INBOUND message with "TLS is required, but
+ // was not offered" (GH-392). New installs get this from
+ // apps/email/engine/samples/postfix/master.cf, but a box deployed before
+ // that fix has its own master.cf on the /etc/postfix bind mount and would
+ // never pick it up — so repair it here, at the moment the global is
+ // written. `postconf -P` edits the master.cf override in place and is
+ // idempotent, so re-saving a relay is a no-op. Unconditional rather than
+ // inside the "all" branch: "selected" leaves the global at `may` today,
+ // but an operator may have hardened it by hand.
+ await exec
+ .exec(engine(`postconf -P ${sq("smtp-amavis/unix/smtp_tls_security_level=none")}`))
+ .catch(() => {});
+
if (scope === "all") {
- // Everything leaves via the relay, so global relay-grade TLS is safe.
+ // Everything leaves via the relay, so global relay-grade TLS is safe for
+ // OUTBOUND. The Postfix→Amavis hop is exempted above; it is an inbound path
+ // that happens to use the same smtp client.
sasl.push("smtp_tls_security_level=encrypt");
if (input.port === IMPLICIT_TLS_PORT) sasl.push("smtp_tls_wrappermode=yes");
await exec.exec(engine(`postconf -e ${[`relayhost=${nexthop}`, ...sasl].map(sq).join(" ")}`));
diff --git a/apps/api/src/modules/mail/admin/password.ts b/apps/api/src/modules/mail/admin/password.ts
index 02dece091..5117b67d5 100644
--- a/apps/api/src/modules/mail/admin/password.ts
+++ b/apps/api/src/modules/mail/admin/password.ts
@@ -5,14 +5,22 @@
* `password` column. Hashing happens ON the target VPS via `doveadm pw`
* so neither cleartext nor hash transits any intermediate process.
*
+ * "On the target VPS" is not the same as "on the target HOST": `doveadm` lives
+ * wherever Dovecot does, which on a container-flavor box is inside the engine and
+ * NOT on the host. Running it bare against the host executor is what made every
+ * mailbox create return a 500 on a containerized install (#562) — the host has no
+ * `doveadm`, so the exec produced nothing, the hash regex rejected the empty
+ * string, and the thrown message never reached the operator. Every invocation now
+ * goes through `runMailCommand`, which resolves the box's flavor and renders the
+ * right prefix; see `../mail-engine.ts` for the topology matrix.
+ *
* This is the same scheme used by `mail-credentials.service.ts` for the
* postmaster password rotation - kept as a separate small module so the
* admin services can call it without depending on the broader credentials
* surface.
*/
-import type { CommandExecutor } from "@repo/adapters";
-import { sshManager } from "../../../lib/ssh-manager";
+import { mailEngineCommand, runMailCommand, type MailTarget } from "../mail-engine";
const SSHA512_HASH_RE = /^\{SSHA512\}[A-Za-z0-9+/=]+$/;
@@ -25,22 +33,27 @@ function shellQuote(s: string): string {
* `{SSHA512}...` string ready to drop into the `password` column.
*
* Throws if doveadm returns anything that doesn't match the expected hash
- * format - easier to fail at hash time than to debug a broken auth row.
+ * format - easier to fail at hash time than to debug a broken auth row. The
+ * plaintext is never echoed in that error, only the (non-)hash we got back.
*/
export async function hashPassword(
- serverIdOrExec: string | CommandExecutor,
+ serverIdOrExec: MailTarget,
plaintext: string,
): Promise {
- const cmd = `doveadm pw -s SSHA512 -p ${shellQuote(plaintext)}`;
- const out =
- typeof serverIdOrExec === "string"
- ? await sshManager.withExecutor(serverIdOrExec, (exec) => exec.exec(cmd))
- : await serverIdOrExec.exec(cmd);
+ const { output, flavor } = await runMailCommand(serverIdOrExec, (f) =>
+ mailEngineCommand(f, `doveadm pw -s SSHA512 -p ${shellQuote(plaintext)}`),
+ );
- const hash = out.trim();
+ const hash = output.trim();
if (!SSHA512_HASH_RE.test(hash)) {
+ // Name the transport in the message: on a container box the overwhelmingly
+ // likely cause is that `doveadm` is missing from the engine image, and an
+ // error that only says "unexpected output" sends the reader hunting the
+ // password instead.
throw new Error(
- `doveadm pw returned unexpected output: ${hash.slice(0, 60)}…`,
+ `doveadm pw (${flavor} engine) returned no usable SSHA512 hash — got ${
+ hash ? `"${hash.slice(0, 60)}…"` : "empty output"
+ }. Check that doveadm is present and Dovecot's config is readable.`,
);
}
return hash;
diff --git a/apps/api/src/modules/mail/admin/platform-mailbox.service.ts b/apps/api/src/modules/mail/admin/platform-mailbox.service.ts
index 67ba1e8fd..c1330cde8 100644
--- a/apps/api/src/modules/mail/admin/platform-mailbox.service.ts
+++ b/apps/api/src/modules/mail/admin/platform-mailbox.service.ts
@@ -36,7 +36,7 @@
import { randomBytes } from "node:crypto";
import type { CommandExecutor } from "@repo/adapters";
-import { safeErrorMessage } from "@repo/core";
+import { safeErrorMessage, mailHostname } from "@repo/core";
import { decrypt, encrypt } from "../../../lib/encryption";
import { sshManager } from "../../../lib/ssh-manager";
import {
@@ -120,7 +120,7 @@ export async function ensureOpenshipPlatformMailbox(
}
const domain = rawDomain;
const email = `${PLATFORM_LOCAL_PART}@${domain}`;
- const smtpHost = `mail.${state.domain}`;
+ const smtpHost = mailHostname(state.domain);
const rotate = opts?.rotate === true;
// Fast path: cached creds match target identity and no rotation requested.
diff --git a/apps/api/src/modules/mail/admin/psql-runner.ts b/apps/api/src/modules/mail/admin/psql-runner.ts
index ca81b6d05..b07915e7e 100644
--- a/apps/api/src/modules/mail/admin/psql-runner.ts
+++ b/apps/api/src/modules/mail/admin/psql-runner.ts
@@ -24,8 +24,8 @@
* coded in the service files.
*
* Transport:
- * HOW psql is reached is not decided here. `runMailCommand` resolves the box's
- * mail topology and `mailPsqlCommand` renders the right invocation (the engine's
+ * HOW psql is reached is not decided here. `runMailSql` resolves the box's mail
+ * topology, renders the right invocation and types its failures (the engine's
* pg sidecar, or `sudo -u postgres` on a legacy pre-container install) — see
* `../mail-engine.ts`. This file was hardcoded to the sidecar, which is why
* every admin read on a legacy box 500'd with "No such container". Same SQL
@@ -34,7 +34,7 @@
import type { CommandExecutor } from "@repo/adapters";
import { safeErrorMessage } from "@repo/core";
-import { mailPsqlCommand, runMailCommand, type MailTarget } from "../mail-engine";
+import { runMailSql, type MailTarget } from "../mail-engine";
/**
* Quote a value as a PostgreSQL string literal. Escapes embedded single
@@ -166,12 +166,10 @@ export async function transaction(
* (when the caller reuses one across several calls).
*
* A stopped engine / gone container surfaces as a typed
- * `MailEngineUnavailableError` (→ 409 + a code the dashboard branches on), not as
- * a raw shell error the panel prints as "API 500".
+ * `MailEngineUnavailableError`, and a `vmail` that was never seeded as
+ * `MailDbNotInitializedError` carrying the exact bootstrap command (→ 409 + a code the
+ * dashboard branches on), not as a raw shell error the panel prints as "API 500".
*/
async function runSql(serverIdOrExec: MailTarget, sql: string): Promise {
- const { output } = await runMailCommand(serverIdOrExec, (flavor) =>
- mailPsqlCommand(flavor, sql),
- );
- return output;
+ return runMailSql(serverIdOrExec, sql);
}
diff --git a/apps/api/src/modules/mail/admin/test-mailbox.service.ts b/apps/api/src/modules/mail/admin/test-mailbox.service.ts
index 85365f60a..61cc38470 100644
--- a/apps/api/src/modules/mail/admin/test-mailbox.service.ts
+++ b/apps/api/src/modules/mail/admin/test-mailbox.service.ts
@@ -35,7 +35,7 @@
*/
import type { CommandExecutor } from "@repo/adapters";
-import { safeErrorMessage } from "@repo/core";
+import { safeErrorMessage, mailHostname } from "@repo/core";
import { decrypt, encrypt } from "../../../lib/encryption";
import { sshManager } from "../../../lib/ssh-manager";
import {
@@ -101,7 +101,7 @@ export async function ensureOpenshipTestMailbox(
const email = `${PLATFORM_LOCAL_PART}@${targetDomain}`;
// Submission host is always the primary install — every domain shares
// the same `mail.` MX / submission endpoint.
- const smtpHost = `mail.${state.domain}`;
+ const smtpHost = mailHostname(state.domain);
const rotate = opts?.rotate === true;
// Fast path: cached creds match target identity and no rotation
diff --git a/apps/api/src/modules/mail/inbound/capture.ts b/apps/api/src/modules/mail/inbound/capture.ts
new file mode 100644
index 000000000..1491fce72
--- /dev/null
+++ b/apps/api/src/modules/mail/inbound/capture.ts
@@ -0,0 +1,353 @@
+/**
+ * Arming and disarming inbound capture on the mail engine.
+ *
+ * The whole mechanism is one row in `vmail.recipient_bcc_domain`, a table the shipped
+ * `main.cf` already consults on every message via `proxy:pgsql`. So arming needs no
+ * postmap, no reload, no container recreate, and behaves identically on the container and
+ * legacy host flavors — which is what lets this ship to mail boxes that already exist.
+ *
+ * DOMAIN-KEYED, ALWAYS. `recipient_bcc_user` is PRIMARY KEY (username) and
+ * `recipient_bcc_domain` is PRIMARY KEY (domain) — one slot each — and Postfix consults
+ * the USER table first, so a mailbox-scope row would MASK the domain-scope row for that
+ * mailbox and make a domain rule silently skip it. Everything is armed at the domain
+ * level; mailbox/sender/subject scoping happens in the control plane (see filter.ts).
+ *
+ * NO MIRROR TABLE. What is armed is read back from vmail rather than cached: the token
+ * lives inside the `bcc_address` itself, and the collector's maildir lives in
+ * `vmail.mailbox`. A cache of the engine's own state is a thing that goes stale.
+ *
+ * NOT ATOMIC, and it cannot be: provisioning spans the engine's `vmail` DB and its disk,
+ * over SSH, with no transaction available across them. `mailboxes.service.ts` hits the
+ * same wall and compensates the same way — a transaction for the two SQL rows, then the
+ * disk step, then a rollback of the rows if the disk step fails. The orphan that matters
+ * is the reverse (a BCC row armed with no reachable collector, quietly copying a domain's
+ * mail into a folder nobody reads), which is why `reconcile` exists below.
+ */
+
+// `shellQuote` from core rather than a local copy: the mail module already carries five
+// byte-identical private ones, and core's docstring records that nine existed before it
+// was consolidated. Every value interpolated into a command on this path reaches root on a
+// host-networked, NET_ADMIN container, so it should be the audited one.
+import { safeErrorMessage } from "@repo/core";
+import { execute, q, queryOne, transaction } from "../admin/psql-runner";
+import { hashPassword } from "../admin/password";
+import { createMaildirOnDisk, generateMaildir, STORAGE_BASE, STORAGE_NODE } from "../admin/maildir";
+import {
+ buildUpsertMailboxSql,
+ buildUpsertSelfForwardingSql,
+ randomPassword,
+} from "../admin/platform-mailbox.service";
+import { mailEngineCommand, runMailCommand, type MailTarget } from "../mail-engine";
+
+/** The local part every collector mailbox uses. One per domain. */
+export const COLLECTOR_LOCAL_PART = "openship-hook";
+
+/**
+ * How a token is recognised as OURS in the single BCC slot.
+ *
+ * Anything else in that slot belongs to the operator (archiving, compliance BCC,
+ * iRedAdmin-Pro) and must never be overwritten — see {@link armDomain}.
+ */
+const COLLECTOR_PREFIX = `${COLLECTOR_LOCAL_PART}+`;
+
+/**
+ * Tokens are `[a-z0-9]` only, and that is a correctness requirement rather than style:
+ * the Dovecot LDA pipe runs with `flags=DRh`, whose `h` folds the DOMAIN to lowercase but
+ * NOT the extension, while the userdb query LOWER()s the whole home path. A mixed-case
+ * token would name a folder that the path we compute cannot find.
+ *
+ * It is also a bearer secret. `openship-hook+@` is a real, externally
+ * addressable mailbox on the public MX and nothing in the shipped config restricts who
+ * may send to it, so anyone who learns a token can post into the collector and fire
+ * notifications. 24 chars of base32-ish alphabet is ~120 bits.
+ */
+export function generateToken(): string {
+ return randomPassword(32).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 24);
+}
+
+export function collectorAddress(domain: string, token: string): string {
+ return `${COLLECTOR_PREFIX}${token}@${domain.toLowerCase()}`;
+}
+
+export function collectorMailbox(domain: string): string {
+ return `${COLLECTOR_LOCAL_PART}@${domain.toLowerCase()}`;
+}
+
+/** What the engine currently says about one domain's capture. */
+export interface ArmedState {
+ domain: string;
+ /** The raw value in the BCC slot, or null when the slot is empty. */
+ bccAddress: string | null;
+ /** Our token, when the slot holds one of ours. */
+ token: string | null;
+ /** True when the slot holds a value that is NOT ours — we must not touch it. */
+ foreign: boolean;
+ /** Absolute maildir of the collector mailbox, when it exists. */
+ maildirPath: string | null;
+}
+
+/** Parse our token out of a bcc_address, or null when it is not ours. */
+export function tokenFromBcc(bccAddress: string | null | undefined): string | null {
+ if (!bccAddress) return null;
+ const local = bccAddress.split("@")[0] ?? "";
+ if (!local.startsWith(COLLECTOR_PREFIX)) return null;
+ const token = local.slice(COLLECTOR_PREFIX.length);
+ return /^[a-z0-9]+$/.test(token) ? token : null;
+}
+
+/**
+ * Read what is armed for a domain, straight from the engine. This is the source of truth
+ * — there is no local mirror to consult.
+ *
+ * The maildir is SELECTed, never recomputed: `generateMaildir` embeds a UTC creation
+ * timestamp, so a rebuilt path points at a directory that does not exist.
+ */
+export async function readArmedState(target: MailTarget, domain: string): Promise {
+ const d = domain.toLowerCase();
+ const bcc = await queryOne<{ bcc_address: string }>(
+ target,
+ `SELECT bcc_address FROM recipient_bcc_domain WHERE domain = ${q(d)} AND active = 1`,
+ );
+ const box = await queryOne<{ base: string; node: string; maildir: string }>(
+ target,
+ `SELECT storagebasedirectory AS base, storagenode AS node, maildir
+ FROM mailbox WHERE username = ${q(collectorMailbox(d))}`,
+ );
+ const token = tokenFromBcc(bcc?.bcc_address);
+ return {
+ domain: d,
+ bccAddress: bcc?.bcc_address ?? null,
+ token,
+ foreign: Boolean(bcc?.bcc_address) && token === null,
+ maildirPath: box ? `${box.base}/${box.node}/${box.maildir}` : null,
+ };
+}
+
+/** Raised when the operator's own BCC occupies the slot. Never clobbered. */
+export class ForeignBccError extends Error {
+ constructor(
+ readonly domain: string,
+ readonly bccAddress: string,
+ ) {
+ super(
+ `${domain} already has a recipient BCC set to ${bccAddress}. Postfix allows only ` +
+ `one per domain, so arming inbound capture would silently disable it. Remove or ` +
+ `move that BCC first.`,
+ );
+ this.name = "ForeignBccError";
+ }
+}
+
+/**
+ * Make sure the collector mailbox for a domain exists.
+ *
+ * Three artifacts have to line up, exactly as `mailboxes.service.ts` documents: the
+ * `vmail.mailbox` auth row, a SELF-FORWARDING row in `vmail.forwardings` (without which
+ * Postfix REJECTS the address even though the mailbox exists — and a domain catch-all
+ * cannot rescue it, because `catchall_maps.cf` explicitly excludes keys containing `+`),
+ * and the on-disk maildir.
+ *
+ * QUOTA 0 (unlimited) is not laziness. Over-quota is `552 5.2.2` — a PERMANENT 5xx — and
+ * because the BCC recipient is added in `cleanup(8)` AFTER smtpd, Dovecot's quota-status
+ * policy cannot reject it at RCPT time. It becomes a delivery-time bounce whose envelope
+ * sender is the ORIGINAL third party, so a full collector would mail confusing bounces to
+ * strangers who wrote to your users. `quota_full_tempfail` is set nowhere in the tree, so
+ * there is no defer to fall back on. The read job deletes every message it dispatches,
+ * which is what keeps unlimited safe.
+ *
+ * Idempotent: upserts, so a re-arm converges instead of failing.
+ */
+export async function ensureCollectorMailbox(
+ target: MailTarget,
+ domain: string,
+): Promise<{ username: string; maildirPath: string }> {
+ const d = domain.toLowerCase();
+ const username = collectorMailbox(d);
+
+ const existing = await queryOne<{ base: string; node: string; maildir: string }>(
+ target,
+ `SELECT storagebasedirectory AS base, storagenode AS node, maildir
+ FROM mailbox WHERE username = ${q(username)}`,
+ );
+ if (existing) {
+ return { username, maildirPath: `${existing.base}/${existing.node}/${existing.maildir}` };
+ }
+
+ // Never returned to anyone. The collector is not an interactive account: nothing signs
+ // in as it, and the control plane reads its maildir over the exec channel instead.
+ const hash = await hashPassword(target, randomPassword(40));
+ const layout = generateMaildir(d, COLLECTOR_LOCAL_PART);
+
+ await transaction(target, [
+ buildUpsertMailboxSql({
+ username,
+ passwordHash: hash,
+ name: "Openship inbound capture",
+ domain: d,
+ storagebasedirectory: layout.storagebasedirectory,
+ storagenode: layout.storagenode,
+ maildir: layout.maildir,
+ quotaMB: 0,
+ }),
+ buildUpsertSelfForwardingSql(username, d),
+ ]);
+
+ try {
+ await createMaildirOnDisk(target, layout);
+ } catch (err) {
+ // Same compensating rollback as createMailbox: leaving the rows behind would make
+ // Postfix accept mail for an address whose storage does not exist, which bounces.
+ await execute(target, `DELETE FROM forwardings WHERE address = ${q(username)}`).catch(() => {});
+ await execute(target, `DELETE FROM mailbox WHERE username = ${q(username)}`).catch(() => {});
+ throw new Error(
+ `Could not create the collector maildir for ${d}: ${safeErrorMessage(err)}`,
+ );
+ }
+
+ return {
+ username,
+ maildirPath: `${STORAGE_BASE}/${STORAGE_NODE}/${layout.maildir}`,
+ };
+}
+
+/**
+ * Arm capture for one domain, returning the token in the slot.
+ *
+ * Refuses rather than overwrites when the slot holds somebody else's BCC: that single row
+ * is shared with operator archiving and compliance copies, and taking it would silently
+ * turn those off.
+ *
+ * Re-arming an already-armed domain KEEPS the existing token. Rotating it would orphan
+ * whatever is already sitting in the folder that token names.
+ */
+export async function armDomain(target: MailTarget, domain: string): Promise {
+ const d = domain.toLowerCase();
+ const state = await readArmedState(target, d);
+ if (state.foreign) throw new ForeignBccError(d, state.bccAddress!);
+
+ await ensureCollectorMailbox(target, d);
+
+ if (state.token) return state.token;
+
+ const token = generateToken();
+ await execute(
+ target,
+ `INSERT INTO recipient_bcc_domain (domain, bcc_address, active)
+ VALUES (${q(d)}, ${q(collectorAddress(d, token))}, 1)
+ ON CONFLICT (domain) DO UPDATE SET
+ bcc_address = EXCLUDED.bcc_address, active = 1, modified = NOW()`,
+ );
+ return token;
+}
+
+/**
+ * Disarm a domain: drop the BCC row so Postfix stops copying immediately.
+ *
+ * The collector mailbox and anything still in it are LEFT ALONE. Deleting a mailbox is
+ * destructive and this is called from a rule delete, which the operator may well be
+ * undoing a moment later. Only our own row is removed — a foreign BCC is left untouched.
+ */
+export async function disarmDomain(target: MailTarget, domain: string): Promise {
+ const d = domain.toLowerCase();
+ const state = await readArmedState(target, d);
+ if (!state.token) return;
+ await execute(
+ target,
+ `DELETE FROM recipient_bcc_domain
+ WHERE domain = ${q(d)} AND bcc_address LIKE ${q(`${COLLECTOR_PREFIX}%`)}`,
+ );
+}
+
+/**
+ * The domain a rule's target names — the one place that parses `scope` + `target`.
+ *
+ * Null for `scope: "all"`, which names no single domain and is resolved differently by
+ * each caller (the write path asks the engine for every domain; the read path asks which
+ * domains are actually armed). Everything else about the derivation is identical, and it
+ * lived twice — once in the controller and once in the read job — which is exactly how the
+ * two drift on a mailbox address that contains an unexpected character.
+ */
+export function ruleDomain(rule: {
+ scope: string;
+ target: string | null;
+}): string | null {
+ const t = rule.target?.trim().toLowerCase();
+ if (!t) return null;
+ if (rule.scope === "domain") return t;
+ if (rule.scope === "mailbox") {
+ const at = t.indexOf("@");
+ return at > 0 ? t.slice(at + 1) : null;
+ }
+ return null;
+}
+
+/** Every domain on the engine — what `scope: "all"` fans out over. */
+export async function listEngineDomains(target: MailTarget): Promise {
+ const rows = await queryOne<{ domains: string | null }>(
+ target,
+ `SELECT string_agg(domain, ',' ORDER BY domain) AS domains FROM domain WHERE active = 1`,
+ );
+ return (rows?.domains ?? "").split(",").filter(Boolean);
+}
+
+/**
+ * Bring the engine's armed rows in line with the rules that exist.
+ *
+ * This is the answer to the atomicity gap in this file's header, and it runs on a
+ * schedule rather than only on write. Two drifts it repairs:
+ *
+ * - a domain that SHOULD be armed but is not (a failed arm, or a new domain that a
+ * `scope: "all"` rule now covers — there is no global BCC in the shipped config, so
+ * `all` is a genuine per-domain fan-out and a domain added later is invisible
+ * until something re-arms it);
+ * - a domain armed with OUR token that no enabled rule wants any more, which would
+ * otherwise keep copying mail into a folder nobody reads, forever, on a bind mount
+ * with no quota of its own.
+ *
+ * A foreign BCC is reported, never touched.
+ */
+export async function reconcileDomains(
+ target: MailTarget,
+ wanted: { domains: Set; all: boolean },
+): Promise<{ armed: string[]; disarmed: string[]; refused: string[] }> {
+ const engineDomains = await listEngineDomains(target);
+ const shouldArm = wanted.all ? new Set(engineDomains) : wanted.domains;
+
+ const armed: string[] = [];
+ const disarmed: string[] = [];
+ const refused: string[] = [];
+
+ for (const d of engineDomains) {
+ const state = await readArmedState(target, d).catch(() => null);
+ if (!state) continue;
+
+ if (shouldArm.has(d)) {
+ if (state.foreign) {
+ refused.push(d);
+ continue;
+ }
+ if (!state.token) {
+ await armDomain(target, d).then(() => armed.push(d)).catch(() => refused.push(d));
+ }
+ continue;
+ }
+ if (state.token) {
+ await disarmDomain(target, d).then(() => disarmed.push(d)).catch(() => undefined);
+ }
+ }
+ return { armed, disarmed, refused };
+}
+
+/**
+ * The collector folder for a token, as an absolute path inside the engine.
+ *
+ * Maildir++ layout: the LDA's `-m ` creates a folder named for the token, and
+ * Maildir++ stores a top-level folder as a DOT-PREFIXED directory beside `cur/new/tmp`.
+ * `mail_location = maildir:%Lh/Maildir/` puts the whole tree one level under the home.
+ */
+export function collectorFolderPath(maildirPath: string, token: string): string {
+ const home = maildirPath.replace(/\/+$/, "");
+ return `${home}/Maildir/.${token}`;
+}
+
+export { mailEngineCommand, runMailCommand };
diff --git a/apps/api/src/modules/mail/inbound/filter.ts b/apps/api/src/modules/mail/inbound/filter.ts
new file mode 100644
index 000000000..61001d9e7
--- /dev/null
+++ b/apps/api/src/modules/mail/inbound/filter.ts
@@ -0,0 +1,259 @@
+/**
+ * Deciding whether a captured message should fire a notification — pure, no I/O.
+ *
+ * Everything here runs on the HEADER BLOCK of a BCC copy sitting in the collector
+ * folder. Keeping it pure is deliberate: this is where the feature is dangerous (a loop
+ * that mails itself, a rule that leaks every message on the server, an alert per spam),
+ * and none of those failure modes should need a mail server to test.
+ *
+ * WHAT WE CAN AND CANNOT KNOW. `enable_original_recipient = no` in the shipped main.cf
+ * ("Avoid duplicate recipient messages"), so the BCC copy carries NO `X-Original-To` and
+ * its envelope recipient is the collector, not the mailbox the mail was for. The only
+ * evidence of the original recipient is the `To`/`Cc` header. That is authoritative for
+ * ordinary mail and WRONG for anything Bcc'd or alias-expanded — so a `mailbox`-scope
+ * rule can miss a message that genuinely arrived at its target. A `domain`-scope rule has
+ * no such gap, because the capture itself is domain-keyed. This is a property of Postfix's
+ * config, not something the control plane can fix; it is called out in the UI copy.
+ */
+
+import type { MailInboundRule } from "@repo/db";
+
+/** The subset of headers any decision here is allowed to depend on. */
+export interface ParsedHeaders {
+ /** `From:` — display form, e.g. `Alice `. */
+ from?: string;
+ /** Lowercased bare address out of `From:`, e.g. `alice@example.com`. */
+ fromAddress?: string;
+ /** `To:` and `Cc:` joined — the only evidence of the original recipient. */
+ recipients: string[];
+ subject?: string;
+ /** `Return-Path:` — the ENVELOPE sender. `<>` means this is a bounce. */
+ returnPath?: string;
+ messageId?: string;
+ /** amavis writes these when SpamAssassin crosses the tag level. */
+ spamFlagYes: boolean;
+ spamScore?: number;
+ autoSubmitted?: string;
+ precedence?: string;
+ listId?: string;
+}
+
+/** Why a message was dropped before any rule was consulted. */
+export type DropReason =
+ | "bounce"
+ | "auto-submitted"
+ | "bulk-precedence"
+ | "mailing-list"
+ | "openship-sender"
+ | "spam-flagged";
+
+export interface FilterDecision {
+ drop: boolean;
+ reason?: DropReason;
+}
+
+/**
+ * Unfold and split an RFC 5322 header block.
+ *
+ * Continuation lines begin with space or tab and belong to the previous field, which
+ * matters immediately: long `Subject:` and `To:` values are folded in practice, and a
+ * naive line split would truncate a subject mid-word and lose half a recipient list.
+ * Field names are case-insensitive; the FIRST occurrence wins, because a second
+ * `From:` is either malformed or an attempt to confuse a reader downstream.
+ */
+export function parseHeaderBlock(raw: string): ParsedHeaders {
+ const fields = new Map();
+ let currentName: string | null = null;
+ let currentValue = "";
+
+ const flush = () => {
+ if (currentName && !fields.has(currentName)) {
+ fields.set(currentName, currentValue.trim());
+ }
+ currentName = null;
+ currentValue = "";
+ };
+
+ // Normalize CRLF first so a Windows-authored fixture and a real message agree.
+ for (const line of raw.replace(/\r\n/g, "\n").split("\n")) {
+ // A blank line ends the header block; a body must never reach a rule decision.
+ if (line === "") break;
+ if (/^[ \t]/.test(line)) {
+ if (currentName) currentValue += " " + line.trim();
+ continue;
+ }
+ const sep = line.indexOf(":");
+ if (sep <= 0) continue;
+ flush();
+ currentName = line.slice(0, sep).trim().toLowerCase();
+ currentValue = line.slice(sep + 1);
+ }
+ flush();
+
+ const to = fields.get("to");
+ const cc = fields.get("cc");
+ const recipients = [to, cc]
+ .filter((v): v is string => Boolean(v))
+ .flatMap((v) => v.split(","))
+ .map((v) => bareAddress(v))
+ .filter((v): v is string => Boolean(v));
+
+ const scoreRaw = fields.get("x-spam-score");
+ const score = scoreRaw === undefined ? undefined : Number.parseFloat(scoreRaw);
+
+ return {
+ from: fields.get("from"),
+ fromAddress: bareAddress(fields.get("from")),
+ recipients,
+ subject: fields.get("subject"),
+ returnPath: fields.get("return-path"),
+ messageId: fields.get("message-id"),
+ // amavis writes `X-Spam-Flag: YES`; anything else (absent, NO) is not a positive.
+ spamFlagYes: (fields.get("x-spam-flag") ?? "").trim().toUpperCase() === "YES",
+ spamScore: score !== undefined && Number.isFinite(score) ? score : undefined,
+ autoSubmitted: fields.get("auto-submitted"),
+ precedence: fields.get("precedence"),
+ listId: fields.get("list-id"),
+ };
+}
+
+/** `Alice ` / `a@b.com` / ` ` → `a@b.com`, lowercased. */
+export function bareAddress(value: string | undefined): string | undefined {
+ if (!value) return undefined;
+ const angled = value.match(/<([^>]*)>/);
+ const candidate = (angled ? angled[1] : value).trim().toLowerCase();
+ // An empty `<>` is a real and meaningful value (a bounce), so it is preserved rather
+ // than being normalized away to undefined.
+ if (candidate === "") return "";
+ return candidate.includes("@") ? candidate : undefined;
+}
+
+/**
+ * The guards that run BEFORE any rule, in the order a mail incident actually happens.
+ *
+ * A notification about mail is itself mail. If any of these is missing, a single
+ * notification delivered to an address on the watched engine re-captures itself and the
+ * loop only stops when someone notices — so these are not tuning knobs.
+ *
+ * `openshipSenders` are the addresses this instance sends its own mail FROM (the platform
+ * mailbox, and whatever Settings→Email is configured with). They are passed in rather
+ * than derived so this stays pure.
+ */
+export function loopGuard(
+ h: ParsedHeaders,
+ opts: { openshipSenders?: readonly string[] } = {},
+): FilterDecision {
+ // A null envelope sender is a BOUNCE. Notifying on bounces is how a bounce storm
+ // becomes an alert storm, and the reply-to-a-bounce case is a classic mail loop.
+ if (h.returnPath !== undefined && bareAddress(h.returnPath) === "") {
+ return { drop: true, reason: "bounce" };
+ }
+ // RFC 3834: anything not `no` is machine-generated, which includes our own alerts if a
+ // channel ever mails one back.
+ const auto = (h.autoSubmitted ?? "").trim().toLowerCase();
+ if (auto && auto !== "no") return { drop: true, reason: "auto-submitted" };
+
+ const precedence = (h.precedence ?? "").trim().toLowerCase();
+ if (precedence === "bulk" || precedence === "junk") {
+ return { drop: true, reason: "bulk-precedence" };
+ }
+ // `List-Id` marks list traffic, which is the highest-volume way to flood a channel.
+ if (h.listId) return { drop: true, reason: "mailing-list" };
+
+ const senders = opts.openshipSenders ?? [];
+ if (h.fromAddress && senders.some((s) => s.toLowerCase() === h.fromAddress)) {
+ return { drop: true, reason: "openship-sender" };
+ }
+ return { drop: false };
+}
+
+/**
+ * The spam gate, which exists because nothing upstream provides one.
+ *
+ * The shipped amavis policy sets `spam_lover='Y'` AND `bad_header_lover='Y'` on the
+ * catch-all `@.` policy with empty quarantine targets, so the global
+ * `$final_spam_destiny = D_DISCARD` never applies to any recipient: spam IS delivered,
+ * and therefore IS captured. Bad-header mail is delivered too and carries no
+ * `X-Spam-Flag` at all, which is why the score is checked independently of the flag.
+ *
+ * With no `maxSpamScore` set on the rule, a positive `X-Spam-Flag` alone is enough to
+ * drop — the conservative default, since the alternative is paging on spam.
+ */
+export function spamGate(h: ParsedHeaders, maxSpamScore: number | null): FilterDecision {
+ if (maxSpamScore === null || maxSpamScore === undefined) {
+ return h.spamFlagYes ? { drop: true, reason: "spam-flagged" } : { drop: false };
+ }
+ if (h.spamScore !== undefined && h.spamScore > maxSpamScore) {
+ return { drop: true, reason: "spam-flagged" };
+ }
+ return { drop: false };
+}
+
+/**
+ * Does this rule want this message?
+ *
+ * `capturedDomain` is the domain whose BCC row produced the copy — structural and
+ * trustworthy, unlike the recipient headers.
+ *
+ * FAIL CLOSED is the whole point of the target checks. There is no CHECK constraint
+ * tying `scope` to `target` (this schema has none anywhere), so a `mailbox` or `domain`
+ * rule with a null target is representable — and if it were treated as "no constraint"
+ * it would silently become "every message on the server", i.e. an operator who mistyped
+ * a rule quietly forwards a whole domain's mail metadata to Slack. It matches nothing.
+ */
+export function matchesRule(
+ rule: Pick<
+ MailInboundRule,
+ "scope" | "target" | "fromPattern" | "subjectPattern" | "enabled" | "pausedReason"
+ >,
+ h: ParsedHeaders,
+ capturedDomain: string,
+): boolean {
+ if (!rule.enabled || rule.pausedReason) return false;
+
+ const target = rule.target?.trim().toLowerCase() ?? "";
+
+ switch (rule.scope) {
+ case "all":
+ break;
+ case "domain":
+ if (!target) return false;
+ if (target !== capturedDomain.toLowerCase()) return false;
+ break;
+ case "mailbox": {
+ if (!target) return false;
+ // The To/Cc caveat in this file's header applies here: a Bcc'd or alias-expanded
+ // message cannot be attributed to a mailbox from headers alone.
+ if (!h.recipients.some((r) => r === target)) return false;
+ break;
+ }
+ default:
+ // An unknown scope is a rule this build does not understand. Matching nothing is
+ // the only safe reading — the alternative is matching everything.
+ return false;
+ }
+
+ if (rule.fromPattern && !matchesPattern(rule.fromPattern, h.from ?? h.fromAddress)) {
+ return false;
+ }
+ if (rule.subjectPattern && !matchesPattern(rule.subjectPattern, h.subject)) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Operator-facing matching: case-insensitive substring, with `*` as a wildcard.
+ *
+ * Deliberately NOT a regular expression. These patterns come from a text box, they run
+ * once per delivered message, and an operator pasting `.*(a+)+$` would hand us a
+ * catastrophic-backtracking stall on the mail path. Every metacharacter is escaped and
+ * only `*` is given meaning.
+ */
+export function matchesPattern(pattern: string, value: string | undefined): boolean {
+ if (!value) return false;
+ const trimmed = pattern.trim();
+ if (!trimmed) return true;
+ const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*");
+ return new RegExp(escaped, "i").test(value);
+}
diff --git a/apps/api/src/modules/mail/inbound/inbound.controller.ts b/apps/api/src/modules/mail/inbound/inbound.controller.ts
new file mode 100644
index 000000000..6a6c01bff
--- /dev/null
+++ b/apps/api/src/modules/mail/inbound/inbound.controller.ts
@@ -0,0 +1,318 @@
+/**
+ * Inbound-rule CRUD for the mail admin panel.
+ *
+ * Every handler repeats the same three-part guard the ~30 existing mail handlers use, and
+ * that repetition is load-bearing rather than copy-paste:
+ *
+ * assertNotCloud — the mail engine is self-hosted only.
+ * permission.assert — with the CONCRETE serverId, never the wildcard. `mail_server`
+ * is a CONDITIONAL_SINGLETON: on a route with no `:serverId`
+ * PATH param the tag degrades to resourceId "*", which for
+ * owner/admin/member is a pure role×type decision naming no
+ * server — i.e. no tenant boundary at all. That is why serverId
+ * is in the path here and not in the body.
+ * isServerInOrg — ties the request to THIS org's server.
+ *
+ * Arming happens on write, and the engine is the source of truth for what is armed; there
+ * is no mirror table. A failed arm leaves the rule row disabled with the reason attached
+ * rather than a rule that looks live and captures nothing.
+ */
+
+import type { Context } from "hono";
+import { safeErrorMessage } from "@repo/core";
+import { repos, type MailInboundScope } from "@repo/db";
+import { getRequestContext } from "../../../lib/request-context";
+import { permission } from "../../../lib/permission";
+import { param, isServerInOrg, assertNotCloud } from "../../../lib/controller-helpers";
+import {
+ armDomain,
+ disarmDomain,
+ ForeignBccError,
+ listEngineDomains,
+ ruleDomain,
+} from "./capture";
+import { runInboundForServer } from "./read";
+
+const SCOPES: readonly MailInboundScope[] = ["mailbox", "domain", "all"];
+const EMAIL_RE = /^[a-z0-9._+-]+@[a-z0-9.-]+\.[a-z]{2,}$/;
+const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/;
+
+function errorJson(c: Context, err: unknown) {
+ if (err instanceof ForeignBccError) {
+ return c.json({ error: err.message, code: "MAIL_FOREIGN_BCC" }, 409);
+ }
+ return c.json({ error: safeErrorMessage(err) }, 500);
+}
+
+/**
+ * Guard shared by every handler below. Returns a Response to short-circuit, or the
+ * resolved context to continue with.
+ */
+async function guardServer(c: Context, action: "read" | "write") {
+ const cloud = assertNotCloud(c);
+ if (cloud) return { response: cloud } as const;
+ const serverId = param(c, "serverId");
+ await permission.assert(getRequestContext(c), {
+ resourceType: "mail_server",
+ resourceId: serverId,
+ action,
+ });
+ const ctx = getRequestContext(c);
+ if (!(await isServerInOrg(ctx, serverId))) {
+ return { response: c.json({ error: "Server not found" }, 404) } as const;
+ }
+ return { serverId, organizationId: ctx.organizationId! } as const;
+}
+
+/**
+ * Validate the scope/target pair at the WRITE boundary.
+ *
+ * This is the enforcement the schema deliberately does not carry as a CHECK constraint:
+ * a mailbox or domain rule with no target would be representable, and if the filter ever
+ * read a missing target as "no constraint" it would silently widen to every message on the
+ * server. The filter already fails closed; this refuses to store the row in the first
+ * place, so the operator finds out at save time instead of never.
+ */
+function validateScope(scope: string, target: unknown): { scope: MailInboundScope; target: string | null } {
+ if (!SCOPES.includes(scope as MailInboundScope)) {
+ throw new Error(`Unknown scope "${scope}". Expected one of: ${SCOPES.join(", ")}.`);
+ }
+ const s = scope as MailInboundScope;
+ if (s === "all") return { scope: s, target: null };
+
+ const t = typeof target === "string" ? target.trim().toLowerCase() : "";
+ if (!t) throw new Error(`A ${s} rule needs a target.`);
+ if (s === "mailbox" && !EMAIL_RE.test(t)) {
+ throw new Error(`"${t}" is not a valid email address.`);
+ }
+ if (s === "domain" && !DOMAIN_RE.test(t)) {
+ throw new Error(`"${t}" is not a valid domain.`);
+ }
+ return { scope: s, target: t };
+}
+
+/**
+ * The domains a rule needs armed on the engine.
+ *
+ * The scope→domain derivation itself lives in `ruleDomain` (capture.ts) so the write path
+ * and the read job cannot disagree about which domain a target names. Only the `all`
+ * fan-out differs here: the write path has to arm every domain the engine currently has,
+ * whereas the read job walks whatever is actually armed.
+ */
+async function domainsFor(
+ serverId: string,
+ scope: MailInboundScope,
+ target: string | null,
+): Promise {
+ if (scope === "all") return listEngineDomains(serverId);
+ const domain = ruleDomain({ scope, target });
+ return domain ? [domain] : [];
+}
+
+/**
+ * Release the BCC on any candidate domain no enabled rule still wants.
+ *
+ * This has to run on EVERY mutation that can narrow what is watched, not just delete
+ * (GH-559). An armed domain keeps Postfix BCC'ing a full second copy of every message
+ * into a collector mailbox provisioned at QUOTA 0, and the only thing that prunes those
+ * copies is the read job visiting that domain. Disable a rule — or move its scope — and
+ * the copies keep arriving while nothing deletes them, on /var/vmail, a bind mount with
+ * no quota of its own. That fills the disk and takes the mail server down.
+ *
+ * Capture is domain-keyed and SHARED, so a domain a second rule still covers must stay
+ * armed; `candidates` is therefore only what the mutation could have orphaned, never the
+ * whole engine. Failures are swallowed because `reconcileDomains` repairs the residue on
+ * a schedule — a disarm that loses a race must not fail the operator's save.
+ */
+async function releaseOrphanedDomains(serverId: string, candidates: string[]): Promise {
+ if (candidates.length === 0) return;
+ const remaining = await repos.mailInbound.listEnabledByServer(serverId);
+ const stillWanted = new Set();
+ for (const r of remaining) {
+ for (const d of await domainsFor(serverId, r.scope as MailInboundScope, r.target)) {
+ stillWanted.add(d);
+ }
+ }
+ for (const d of candidates) {
+ if (!stillWanted.has(d)) await disarmDomain(serverId, d).catch(() => undefined);
+ }
+}
+
+export async function listRulesHandler(c: Context) {
+ const g = await guardServer(c, "read");
+ if ("response" in g) return g.response;
+ try {
+ const rules = await repos.mailInbound.listByServer(g.serverId);
+ return c.json({ rules });
+ } catch (err) {
+ return errorJson(c, err);
+ }
+}
+
+export async function createRuleHandler(c: Context) {
+ const g = await guardServer(c, "write");
+ if ("response" in g) return g.response;
+
+ let body: Record;
+ try {
+ body = (await c.req.json()) as Record;
+ } catch {
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ let scope: MailInboundScope;
+ let target: string | null;
+ try {
+ ({ scope, target } = validateScope(String(body.scope ?? ""), body.target));
+ } catch (err) {
+ return c.json({ error: safeErrorMessage(err) }, 400);
+ }
+
+ const name = String(body.name ?? "").trim();
+ if (!name) return c.json({ error: "A rule needs a name." }, 400);
+
+ const channelIds = Array.isArray(body.channelIds) ? body.channelIds.map(String) : [];
+ if (channelIds.length === 0) {
+ return c.json({ error: "Pick at least one notification channel." }, 400);
+ }
+
+ try {
+ // Arm FIRST. A rule row that exists while capture is off is a rule that looks live and
+ // silently sees nothing — the worse of the two half-states, and the one an operator
+ // cannot diagnose from the UI.
+ const domains = await domainsFor(g.serverId, scope, target);
+ for (const d of domains) await armDomain(g.serverId, d);
+
+ const rule = await repos.mailInbound.create({
+ serverId: g.serverId,
+ organizationId: g.organizationId,
+ name,
+ scope,
+ target,
+ fromPattern: typeof body.fromPattern === "string" ? body.fromPattern.trim() || null : null,
+ subjectPattern:
+ typeof body.subjectPattern === "string" ? body.subjectPattern.trim() || null : null,
+ maxSpamScore: typeof body.maxSpamScore === "number" ? body.maxSpamScore : null,
+ channelIds,
+ enabled: body.enabled === false ? false : true,
+ });
+ return c.json({ rule }, 201);
+ } catch (err) {
+ return errorJson(c, err);
+ }
+}
+
+export async function updateRuleHandler(c: Context) {
+ const g = await guardServer(c, "write");
+ if ("response" in g) return g.response;
+ const ruleId = param(c, "ruleId");
+
+ let body: Record;
+ try {
+ body = (await c.req.json()) as Record;
+ } catch {
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ try {
+ const existing = await repos.mailInbound.findById(g.organizationId, ruleId);
+ if (!existing) return c.json({ error: "Rule not found" }, 404);
+
+ const patch: Record = {};
+ if (typeof body.name === "string" && body.name.trim()) patch.name = body.name.trim();
+ if (Array.isArray(body.channelIds)) patch.channelIds = body.channelIds.map(String);
+ if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
+ if ("fromPattern" in body) {
+ patch.fromPattern = typeof body.fromPattern === "string" ? body.fromPattern.trim() || null : null;
+ }
+ if ("subjectPattern" in body) {
+ patch.subjectPattern =
+ typeof body.subjectPattern === "string" ? body.subjectPattern.trim() || null : null;
+ }
+ if ("maxSpamScore" in body) {
+ patch.maxSpamScore = typeof body.maxSpamScore === "number" ? body.maxSpamScore : null;
+ }
+ // Clearing the pause is an explicit act, so resuming re-arms below.
+ if (body.pausedReason === null) patch.pausedReason = null;
+
+ if (typeof body.scope === "string") {
+ const v = validateScope(body.scope, body.target ?? existing.target);
+ patch.scope = v.scope;
+ patch.target = v.target;
+ }
+
+ // What this rule covered BEFORE the write - the set that may now be orphaned. Read
+ // first, because after the update the old scope is unrecoverable.
+ const previouslyCovered = await domainsFor(
+ g.serverId,
+ existing.scope as MailInboundScope,
+ existing.target,
+ );
+
+ const rule = await repos.mailInbound.update(g.organizationId, ruleId, patch as never);
+ // The row can vanish between the findById above and here (a concurrent delete). That
+ // delete already released its own domains, so there is nothing to arm and nothing to
+ // orphan - just report it honestly rather than arming for a rule that no longer exists.
+ if (!rule) return c.json({ error: "Rule not found" }, 404);
+
+ // Arm from the PERSISTED row, so re-enabling a rule arms it again — the old code only
+ // armed inside the scope branch, so flipping `enabled` back to true left a live rule
+ // watching nothing. Unguarded on purpose: a failed arm is the operator's problem to
+ // see now, and the file header's atomicity gap is what reconcileDomains repairs.
+ if (rule.enabled) {
+ for (const d of await domainsFor(g.serverId, rule.scope as MailInboundScope, rule.target)) {
+ await armDomain(g.serverId, d);
+ }
+ }
+ // Then release whatever this rule no longer wants: `enabled: false`, a narrowed scope,
+ // or a retargeted domain (GH-559).
+ await releaseOrphanedDomains(g.serverId, previouslyCovered);
+
+ return c.json({ rule });
+ } catch (err) {
+ return errorJson(c, err);
+ }
+}
+
+export async function deleteRuleHandler(c: Context) {
+ const g = await guardServer(c, "write");
+ if ("response" in g) return g.response;
+ const ruleId = param(c, "ruleId");
+
+ try {
+ const existing = await repos.mailInbound.findById(g.organizationId, ruleId);
+ if (!existing) return c.json({ error: "Rule not found" }, 404);
+
+ // Read what it covered before the row is gone.
+ const covered = await domainsFor(
+ g.serverId,
+ existing.scope as MailInboundScope,
+ existing.target,
+ );
+ await repos.mailInbound.remove(g.organizationId, ruleId);
+ await releaseOrphanedDomains(g.serverId, covered);
+ return c.json({ ok: true });
+ } catch (err) {
+ return errorJson(c, err);
+ }
+}
+
+/**
+ * Run the read once, without dispatching or deleting — the "why did my rule not fire?"
+ * button. `mail_server:write` rather than `:read` because the boot scanner rejects a POST
+ * on a read-tagged route as a CRITICAL error and exits the process.
+ */
+export async function testRulesHandler(c: Context) {
+ const g = await guardServer(c, "write");
+ if ("response" in g) return g.response;
+ try {
+ const result = await runInboundForServer({
+ serverId: g.serverId,
+ organizationId: g.organizationId,
+ dryRun: true,
+ });
+ return c.json(result);
+ } catch (err) {
+ return errorJson(c, err);
+ }
+}
diff --git a/apps/api/src/modules/mail/inbound/read.ts b/apps/api/src/modules/mail/inbound/read.ts
new file mode 100644
index 000000000..a2eab64dc
--- /dev/null
+++ b/apps/api/src/modules/mail/inbound/read.ts
@@ -0,0 +1,331 @@
+/**
+ * Reading captured mail out of the collector folder and turning it into notifications.
+ *
+ * DISPATCH THEN DELETE, and the deletion is the point. There is no cursor table and no
+ * UID bookkeeping: the BCC copy is a whole message sitting on disk, so removing it after
+ * dispatch is both "we have handled this" and the prune that stops a second full copy of
+ * every watched message accumulating forever under `/var/vmail` — a host bind mount with
+ * no quota of its own. The collector runs at quota 0 precisely because this runs.
+ *
+ * Dispatch means ENQUEUED: a `notification_delivery` row is written and the notification
+ * worker owns retries. So deleting the message immediately afterwards is correct — the
+ * durable retry lives in that table, not in the maildir.
+ *
+ * HEADERS ONLY cross the wire. Bodies and attachments are never read, which keeps the
+ * payload small, keeps message content out of the control plane's logs and out of the
+ * notification, and makes the read cost independent of a 20 MB attachment.
+ *
+ * Everything runs through `runMailCommand`/`mailEngineCommand`, so `/var/vmail` resolves
+ * inside the engine on a container box and on the host on a legacy one. Reading these
+ * paths with the raw host executor is the GH-562 bug class.
+ */
+
+import { safeErrorMessage, shellQuote } from "@repo/core";
+import { repos, type MailInboundRule } from "@repo/db";
+import { mailEngineCommand, runMailCommand } from "../mail-engine";
+import { collectorFolderPath, readArmedState, ruleDomain, tokenFromBcc } from "./capture";
+import { loopGuard, matchesRule, parseHeaderBlock, spamGate, type ParsedHeaders } from "./filter";
+
+/** Bytes of each message we read. Enough for a full header block, never a body. */
+const HEADER_BYTES = 8192;
+
+/**
+ * Messages handled per domain per tick.
+ *
+ * A COUNT, never a byte cap: slicing a batch by bytes can cut a record mid-field and
+ * corrupt the parse of an otherwise healthy message. A mailing-list burst that exceeds
+ * this is drained over subsequent ticks rather than dropped, and the digest below is what
+ * keeps it from becoming one alert per message.
+ */
+const MAX_PER_TICK = 40;
+
+/**
+ * Above this many messages in one tick for one rule, send ONE digest instead of N alerts.
+ *
+ * The notification subsystem has no rate limiting or windowed dedup of its own — burst
+ * control is explicitly the producer's job — so without this a list posting to a watched
+ * address is a channel flood, and a backlog after an outage is worse.
+ */
+const DIGEST_THRESHOLD = 5;
+
+export interface InboundMessage {
+ /** Maildir filename — unique, and what we delete by. */
+ file: string;
+ headers: ParsedHeaders;
+}
+
+/**
+ * List then read the new messages in a collector folder, in ONE round trip.
+ *
+ * `head -c` per file rather than `cat`: a body must not cross the wire, and a message with
+ * a 20 MB attachment must not make this read expensive. Each blob is fenced by a marker
+ * line carrying the filename, so one exec yields both identities and content — a
+ * per-message exec would be a round trip per message.
+ */
+export async function readCollectorFolder(
+ serverId: string,
+ folder: string,
+ limit = MAX_PER_TICK,
+): Promise {
+ // `|| true` so an empty or absent folder is an empty read, not a thrown command: a
+ // domain armed a moment ago has no `new/` until the first delivery creates it.
+ const script =
+ `set -e; d=${shellQuote(`${folder}/new`)}; [ -d "$d" ] || exit 0; ` +
+ `for f in $(ls -1 "$d" 2>/dev/null | head -n ${limit}); do ` +
+ `echo "__OPENSHIP_MSG__ $f"; head -c ${HEADER_BYTES} "$d/$f" 2>/dev/null || true; ` +
+ `echo; done`;
+
+ const { output } = await runMailCommand(
+ serverId,
+ (flavor) => mailEngineCommand(flavor, `sh -c ${shellQuote(script)}`),
+ { timeout: 30_000 },
+ );
+
+ const out: InboundMessage[] = [];
+ for (const chunk of output.split("__OPENSHIP_MSG__ ").slice(1)) {
+ const nl = chunk.indexOf("\n");
+ if (nl < 0) continue;
+ const file = chunk.slice(0, nl).trim();
+ if (!file) continue;
+ out.push({ file, headers: parseHeaderBlock(chunk.slice(nl + 1)) });
+ }
+ return out;
+}
+
+/**
+ * Delete handled messages. Batched into one exec, and never a wildcard.
+ *
+ * Filenames come from `ls` of a directory we own, but they are still interpolated into a
+ * shell command on a root, host-networked container — so every one is individually
+ * shell-quoted, and anything that does not look like a maildir filename is skipped rather
+ * than quoted-and-hoped.
+ */
+export async function deleteMessages(
+ serverId: string,
+ folder: string,
+ files: readonly string[],
+): Promise {
+ const safe = files.filter((f) => /^[A-Za-z0-9._,:=-]+$/.test(f));
+ if (safe.length === 0) return;
+ const args = safe.map((f) => shellQuote(`${folder}/new/${f}`)).join(" ");
+ await runMailCommand(serverId, (flavor) => mailEngineCommand(flavor, `rm -f ${args}`), {
+ timeout: 30_000,
+ });
+}
+
+interface Matched {
+ rule: MailInboundRule;
+ message: InboundMessage;
+}
+
+/**
+ * Run one server's collectors: read, filter, dispatch, delete.
+ *
+ * Returns counts rather than throwing on a per-domain failure — one unreachable domain
+ * must not stop the others, and the job wrapper reports the totals.
+ */
+export async function runInboundForServer(opts: {
+ serverId: string;
+ organizationId: string;
+ openshipSenders?: readonly string[];
+ /** Skip dispatch and deletion — powers the UI's "test this rule" button. */
+ dryRun?: boolean;
+}): Promise<{ read: number; matched: number; emitted: number; dropped: number; errors: string[] }> {
+ const { serverId, organizationId, dryRun } = opts;
+ const errors: string[] = [];
+ let read = 0;
+ let matched = 0;
+ let emitted = 0;
+ let dropped = 0;
+
+ const rules = await repos.mailInbound.listEnabledByServer(serverId);
+ if (rules.length === 0) return { read, matched, emitted, dropped, errors };
+
+ // Which domains to visit. `all` means every armed domain, so it is resolved from what
+ // the ENGINE says is armed rather than from the rule rows.
+ const wantsAll = rules.some((r) => r.scope === "all");
+ const explicit = new Set(
+ rules
+ .filter((r) => r.scope === "domain" || r.scope === "mailbox")
+ .map((r) => ruleDomain(r))
+ .filter((d): d is string => Boolean(d)),
+ );
+
+ const domains = wantsAll ? await armedDomains(serverId) : [...explicit];
+
+ for (const domain of domains) {
+ try {
+ const state = await readArmedState(serverId, domain);
+ if (!state.token || !state.maildirPath) continue;
+
+ const folder = collectorFolderPath(state.maildirPath, state.token);
+ const messages = await readCollectorFolder(serverId, folder);
+ read += messages.length;
+ if (messages.length === 0) continue;
+
+ const handled: string[] = [];
+ const perRule = new Map();
+
+ for (const message of messages) {
+ handled.push(message.file);
+
+ const guard = loopGuard(message.headers, { openshipSenders: opts.openshipSenders });
+ if (guard.drop) {
+ dropped++;
+ continue;
+ }
+ let any = false;
+ for (const rule of rules) {
+ if (!matchesRule(rule, message.headers, domain)) continue;
+ if (spamGate(message.headers, rule.maxSpamScore).drop) continue;
+ any = true;
+ const list = perRule.get(rule.id) ?? [];
+ list.push({ rule, message });
+ perRule.set(rule.id, list);
+ }
+ if (any) matched++;
+ else dropped++;
+ }
+
+ if (!dryRun) {
+ for (const [, group] of perRule) {
+ emitted += await emitForRule(organizationId, serverId, domain, group);
+ }
+ // Delete AFTER dispatch. A message that produced no match is deleted too: it was
+ // examined and rejected, and leaving it would re-examine it forever.
+ await deleteMessages(serverId, folder, handled);
+ for (const ruleId of perRule.keys()) {
+ await repos.mailInbound.markMatched(ruleId).catch(() => undefined);
+ }
+ }
+ } catch (err) {
+ errors.push(`${domain}: ${safeErrorMessage(err)}`);
+ }
+ }
+
+ return { read, matched, emitted, dropped, errors };
+}
+
+/**
+ * Emit for one rule's matches — individually, or one digest above the threshold.
+ *
+ * The digest is the correct shape after a backlog: an operator returning from an outage
+ * wants "31 messages arrived", not thirty-one notifications spread across ten minutes.
+ *
+ * DELIVERY GOES TO THE RULE'S OWN CHANNELS, not to the org's category subscriptions. The
+ * operator picked channels per rule ("support@ → #support"), so routing through the
+ * dispatcher would ignore that choice and fan every rule to whoever happens to be
+ * subscribed. This is the same per-target enqueue `job-command.ts` uses for a job's
+ * `notifyConfig`, for the same reason.
+ *
+ * With no channels the rule delivers NOTHING and says so. Quietly falling back to org-wide
+ * subscriptions would send a rule's mail somewhere the operator never chose.
+ */
+async function emitForRule(
+ organizationId: string,
+ serverId: string,
+ domain: string,
+ group: readonly Matched[],
+): Promise {
+ const rule = group[0]!.rule;
+
+ const payloads =
+ group.length > DIGEST_THRESHOLD
+ ? [
+ {
+ title: `${group.length} messages matched “${rule.name}”`,
+ message:
+ `${group.length} messages arrived for ${domain} matching “${rule.name}”. ` +
+ `Collapsed into one alert to avoid flooding this channel.`,
+ ruleName: rule.name,
+ domain,
+ count: group.length,
+ },
+ ]
+ : group.map(({ message }) => {
+ const h = message.headers;
+ return {
+ title: h.subject ? `New mail: ${h.subject}` : "New mail received",
+ // Envelope-level facts only. No body, no snippet — the operator asked to be
+ // told mail ARRIVED, and a channel is not a mail client. It also keeps message
+ // content out of the control plane's delivery rows and logs.
+ message:
+ `From: ${h.from ?? h.fromAddress ?? "unknown"}\n` +
+ `To: ${h.recipients.join(", ") || domain}\n` +
+ `Rule: ${rule.name}`,
+ ruleName: rule.name,
+ domain,
+ from: h.fromAddress,
+ subject: h.subject,
+ };
+ });
+
+ const channelIds = rule.channelIds ?? [];
+ if (channelIds.length === 0) {
+ console.warn(
+ `[mail:inbound] rule ${rule.id} (“${rule.name}”) matched ${group.length} message(s) ` +
+ `but has no notification channels — nothing was sent.`,
+ );
+ return 0;
+ }
+
+ let sent = 0;
+ for (const channelId of channelIds) {
+ const channel = await repos.notificationChannel.findById(channelId).catch(() => null);
+ // `verified` is the ONLY gate the dispatcher applies before shipping org payloads to
+ // an outbound URL, so a direct enqueue has to apply it too — otherwise this path
+ // becomes the way to post to an unverified endpoint.
+ if (!channel || !channel.enabled || !channel.verified) continue;
+
+ // A rule must not be able to name another tenant's channel. The dispatcher gets this
+ // for free by looking subscriptions up per org; a direct enqueue has to check.
+ const channelOrgId = await resolveChannelOrg(channel.userId);
+ if (!channelOrgId || channelOrgId !== organizationId) continue;
+
+ for (const payload of payloads) {
+ await repos.notificationDelivery
+ .create({
+ userId: channel.userId,
+ organizationId,
+ auditEventId: null,
+ category: MAIL_INBOUND_EVENT,
+ channelId: channel.id,
+ channelKind: channel.kind,
+ status: "queued",
+ attempts: 0,
+ payload: { ...payload, resourceType: "mail_server", resourceId: serverId },
+ })
+ .then(() => {
+ sent++;
+ })
+ .catch((err) => {
+ console.warn(
+ `[mail:inbound] could not queue delivery for rule ${rule.id}: ${safeErrorMessage(err)}`,
+ );
+ });
+ }
+ }
+ return sent;
+}
+
+/** A channel belongs to a user; the org is that user's membership. */
+async function resolveChannelOrg(userId: string): Promise {
+ const members = await repos.member.listByUser(userId).catch(() => []);
+ return members[0]?.organizationId ?? null;
+}
+
+/**
+ * Hoisted so the audit-taxonomy scan sees exactly one literal, and so a typo cannot make
+ * the producer and the registry disagree — an unmapped eventType is dropped silently.
+ */
+export const MAIL_INBOUND_EVENT = "mail.inbound_received";
+
+/** Domains the ENGINE currently has one of our BCC rows on. */
+async function armedDomains(serverId: string): Promise {
+ const { queryRows, q } = await import("../admin/psql-runner");
+ const rows = await queryRows<{ domain: string; bcc_address: string }>(
+ serverId,
+ `SELECT domain, bcc_address FROM recipient_bcc_domain WHERE active = 1 AND bcc_address LIKE ${q("openship-hook+%")}`,
+ );
+ return rows.filter((r) => tokenFromBcc(r.bcc_address)).map((r) => r.domain);
+}
diff --git a/apps/api/src/modules/mail/inbound/senders.ts b/apps/api/src/modules/mail/inbound/senders.ts
new file mode 100644
index 000000000..70249eaf9
--- /dev/null
+++ b/apps/api/src/modules/mail/inbound/senders.ts
@@ -0,0 +1,50 @@
+/**
+ * The addresses THIS instance sends its own mail from.
+ *
+ * This exists for exactly one reason: a notification about mail is itself mail. If an
+ * inbound rule watches a domain that also receives Openship's own alerts — a support
+ * address on the same engine, a member whose mailbox lives there — then the alert is
+ * captured, matched, and emitted again. The loop only stops when a human notices.
+ *
+ * `loopGuard` takes these as data so it can stay pure. Two sources, because the instance
+ * can send as either:
+ * - `instance_settings.smtpFrom`, i.e. whatever Settings→Email is configured with (the
+ * one sender for ALL system mail);
+ * - `openship@` for each provisioned mail server, the platform mailbox the mail
+ * module creates for itself.
+ *
+ * Best-effort and never fatal: a failure here must not stop the sweep, but it DOES widen
+ * the loop window, so it is logged rather than swallowed silently.
+ */
+
+import { safeErrorMessage } from "@repo/core";
+import { repos } from "@repo/db";
+import { PLATFORM_LOCAL_PART } from "../admin/platform-mailbox.service";
+
+export async function getInstanceSmtpSenders(): Promise {
+ const out = new Set();
+
+ try {
+ const settings = await repos.instanceSettings.get();
+ const from = settings?.smtpFrom?.trim().toLowerCase();
+ // smtpFrom may be a display form (`Openship `), so take the bare address.
+ if (from) {
+ const angled = from.match(/<([^>]+)>/);
+ const address = (angled ? angled[1] : from).trim();
+ if (address.includes("@")) out.add(address);
+ }
+ } catch (err) {
+ console.warn(`[mail:inbound] could not read instance SMTP sender: ${safeErrorMessage(err)}`);
+ }
+
+ try {
+ for (const server of await repos.mailServer.list()) {
+ const domain = server.domain?.trim().toLowerCase();
+ if (domain) out.add(`${PLATFORM_LOCAL_PART}@${domain}`);
+ }
+ } catch (err) {
+ console.warn(`[mail:inbound] could not read mail server domains: ${safeErrorMessage(err)}`);
+ }
+
+ return [...out];
+}
diff --git a/apps/api/src/modules/mail/inbound/watch.ts b/apps/api/src/modules/mail/inbound/watch.ts
new file mode 100644
index 000000000..63f7085d0
--- /dev/null
+++ b/apps/api/src/modules/mail/inbound/watch.ts
@@ -0,0 +1,141 @@
+/**
+ * The `mail:inbound-watch` job body — one sweep across every mail server with rules.
+ *
+ * Kept out of `job.registry.ts` and behind a dynamic import for the same reason the whole
+ * mail module is: it must not load into the cloud runtime, where the engine does not exist.
+ *
+ * Cheap when idle by design. A server with no enabled rules costs one indexed query and
+ * ZERO SSH, which matters at a one-minute cadence on an instance where most boxes are not
+ * mail servers.
+ */
+
+import { safeErrorMessage } from "@repo/core";
+import { repos, type MailInboundScope } from "@repo/db";
+import { getInstanceSmtpSenders } from "./senders";
+import { runInboundForServer } from "./read";
+import { reconcileDomains, ruleDomain } from "./capture";
+
+/** Index signature so this satisfies the job runner's `JobSummary` shape directly. */
+export interface InboundWatchResult {
+ [key: string]: number;
+ servers: number;
+ read: number;
+ emitted: number;
+ dropped: number;
+ errors: number;
+}
+
+export async function runInboundWatch(): Promise {
+ const result: InboundWatchResult = { servers: 0, read: 0, emitted: 0, dropped: 0, errors: 0 };
+
+ // Only servers that actually have rules. `listEnabled` is one indexed read across the
+ // instance, versus a per-server probe that would cost SSH on every box every minute.
+ const rules = await repos.mailInbound.listEnabled().catch(() => []);
+ if (rules.length === 0) return result;
+
+ // The addresses this instance sends its OWN mail from. Passed into the loop guard so a
+ // notification email that lands back inside a watched domain is recognised as ours and
+ // cannot feed itself.
+ const openshipSenders = await getInstanceSmtpSenders().catch(() => []);
+
+ const byServer = new Map();
+ for (const rule of rules) byServer.set(rule.serverId, rule.organizationId);
+
+ for (const [serverId, organizationId] of byServer) {
+ result.servers++;
+ try {
+ const r = await runInboundForServer({ serverId, organizationId, openshipSenders });
+ result.read += r.read;
+ result.emitted += r.emitted;
+ result.dropped += r.dropped;
+ if (r.errors.length > 0) {
+ result.errors += r.errors.length;
+ // Per-domain failures are already summarized by runInboundForServer. Logged rather
+ // than thrown: one unreachable mail box must not stop the rest of the sweep.
+ console.warn(`[mail:inbound-watch] ${serverId}: ${r.errors.join("; ")}`);
+ }
+ } catch (err) {
+ result.errors++;
+ console.warn(`[mail:inbound-watch] ${serverId} failed: ${safeErrorMessage(err)}`);
+ }
+ }
+
+ return result;
+}
+
+export interface InboundReconcileResult {
+ [key: string]: number;
+ servers: number;
+ armed: number;
+ disarmed: number;
+ refused: number;
+ errors: number;
+}
+
+/**
+ * The `mail:inbound-reconcile` job body — drift repair for what is armed on each engine.
+ *
+ * `reconcileDomains` existed with no caller at all (GH-559), which left three drifts
+ * permanent. The write path now disarms what it orphans, so this is the backstop rather
+ * than the only defence — but it is the only thing that fixes:
+ *
+ * - a domain a `scope: "all"` rule should cover that was ADDED AFTER the rule. There is
+ * no global BCC in the shipped config, so "all" is a real per-domain fan-out and a new
+ * domain is invisible to it until something re-arms;
+ * - an arm or disarm that failed mid-write (the atomicity gap capture.ts documents);
+ * - a collector an operator deleted by hand from the Mailboxes tab.
+ *
+ * Slow cadence on purpose: every pass costs SSH per mail server, and none of the drifts
+ * above are latency-sensitive — the expensive one (an unpruned collector filling
+ * /var/vmail) is now closed on the write path, so this only has to catch the residue.
+ */
+export async function runInboundReconcile(): Promise {
+ const result: InboundReconcileResult = {
+ servers: 0,
+ armed: 0,
+ disarmed: 0,
+ refused: 0,
+ errors: 0,
+ };
+
+ const rules = await repos.mailInbound.listEnabled().catch(() => []);
+ if (rules.length === 0) return result;
+
+ // Group the WANTED set per server. `all` short-circuits the domain list: reconcile
+ // resolves it against the engine's real domains, which is the whole point.
+ const wantedByServer = new Map; all: boolean }>();
+ for (const rule of rules) {
+ let w = wantedByServer.get(rule.serverId);
+ if (!w) {
+ w = { domains: new Set(), all: false };
+ wantedByServer.set(rule.serverId, w);
+ }
+ if ((rule.scope as MailInboundScope) === "all") {
+ w.all = true;
+ continue;
+ }
+ const domain = ruleDomain({ scope: rule.scope as MailInboundScope, target: rule.target });
+ if (domain) w.domains.add(domain);
+ }
+
+ for (const [serverId, wanted] of wantedByServer) {
+ result.servers++;
+ try {
+ const r = await reconcileDomains(serverId, wanted);
+ result.armed += r.armed.length;
+ result.disarmed += r.disarmed.length;
+ result.refused += r.refused.length;
+ if (r.refused.length > 0) {
+ // A foreign BCC is reported, never touched — the operator has to resolve it.
+ console.warn(
+ `[mail:inbound-reconcile] ${serverId}: left alone (foreign BCC or failed arm): ${r.refused.join(", ")}`,
+ );
+ }
+ } catch (err) {
+ result.errors++;
+ console.warn(`[mail:inbound-reconcile] ${serverId} failed: ${safeErrorMessage(err)}`);
+ }
+ }
+
+ return result;
+}
diff --git a/apps/api/src/modules/mail/mail-credentials.service.ts b/apps/api/src/modules/mail/mail-credentials.service.ts
index e9a302524..ab3fb4248 100644
--- a/apps/api/src/modules/mail/mail-credentials.service.ts
+++ b/apps/api/src/modules/mail/mail-credentials.service.ts
@@ -4,10 +4,10 @@
* Flow:
* 1. Hash the new password with `doveadm pw -s SSHA512` (the scheme
* iRedMail's default `dovecot-sql.conf` uses for the `password`
- * column). Hashing on the target server avoids sending the
- * cleartext or the hash through any intermediate process.
- * 2. UPDATE vmail.mailbox SET password = '' WHERE username = …
- * via `sudo -u postgres psql`.
+ * column), via the shared `admin/password.ts` helper. Hashing on the
+ * target server avoids sending the cleartext or the hash through any
+ * intermediate process.
+ * 2. UPDATE vmail.mailbox SET password = … through `admin/psql-runner`.
* 3. Scrub any leftover plaintext from the state file. We used to mirror
* it back for the credentials card to display; that was a needless
* attack surface and is gone - the only way to "know" the password
@@ -15,37 +15,10 @@
*/
import type { CommandExecutor } from "@repo/adapters";
+import { hashPassword } from "./admin/password";
+import { execute, q } from "./admin/psql-runner";
import { readState, mutateState } from "./mail-state";
-/**
- * Shell-quote an arbitrary string so it survives as a single argv element
- * inside a `bash -c …` command. Wraps in single quotes and escapes any
- * embedded single quotes via the standard `'\''` trick.
- */
-function shellQuote(s: string): string {
- return `'${s.replace(/'/g, "'\\''")}'`;
-}
-
-/**
- * Hash a plaintext password via doveadm. Returns the `{SSHA512}...` string
- * ready to drop into the `password` column.
- */
-async function hashWithDovecot(
- exec: CommandExecutor,
- plaintext: string,
-): Promise {
- const out = await exec.exec(
- `doveadm pw -s SSHA512 -p ${shellQuote(plaintext)}`,
- );
- const hash = out.trim();
- if (!hash.startsWith("{SSHA512}")) {
- throw new Error(
- `doveadm pw returned unexpected output: ${hash.slice(0, 60)}…`,
- );
- }
- return hash;
-}
-
/**
* Update the postmaster password for ``. Caller is responsible
* for validation (length, etc.) - this function trusts the input.
@@ -59,23 +32,24 @@ export async function updatePostmasterPassword(
newPassword: string,
): Promise {
const username = `postmaster@${domain}`;
- const hash = await hashWithDovecot(exec, newPassword);
+ // Shared with the admin panel's mailbox create/update, so the hash scheme and the
+ // engine-vs-host transport are decided in exactly one place. This used to be a
+ // private copy that ran `doveadm` bare on the host executor, which is dead on a
+ // container-flavor box (#562) — the same defect the mailbox-create path had.
+ const hash = await hashPassword(exec, newPassword);
- // Sanity-check the values we're about to embed. Both come from controlled
- // sources (doveadm output + `postmaster@`), so this is
- // belt-and-suspenders against an upstream surprise.
- if (!/^\{SSHA512\}[A-Za-z0-9+/=]+$/.test(hash)) {
- throw new Error("doveadm pw returned a hash with unexpected characters");
- }
+ // Belt-and-suspenders against an upstream surprise: the username is derived from an
+ // already-validated domain, and `hashPassword` has its own format gate.
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(username)) {
throw new Error(`Refusing to update for suspicious username: ${username}`);
}
- // iRedMail's pg_hba.conf grants the local `postgres` Unix user passwordless
- // access. Single-quote-wrap the SQL string literals - hash chars are
- // [A-Za-z0-9+/={}], username is similarly tame, so no escape gymnastics.
- const psqlCmd = `sudo -u postgres psql -d vmail -v ON_ERROR_STOP=1 -c "UPDATE mailbox SET password='${hash}' WHERE username='${username}';"`;
- await exec.exec(psqlCmd);
+ // Through psql-runner so the invocation matches the box's topology (the engine's pg
+ // sidecar, or `sudo -u postgres` on a legacy install) rather than assuming the latter.
+ await execute(
+ exec,
+ `UPDATE mailbox SET password = ${q(hash)} WHERE username = ${q(username)};`,
+ );
// Persist the new plaintext into state.secrets so the test-email flow
// (and any future SMTP-from-orchestrator use) can authenticate over
diff --git a/apps/api/src/modules/mail/mail-engine.ts b/apps/api/src/modules/mail/mail-engine.ts
index f0fe56e5c..056b442c3 100644
--- a/apps/api/src/modules/mail/mail-engine.ts
+++ b/apps/api/src/modules/mail/mail-engine.ts
@@ -90,6 +90,60 @@ export class MailEngineUnavailableError extends AppError {
}
}
+/**
+ * The engine image's idempotent schema bootstrap, at the path the Dockerfile bakes it
+ * to, plus the restart it needs to be useful: supervisord's default `startretries=3`
+ * has already put Postfix/Dovecot/Amavis in FATAL after their first failed starts
+ * against the empty database, and FATAL is terminal.
+ *
+ * DISPLAY-ONLY — this string is never handed to an executor, which is why the
+ * `${MAIL_CONTAINER}` interpolation here is not shell-quoted like every real docker
+ * string in this file. It mirrors the second half of what entrypoint.sh prints when the
+ * bootstrap fails, so the panel and the container log name the same fix.
+ */
+const MAIL_DB_BOOTSTRAP_COMMAND =
+ `docker exec ${MAIL_CONTAINER} bash /opt/openship-mail/db-bootstrap.sh` +
+ ` && docker restart ${MAIL_CONTAINER}`;
+
+/**
+ * The engine is up and psql answered — with "there is no schema here".
+ *
+ * `db-bootstrap.sh` is what seeds `vmail`, and it used to be able to leave without
+ * having done so. A box in that state serves SSH, runs the container, and answers every
+ * admin read with `relation "domain" does not exist` — which reached the panel as a bare
+ * 500 (GH-562). 409 for the same reason as the engine gate above: the request is valid
+ * and the box is reachable, the operation just cannot run until one documented command
+ * has been. The remediation is IN the message because that is the only place the
+ * operator looks.
+ *
+ * The copy hedges on purpose. `running` comes from `docker inspect .State.Running`,
+ * which is true from the instant the container starts — including the whole time the
+ * entrypoint is inside db-bootstrap.sh waiting up to 180s for the sidecar. A dashboard
+ * poll that lands in that window must not be told confidently to run a repair.
+ *
+ * A separate class from {@link MailEngineUnavailableError} on purpose: the engine IS
+ * installed and running here, so the dashboard's `isMailEngineUnavailable` path — which
+ * hides the message in favour of a banner that will not render — must not claim it.
+ * Since the entrypoint now exits non-zero when the bootstrap fails, a current-image box
+ * lands on `not_running` instead; this is the safety net for an older image, or a
+ * `vmail` wiped under a live engine.
+ */
+export class MailDbNotInitializedError extends AppError {
+ constructor(
+ readonly flavor: MailEngineFlavor,
+ readonly detail: string,
+ ) {
+ super(
+ flavor === "container"
+ ? `The mail database on this server has no schema yet (${detail}). If mail setup is still running, wait for it to finish; otherwise the engine's bootstrap did not complete — run: ${MAIL_DB_BOOTSTRAP_COMMAND}`
+ : `The mail database on this server has no schema yet (${detail}). Re-run mail setup on this server.`,
+ 409,
+ "MAIL_DB_NOT_INITIALIZED",
+ );
+ this.name = "MailDbNotInitializedError";
+ }
+}
+
// ─── Resolution ──────────────────────────────────────────────────────────────
const probes = new WeakMap>();
@@ -203,6 +257,50 @@ export async function runMailCommand(
});
}
+/**
+ * Run SQL against `vmail` on whichever engine this box has — the one funnel every
+ * mail-admin read and write goes through (see `admin/psql-runner`).
+ *
+ * It exists to give a psql failure a TYPE. The flavor is captured as the command is
+ * built, so the "no schema" answer can carry the flavor-correct remediation without a
+ * second topology probe and without psql-runner learning what a container is. `flavor`
+ * stays "none" only when the gate in `runMailCommand` refused before building — the one
+ * case where no SQL ran, and one the classifier never sees because that gate throws a
+ * typed error.
+ */
+export async function runMailSql(target: MailTarget, sql: string): Promise {
+ let flavor: MailEngineFlavor = "none";
+ try {
+ const { output } = await runMailCommand(target, (resolved) => {
+ flavor = resolved;
+ return mailPsqlCommand(resolved, sql);
+ });
+ return output;
+ } catch (err) {
+ if (err instanceof AppError) throw err;
+ const message = err instanceof Error ? err.message : String(err);
+ if (looksLikeMissingSchema(message)) {
+ // Logged here because the throw below is a 409, and the error handler only logs
+ // 5xx — without this line the condition being fixed leaves no trace at all,
+ // which is the complaint that opened the issue.
+ console.warn(`[mail] vmail schema missing on ${flavor} engine: ${firstOutputLine(message)}`);
+ throw new MailDbNotInitializedError(flavor, firstOutputLine(message));
+ }
+ throw err;
+ }
+}
+
+/**
+ * A psql answer that means the schema was never seeded — a missing `vmail` database
+ * (the sidecar initialized, the bootstrap never ran) or a missing table in it (it ran
+ * partially). Deliberately NOT `column … does not exist`: that means OUR SQL disagrees
+ * with a schema that IS there, which is our bug to read verbatim, not an operator's to
+ * bootstrap away.
+ */
+function looksLikeMissingSchema(text: string): boolean {
+ return /database "[^"]+" does not exist|relation "[^"]+" does not exist/i.test(text);
+}
+
/**
* Does this output mean "you talked to the wrong topology / the engine is gone"?
*
@@ -295,6 +393,65 @@ export function mailEngineCommand(flavor: MailEngineFlavor, cmd: string): string
return flavor === "container" ? `docker exec ${MAIL_CONTAINER} ${cmd}` : cmd;
}
+/**
+ * The three renderers the BACKUP shell needs (GH-563).
+ *
+ * `mailPsqlCommand` above cannot serve them: it bakes in `-c `, while a backup
+ * streams a dump to stdout and replays a FILE. And a backup script is generated once and
+ * executed later by the generic custom_command producer over a bare SSH executor, so it
+ * cannot call back into this module — the topology has to be baked into the string.
+ *
+ * The container case is not simply "prefix with docker exec". Two things differ:
+ *
+ * - `-i`. The dump file lives in the producer's `$tmp` ON THE HOST, which the sidecar
+ * cannot see, so the replay has to arrive over stdin (`-f -`) and `docker exec`
+ * needs `-i` to forward it. A `-f "$tmp/…"` inside the container would just be a
+ * missing path.
+ * - WHICH container. Postgres is the sidecar; vmail ownership is the engine. Getting
+ * that pair backwards is how `chown -R vmail:vmail` ran somewhere with no vmail user.
+ */
+/**
+ * Just the topology, for callers that GENERATE a command instead of running one — the
+ * backup plan is built now and executed later, elsewhere, by the generic producer.
+ * `requireRunning` is deliberately not implied: a backup policy can legitimately be
+ * saved while the engine is stopped.
+ */
+export async function resolveMailFlavor(target: MailTarget): Promise {
+ return withMailEngine(target, async (probe) => {
+ if (probe.flavor === "none") {
+ throw new MailEngineUnavailableError("not_installed", probe.flavor);
+ }
+ return probe.flavor;
+ });
+}
+
+export function mailPgDumpToStdout(flavor: MailEngineFlavor, args: string): string {
+ return flavor === "container"
+ ? `docker exec ${MAIL_DB_CONTAINER} pg_dump -U postgres -d ${MAIL_DB_NAME} ${args}`
+ : `sudo -u postgres pg_dump -d ${MAIL_DB_NAME} ${args}`;
+}
+
+/** psql reading SQL from STDIN, so the caller redirects a host-side file into it. */
+export function mailPsqlFromStdin(flavor: MailEngineFlavor): string {
+ const flags = `-d ${MAIL_DB_NAME} -v ON_ERROR_STOP=1 -f -`;
+ return flavor === "container"
+ ? `docker exec -i ${MAIL_DB_CONTAINER} psql -U postgres ${flags}`
+ : `sudo -u postgres psql ${flags}`;
+}
+
+/**
+ * Pick the restored mail data up. The engine reads its accounts from Postgres and its
+ * config from the bind-mounted /etc paths, so a restore is inert until the daemons
+ * re-read both — `supervisorctl` in the container, `systemctl` on a legacy host. The
+ * supervisord program names are deliberately the same strings as the health probe's
+ * units (see mail-health.service.ts), so there is one vocabulary, not two.
+ */
+export function mailDaemonReloadCommand(flavor: MailEngineFlavor): string {
+ return flavor === "container"
+ ? `docker exec ${MAIL_CONTAINER} supervisorctl restart postfix dovecot amavis`
+ : "systemctl reload postfix dovecot 2>/dev/null; systemctl restart amavis 2>/dev/null";
+}
+
/**
* The path each editable config file has INSIDE the engine.
*
@@ -390,7 +547,12 @@ export type MailUnitStatus =
export interface MailUnitState {
status: MailUnitStatus;
- /** Free-form sub-state when running (systemd's, or supervisord's state word). */
+ /**
+ * The supervisor's own state word — systemd's SubState, or supervisord's,
+ * lower-cased. Load-bearing beyond display: `status: "failed"` covers both
+ * supervisord FATAL (it has given up) and BACKOFF (it is still retrying), and
+ * this is the only thing that tells them apart.
+ */
subState?: string;
/** ISO timestamp the unit entered its current state — systemd only. */
activeSince?: string;
@@ -485,10 +647,10 @@ export function mailUnitActionCommand(
): string {
if (flavor === "container") {
return key === "postgresql"
- ? `docker ${action} ${MAIL_DB_CONTAINER}`
- : `docker exec ${MAIL_CONTAINER} supervisorctl ${action} ${unit}`;
+ ? `docker ${action} ${sq(MAIL_DB_CONTAINER)}`
+ : `docker exec ${sq(MAIL_CONTAINER)} supervisorctl ${action} ${sq(unit)}`;
}
- return `systemctl --no-block ${action} ${unit}`;
+ return `systemctl --no-block ${action} ${sq(unit)}`;
}
/**
@@ -508,23 +670,39 @@ export function mailQueueProbeCommand(flavor: MailEngineFlavor, lines = 400): st
}
/**
- * Tail one daemon's logs. supervisord writes each program to
- * `/var/log/supervisor/.log` (see apps/email's supervisord.conf); the
- * sidecar logs to its container; a legacy box has journald. `timeout 10` caps the
- * exec so a hung log can't sit on the SSH channel.
+ * Tail one daemon's logs — the command to run, and the same read in the form we
+ * show the operator.
+ *
+ * supervisord writes each program to `/var/log/supervisor/.log` (see
+ * apps/email's supervisord.conf); the sidecar logs to its container; a legacy box
+ * has journald. `timeout 10` caps the exec so a hung log can't sit on the SSH
+ * channel.
+ *
+ * `source` exists because the drawer printed a hardcoded `journalctl -u …` header,
+ * naming a log the container engine does not have. Both halves come out of this one
+ * switch, so the header cannot drift from the read; `source` drops the `timeout`,
+ * the redirection and the shell quoting, and nothing else. The log path keeps
+ * `${unit}` unquoted deliberately — it is closed over by `MAIL_COMPONENTS`, not
+ * caller input.
*/
-export function mailUnitLogsCommand(
+export function mailUnitLogsRead(
flavor: MailEngineFlavor,
key: string,
unit: string,
lines: number,
-): string {
+): { command: string; source: string } {
if (flavor === "container") {
- return key === "postgresql"
- ? `timeout 10 docker logs --tail ${lines} ${MAIL_DB_CONTAINER} 2>&1 || true`
- : `timeout 10 docker exec ${MAIL_CONTAINER} tail -n ${lines} /var/log/supervisor/${unit}.log 2>/dev/null || true`;
+ if (key === "postgresql") {
+ const source = `docker logs --tail ${lines} ${MAIL_DB_CONTAINER}`;
+ return { command: `timeout 10 ${source} 2>&1 || true`, source };
+ }
+ const source = `docker exec ${MAIL_CONTAINER} tail -n ${lines} /var/log/supervisor/${unit}.log`;
+ return { command: `timeout 10 ${source} 2>/dev/null || true`, source };
}
- return `timeout 10 journalctl -u ${sq(unit)} -n ${lines} --no-pager 2>&1 || true`;
+ return {
+ command: `timeout 10 journalctl -u ${sq(unit)} -n ${lines} --no-pager 2>&1 || true`,
+ source: `journalctl -u ${unit} -n ${lines}`,
+ };
}
function firstOutputLine(text: string): string {
@@ -543,6 +721,9 @@ function mapSupervisorState(s: string): MailUnitStatus {
case "STOPPED":
case "EXITED":
return "inactive";
+ // Both are `failed` for every consumer that grades this — the deploy gate, the
+ // serving check, the Health banner. What differs is whether anything is still
+ // trying, and that rides on `subState`: FATAL means supervisord has given up.
case "FATAL":
case "BACKOFF":
return "failed";
diff --git a/apps/api/src/modules/mail/mail-health.service.ts b/apps/api/src/modules/mail/mail-health.service.ts
index 1bb5ed6b8..ea64b883d 100644
--- a/apps/api/src/modules/mail/mail-health.service.ts
+++ b/apps/api/src/modules/mail/mail-health.service.ts
@@ -24,6 +24,28 @@ import {
type MailUnitStatus,
} from "./mail-engine";
+/**
+ * Does a mail server stop being a mail server without this daemon?
+ *
+ * - `required` — Postfix / Dovecot / PostgreSQL. Nothing is delivered without them.
+ * - `advisory` — the filtering and hardening daemons. Their absence degrades the
+ * box; it does not stop it being one.
+ *
+ * ONE definition, read by both the install gate (`stepDeployEngine`) and the Health
+ * banner, so the two cannot disagree about which daemon is load-bearing.
+ */
+/**
+ * How much a component's state says about whether this box is a working mail server.
+ *
+ * required — down means mail has stopped. Red.
+ * advisory — mail still flows, with reduced protection. Amber.
+ * informational — reported for completeness; NOTHING depends on it, so its state must
+ * never colour the banner (GH-240). Distinct from "advisory" on
+ * purpose: amber for a daemon no part of the stack consults teaches
+ * operators to ignore the banner, which is worse than not showing it.
+ */
+export type MailComponentSeverity = "required" | "advisory" | "informational";
+
/** Components we check. `unit` is the supervisord program name in the engine image. */
export interface MailComponentDef {
/** Stable id - used by the frontend as a React key + for icon lookup. */
@@ -32,6 +54,7 @@ export interface MailComponentDef {
description: string;
/** supervisord program name inside the engine (or the pg sidecar for postgresql). */
unit: string;
+ severity: MailComponentSeverity;
}
export const MAIL_COMPONENTS: MailComponentDef[] = [
@@ -40,30 +63,41 @@ export const MAIL_COMPONENTS: MailComponentDef[] = [
label: "Postfix",
description: "SMTP server (receives + sends mail)",
unit: "postfix",
+ severity: "required",
},
{
key: "dovecot",
label: "Dovecot",
description: "IMAP / POP3 / LMTP (inbox access + delivery)",
unit: "dovecot",
+ severity: "required",
},
{
key: "amavis",
label: "Amavis",
description: "Filtering pipeline (spam + virus scan)",
unit: "amavis",
+ severity: "advisory",
},
{
key: "clamav",
label: "ClamAV",
description: "Anti-virus engine",
unit: "clamav-daemon",
+ // Advisory to the INSTALL gate (a large signature load can still be warming
+ // up), never "harmless": amavis has one scanner and no backup, so with clamd
+ // gone mail is DELIVERED UNSCANNED — it does not defer (GH-565; failing closed
+ // is an opt-in documented in the engine's amavisd.conf). The banner's copy
+ // says exactly that, because "advisory" here means "delivery still works",
+ // not "nothing is wrong".
+ severity: "advisory",
},
{
key: "freshclam",
label: "ClamAV updates",
description: "Auto-updates virus signatures",
unit: "clamav-freshclam",
+ severity: "advisory",
},
{
key: "spamassassin",
@@ -73,29 +107,38 @@ export const MAIL_COMPONENTS: MailComponentDef[] = [
// (>=4.0) ships its systemd unit as `spamd.service`, so checking
// `spamassassin` always read LoadState=not-found ("Missing") on a perfectly
// healthy legacy host — and the engine image's supervisord program is named
- // to match. Note Amavis scores spam via its own in-process
- // Mail::SpamAssassin integration regardless of this daemon's state; spamd is
- // the standalone network-facing scorer other tools (spamc) talk to, and this
- // check is about ITS state specifically.
+ // to match.
unit: "spamd",
+ // INFORMATIONAL, not advisory (GH-240 FP1). Amavis scores spam through its own
+ // in-process Mail::SpamAssassin integration, which is what actually tags mail on
+ // this stack; `spamd` is the separate network-facing scorer that `spamc` and other
+ // external tools talk to, and NOTHING here speaks to it. So its state carries no
+ // information about whether spam filtering works, and reporting it as a degradation
+ // was a false positive on every host that simply does not run it — the issue's
+ // "SpamAssassin daemon reported not installed although SA scores in-process".
+ // Still listed, because an operator who deliberately runs spamd wants to see it.
+ severity: "informational",
},
{
key: "iredapd",
label: "iRedAPD",
description: "Policy daemon (greylisting, throttling)",
unit: "iredapd",
+ severity: "advisory",
},
{
key: "fail2ban",
label: "fail2ban",
description: "Brute-force protection",
unit: "fail2ban",
+ severity: "advisory",
},
{
key: "postgresql",
label: "PostgreSQL",
description: "Mail account + alias store",
unit: "postgresql",
+ severity: "required",
},
];
@@ -107,8 +150,13 @@ export interface MailComponentHealth {
label: string;
description: string;
unit: string;
+ severity: MailComponentSeverity;
status: MailComponentStatus;
- /** Free-form sub-state when running — systemd's, or supervisord's state word. */
+ /**
+ * The supervisor's state word (systemd's SubState, or supervisord's, lower-cased).
+ * The only thing separating supervisord FATAL — given up — from BACKOFF, both of
+ * which arrive as `status: "failed"`.
+ */
subState?: string;
/** ISO timestamp the unit entered its current state, if known (systemd only). */
activeSince?: string;
@@ -179,9 +227,23 @@ function describe(comp: MailComponentDef) {
label: comp.label,
description: comp.description,
unit: comp.unit,
+ severity: comp.severity,
};
}
+/**
+ * Does this component gate "is this box a mail server?"
+ *
+ * THE definition — `stepDeployEngine`'s install gate and the Health banner both read
+ * it, so a component can never be fatal to one and cosmetic to the other.
+ * Deliberately NOT merged with `SERVING_COMPONENTS`: that answers a different
+ * question ("is mail moving right now?", postfix + dovecot only) and PostgreSQL is
+ * required-but-not-serving. Two questions, two lists.
+ */
+export function requiresMailComponent(key: string): boolean {
+ return MAIL_COMPONENTS.some((c) => c.key === key && c.severity === "required");
+}
+
async function probeUnit(
exec: CommandExecutor,
flavor: MailEngineFlavor,
diff --git a/apps/api/src/modules/mail/mail.controller.ts b/apps/api/src/modules/mail/mail.controller.ts
index 6f00447d1..3e8c0180e 100644
--- a/apps/api/src/modules/mail/mail.controller.ts
+++ b/apps/api/src/modules/mail/mail.controller.ts
@@ -33,9 +33,10 @@ import { buildMailBackupPayload } from "./admin/backup-plan";
// goes through the same cron validation + schedule registration as a project's.
import { syncPolicySchedule, validateCronExpression } from "../backups/triggers/cron";
import { streamSSE } from "../../lib/sse";
+import { requestTag } from "../../middleware/error-handler";
import { invalidatePlatformTransport } from "../../lib/mail";
import { env } from "../../config";
-import { safeErrorMessage, DEFAULT_RETAIN_COUNT } from "@repo/core";
+import { safeErrorMessage, DEFAULT_RETAIN_COUNT, mailHostname } from "@repo/core";
import { sshManager } from "../../lib/ssh-manager";
import { repos } from "@repo/db";
import { getRequestContext, type RequestContext } from "../../lib/request-context";
@@ -63,7 +64,7 @@ import {
} from "./mail.service";
import { checkMailDelivery } from "./mail-delivery.service";
import { checkMailHealth, mailIsServing, MAIL_COMPONENTS } from "./mail-health.service";
-import { resolveMailEngine } from "./mail-engine";
+import { resolveMailEngine, resolveMailFlavor } from "./mail-engine";
import { updatePostmasterPassword } from "./mail-credentials.service";
import { reserveMailSetup } from "./mail-setup-lease";
import { preflightMailSetup } from "./mail-setup-preflight";
@@ -153,9 +154,9 @@ function statusFromState(
const credentials = state.domain
? {
username: `postmaster@${state.domain}`,
- smtpHost: `mail.${state.domain}`,
+ smtpHost: mailHostname(state.domain),
smtpPort: 587,
- imapHost: `mail.${state.domain}`,
+ imapHost: mailHostname(state.domain),
imapPort: 993,
}
: undefined;
@@ -210,7 +211,7 @@ function buildPtrPayload(
return {
ipv4,
ipv6,
- target: `mail.${state.domain}`,
+ target: mailHostname(state.domain),
resumeStep,
};
}
@@ -569,7 +570,7 @@ async function augmentStateWithHostRecords(
const { ipv4, ipv6 } = await resolveHostIPs(server.sshHost);
if (!ipv4) return state;
- const mailDomain = `mail.${state.domain}`;
+ const mailDomain = mailHostname(state.domain);
const augmented: Record = {
a: { type: "A", name: mailDomain, value: ipv4, required: true },
...(ipv6 && {
@@ -1105,10 +1106,10 @@ export async function startSetup(c: Context) {
data: JSON.stringify({
success: true,
domain,
- mailDomain: `mail.${domain}`,
+ mailDomain: mailHostname(domain),
finishedAt: Date.parse(finishedAt),
- webmailUrl: `https://mail.${domain}/mail`,
- adminUrl: `https://mail.${domain}/iredadmin`,
+ webmailUrl: `https://${mailHostname(domain)}/mail`,
+ adminUrl: `https://${mailHostname(domain)}/iredadmin`,
}),
});
} catch (err) {
@@ -1413,7 +1414,13 @@ export async function saveMailBackupPolicy(c: Context) {
const messageData = body.messageData === true;
const keys = body.keys !== false; // default: include keys/secrets
- const payload = buildMailBackupPayload(mailRow.domain, { messageData, keys });
+ // The produce/restore shell is baked HERE and executed later by the generic
+ // custom_command producer, which has no mail knowledge — so the topology has to be
+ // resolved at build time. A containerized engine keeps Postgres in a sidecar and the
+ // `vmail` user only inside the engine, and the old plan issued `sudo -u postgres` and
+ // `chown vmail` on the host, where neither exists (GH-563).
+ const flavor = await resolveMailFlavor(serverId);
+ const payload = buildMailBackupPayload(mailRow.domain, { messageData, keys }, flavor);
const cronExpression =
typeof body.cronExpression === "string" && body.cronExpression.trim()
@@ -1616,6 +1623,9 @@ export async function getHealth(c: Context) {
return c.json({ serverId, components, definitions: MAIL_COMPONENTS, delivery });
} catch (err) {
const message = err instanceof Error ? err.message : "Health check failed";
+ // Same reason as the mail-admin funnel: this 500 is answered here, so `app.onError`
+ // never logs it and every Health-tab failure was invisible in the API log.
+ console.error(`[MAIL HEALTH ERROR] ${requestTag(c)}`, err);
return c.json({ error: message }, 500);
}
}
diff --git a/apps/api/src/modules/mail/mail.routes.ts b/apps/api/src/modules/mail/mail.routes.ts
index 72bf9e224..23aa71745 100644
--- a/apps/api/src/modules/mail/mail.routes.ts
+++ b/apps/api/src/modules/mail/mail.routes.ts
@@ -9,6 +9,7 @@ import { secureRouter } from "../../lib/secure-router";
import * as mail from "./mail.controller";
import * as admin from "./admin/admin.controller";
import * as webmail from "./webmail/webmail.controller";
+import * as inbound from "./inbound/inbound.controller";
const r = secureRouter(new Hono(), {
module: "mail",
@@ -243,5 +244,39 @@ r.post(
webmail.startExternalDeployAsProjectHandler,
);
+/* ── Inbound rules (mail arrives → notification channel) ──────────── */
+// `:serverId` is a PATH param on every one of these, and that is deliberate:
+// `mail_server` is a CONDITIONAL_SINGLETON, so a route that took the id from the body
+// would degrade to resourceId "*" — a pure role×type check that names no server and
+// therefore establishes no tenant boundary.
+r.get(
+ "/admin/:serverId/inbound-rules",
+ { tag: "mail_server:read" },
+ inbound.listRulesHandler,
+);
+r.post(
+ "/admin/:serverId/inbound-rules",
+ { tag: "mail_server:write" },
+ inbound.createRuleHandler,
+);
+r.patch(
+ "/admin/:serverId/inbound-rules/:ruleId",
+ { tag: "mail_server:write" },
+ inbound.updateRuleHandler,
+);
+r.delete(
+ "/admin/:serverId/inbound-rules/:ruleId",
+ { tag: "mail_server:write" },
+ inbound.deleteRuleHandler,
+);
+// Dry run — reads and reports what WOULD notify, dispatching and deleting nothing.
+// Tagged `write` because the boot scanner treats a POST on a read-tagged route as a
+// CRITICAL error and exits the process.
+r.post(
+ "/admin/:serverId/inbound-rules/test",
+ { tag: "mail_server:write" },
+ inbound.testRulesHandler,
+);
+
export const mailRoutes = r.hono;
diff --git a/apps/api/src/modules/mail/mail.service.ts b/apps/api/src/modules/mail/mail.service.ts
index 64ab94225..0322dfb7c 100644
--- a/apps/api/src/modules/mail/mail.service.ts
+++ b/apps/api/src/modules/mail/mail.service.ts
@@ -14,8 +14,8 @@
import { randomBytes } from "node:crypto";
import type { CommandExecutor, SystemLogCallback, SystemLog } from "@repo/adapters";
-import { checkMailHealth } from "./mail-health.service";
-import { safeErrorMessage } from "@repo/core";
+import { checkMailHealth, requiresMailComponent } from "./mail-health.service";
+import { safeErrorMessage, mailHostname } from "@repo/core";
import {
installDocker,
installContainerEdge,
@@ -28,6 +28,7 @@ import {
MAIL_PORTS,
opScript,
resolveEnvironment,
+ rootOrDegrade,
} from "@repo/adapters";
import {
HOST_AMAVIS_CONF_CANDIDATES,
@@ -495,10 +496,20 @@ export async function stepOpenMailFirewall(
// collapsing the whole step. No check-then-insert guard any more: we only get here for
// ufw and firewalld, both of which are idempotent about a rule they already hold — the
// duplicate-stacking that guard existed for was a raw-iptables property.
+ // Elevated: `ufw`/`firewall-cmd` are root-only, and this ran on the raw executor — so on
+ // a box we log into as a non-root sudo user every rule failed and the step reported the
+ // ports as rejected by the firewall rather than as never attempted. The profile above
+ // only chooses the SYNTAX; it never decided who runs it.
+ const fw = await rootOrDegrade(exec, {
+ purpose: "Opening the inbound mail ports",
+ consequence: "The ports may stay closed, so inbound mail is rejected at the edge of the host.",
+ report: (message) => log(stepId, "warn", message),
+ });
+
const failed: string[] = [];
for (const { port, script } of rules) {
try {
- await exec.exec(script);
+ await fw.exec(script);
} catch (err) {
// A dropped connection is not a firewall verdict, and retrying it seven more times
// just delays the real error.
@@ -632,9 +643,11 @@ export async function stepDeployEngine(
const health = await checkMailHealth(exec).catch(() => null);
if (health) {
// Only the mail-path daemons gate the deploy; ClamAV/freshclam can still be
- // warming up (large signature load) without blocking a working mail server.
- const CRITICAL = new Set(["postfix", "dovecot", "postgresql"]);
- const down = health.filter((c) => CRITICAL.has(c.key) && c.status !== "active");
+ // warming up (large signature load) without blocking a working mail server. The
+ // marker lives on the catalog (mail-health.service) so the Health tab grades the
+ // same daemons the same way — this was a private Set here, and the banner's own
+ // idea of "down" had drifted into painting an advisory daemon red.
+ const down = health.filter((c) => requiresMailComponent(c.key) && c.status !== "active");
if (down.length > 0) {
return {
stepId,
@@ -690,7 +703,7 @@ export async function stepDkimKeys(
domain: string,
log: StepLogger,
): Promise {
- const mailDomain = `mail.${domain}`;
+ const mailDomain = mailHostname(domain);
log(6, "info", "Locating amavis binary...");
let amavis: Awaited>;
@@ -993,7 +1006,7 @@ export async function stepRequestSSL(
target: MailSslTarget,
): Promise {
const stepId = 7;
- const mailDomain = `mail.${domain}`;
+ const mailDomain = mailHostname(domain);
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(mailDomain)) {
return { stepId, success: false, message: `Invalid mail domain: ${mailDomain}` };
}
@@ -1060,7 +1073,7 @@ export async function stepConfigureSSL(
log: StepLogger,
): Promise {
const stepId = 8;
- const mailDomain = `mail.${domain}`;
+ const mailDomain = mailHostname(domain);
let flavor: MailEngineFlavor;
try {
diff --git a/apps/api/src/modules/mail/webmail/webmail-branding-head.test.ts b/apps/api/src/modules/mail/webmail/webmail-branding-head.test.ts
new file mode 100644
index 000000000..6750ff53b
--- /dev/null
+++ b/apps/api/src/modules/mail/webmail/webmail-branding-head.test.ts
@@ -0,0 +1,183 @@
+/**
+ * Branding must reach the webmail document .
+ *
+ * GH-568: `siteTitle` and `siteDescription` were accepted by
+ * `PATCH /admin/branding`, persisted, and echoed by `/branding.json` - yet the
+ * served page still said "OpenShip Mail", because the SPA is prebuilt and both
+ * values are frozen into `client/build/client/index.html`. The fix substitutes
+ * them server-side (apps/email/server/src/lib/index-html.ts) rather than in the
+ * browser, because the issue also asks for correct OpenGraph tags and
+ * link-preview crawlers do not execute JavaScript.
+ *
+ * The failure mode that matters is a SILENT no-op - a substitution that stops
+ * matching and reports success - so these tests pin the two things that make it
+ * silent: the tag patterns, and the `missing` report that surfaces a template
+ * whose changed shape.
+ *
+ * Why this test lives in apps/api rather than next to the code: the root
+ * package.json runs `turbo run test --filter=!@repo/email`, and apps/email
+ * declares no test script at all, so anything under apps/email/**\/test is
+ * never executed by CI. Its siblings webmail-catalog-contract.test.ts and
+ * webmail-client-origin.test.ts are here for the same reason. The import below
+ * reaches across app boundaries deliberately: `injectBranding` is pure - no
+ * env, no filesystem, no module state - specifically so it can be imported
+ * from a runner that CI actually runs.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { injectBranding, escapeHtml } from "../../../../../email/server/src/lib/index-html";
+
+/** The emitted , trimmed to the tags under test. */
+const HEAD = [
+ "",
+ ' ',
+ ' ',
+ "OpenShip Mail ",
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ "",
+].join("");
+
+function clientFile(...parts: string[]): string {
+ let dir = dirname(fileURLToPath(import.meta.url));
+ for (let i = 0; i < 10; i++) {
+ const candidate = join(dir, "apps", "email", "client");
+ try {
+ readFileSync(join(candidate, "react-router.config.ts"));
+ return readFileSync(join(candidate, ...parts), "utf-8");
+ } catch {
+ /* keep walking */
+ }
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ throw new Error("apps/email/client not found - fix the marker walk in this test");
+}
+
+const BRANDED = { siteTitle: "Contribute Club eMail", siteDescription: "Mail Server for CC" };
+
+describe("webmail branding head (GH-568)", () => {
+ it("substitutes every branded slot", () => {
+ const { html, missing } = injectBranding(HEAD, BRANDED);
+
+ expect(missing).toEqual([]);
+ expect(html).toContain("Contribute Club eMail ");
+ expect(html).toContain(' ');
+ expect(html).toContain(' ');
+ expect(html).toContain(' ');
+ // The default must be gone, not merely accompanied.
+ expect(html).not.toContain("OpenShip Mail");
+ expect(html).not.toContain("Your self-hosted mailbox.");
+ });
+
+ it("leaves tags it does not own alone", () => {
+ const { html } = injectBranding(HEAD, BRANDED);
+ expect(html).toContain(' ');
+ expect(html).toContain(' ');
+ expect(html).toContain(' ');
+ });
+
+ it("escapes operator input instead of injecting markup", () => {
+ // The PATCH token bounds who can set this, not what it may do. A title is
+ // interpolated into both element text and an attribute value, so a `<` or a
+ // `"` must not be able to break out of either.
+ const { html } = injectBranding(HEAD, {
+ siteTitle: 'Acme "Mail" ',
+ siteDescription: "a & b",
+ });
+
+ expect(html).not.toContain("` in an operator string ends the block early.
+ const { html } = injectBranding(HEAD, {
+ siteTitle: "Acme ",
+ siteDescription: "d",
+ });
+ expect(html).not.toContain(" /g)).toHaveLength(1);
+ const json = html.match(
+ / ");
+ });
+
+ it("reports a template with no instead of dropping the payload", () => {
+ const { missing } = injectBranding("t ", BRANDED);
+ expect(missing).toContain("embed");
+ });
+
+ it("root.tsx builds its meta from the document, never from build-time constants", () => {
+ // The precise regression: `{ title: siteConfig.title }` in the meta export
+ // is what overwrote the server's value. og:image may stay - it is a
+ // relative path, not branding.
+ const root = clientFile("app", "root.tsx");
+ const meta = root.slice(root.indexOf("export const meta"), root.indexOf("export function Layout"));
+
+ expect(meta).toContain("runtimeBranding()");
+ expect(meta).not.toContain("siteConfig.title");
+ expect(meta).not.toContain("siteConfig.description");
+ });
+});
diff --git a/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts b/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts
index c65bdc84d..68c726285 100644
--- a/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts
+++ b/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts
@@ -64,6 +64,25 @@ describe("webmail catalog contract", () => {
}
});
+ it("keeps both secrets un-inlined, so a missing one can be minted later", () => {
+ // `ensureGeneratedAppSecrets` refuses to mint a key the template substitutes into
+ // some other string, because those copies were written once and would contradict a
+ // new value. Inlining either of these would silently disable the backfill that
+ // stops webmail deploying without a SESSION_ENCRYPTION_KEY (#566) — and the image
+ // treats that as fatal.
+ const inlined = [
+ ...(template?.services ?? []).flatMap((s) => Object.values(s.environment ?? {})),
+ ...(template?.files ?? []).map((f) => f.content),
+ ...(template?.services ?? []).map((s) => s.build?.dockerfile ?? ""),
+ ].join("\n");
+ for (const key of ["SESSION_ENCRYPTION_KEY", "BRANDING_ADMIN_TOKEN"]) {
+ // Tolerant of inner whitespace, exactly like the substitution itself.
+ expect(inlined, `${key} must not be inlined`).not.toMatch(
+ new RegExp(`\\{\\{\\s*config:${key}\\s*\\}\\}`),
+ );
+ }
+ });
+
it("exposes one routable HTTP endpoint", () => {
// `startWebmailDeploy` reads endpoint[0] to size its route instead of pinning a
// port number — an app with no endpoint would deploy unreachable.
diff --git a/apps/api/src/modules/mail/webmail/webmail-client-origin.test.ts b/apps/api/src/modules/mail/webmail/webmail-client-origin.test.ts
new file mode 100644
index 000000000..06b262b34
--- /dev/null
+++ b/apps/api/src/modules/mail/webmail/webmail-client-origin.test.ts
@@ -0,0 +1,118 @@
+/**
+ * The webmail client must never learn its own origin at build time.
+ *
+ * GH-567: every route guard in the Zero client built its redirect by
+ * interpolating `import.meta.env.VITE_PUBLIC_APP_URL` -
+ *
+ * if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
+ *
+ * - so the published image froze whatever the BUILD host defined into the
+ * bundle. `ghcr.io/oblien/openship-webmail:latest` shipped
+ * `http://localhost:3000/login`; a build with the var unset ships the literal
+ * string `"undefined/login"`. On any real hostname a session expiry threw the
+ * user clean off their own domain, and no runtime env could correct it - the
+ * value was a constant in minified JavaScript.
+ *
+ * Two bans, both narrow and both load-bearing:
+ *
+ * VITE_PUBLIC_APP_URL - the app's own origin is not a build-time fact. It is
+ * `window.location.origin`, always: the Hono server serves the SPA and the
+ * API on one port (client/lib/backend-url.ts documents that invariant).
+ *
+ * Response.redirect - per the Fetch spec this REQUIRES an absolute URL, so
+ * reaching for it in a route loader is what forces an origin into the
+ * picture at all. `redirect()` from react-router takes a relative path and
+ * the router resolves it against the live origin, basename included.
+ *
+ * Why this test lives in apps/api rather than apps/email: `turbo run test`
+ * only runs packages that declare a test script, and apps/email declares none
+ * - so a test placed there would never run on a pull request. Its sibling
+ * webmail-catalog-contract.test.ts already asserts cross-boundary webmail
+ * facts from here for the same reason. The release build carries the matching
+ * OUTPUT check (apps/email/scripts/build-release.ts scans the built bundle for
+ * baked dev origins, so neither the image nor the tarball can publish one);
+ * this is the copy that fails on the PR that introduces it.
+ */
+import { describe, it, expect } from "vitest";
+import { readdirSync, readFileSync, existsSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/**
+ * Walk up from this file to the repo root - the directory that contains the
+ * webmail client. Resolved by marker rather than by a fixed `../../../..`
+ * count so moving this test doesn't silently turn it into a no-op.
+ */
+function findClientRoot(): string {
+ let dir = dirname(fileURLToPath(import.meta.url));
+ for (let i = 0; i < 10; i++) {
+ const candidate = join(dir, "apps", "email", "client");
+ if (existsSync(join(candidate, "react-router.config.ts"))) return candidate;
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ throw new Error("apps/email/client not found - fix the marker walk in this test");
+}
+
+const CLIENT = findClientRoot();
+
+// The hand-written source. Everything else under client/ is generated or
+// vendored: build/ (Vite output), paraglide/ (i18n codegen), .react-router/
+// (route typegen), node_modules/.
+const SOURCE_DIRS = ["app", "components", "lib", "hooks", "providers", "store", "utils"];
+const SOURCE_EXTENSIONS = [".ts", ".tsx"];
+
+function sourceFiles(): string[] {
+ const out: string[] = [];
+ const walk = (dir: string) => {
+ if (!existsSync(dir)) return;
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === "node_modules") continue;
+ walk(path);
+ } else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
+ out.push(path);
+ }
+ }
+ };
+ for (const dir of SOURCE_DIRS) walk(join(CLIENT, dir));
+ return out;
+}
+
+const FILES = sourceFiles().map((path) => ({
+ path: relative(CLIENT, path),
+ text: readFileSync(path, "utf-8"),
+}));
+
+describe("webmail client origin (GH-567)", () => {
+ it("has source to check", () => {
+ // A broken walk would make every assertion below vacuously pass.
+ expect(FILES.length).toBeGreaterThan(100);
+ });
+
+ it("never reads its own origin from the build env", () => {
+ const offenders = FILES.filter((f) => f.text.includes("VITE_PUBLIC_APP_URL")).map(
+ (f) => f.path,
+ );
+ expect(
+ offenders,
+ "The app's origin is window.location.origin at runtime, never a build-time env " +
+ "var (see client/lib/backend-url.ts). Use a relative redirect instead.",
+ ).toEqual([]);
+ });
+
+ it("never uses Response.redirect, which forces an absolute URL", () => {
+ const offenders = FILES.filter((f) => /\bResponse\.redirect\s*\(/.test(f.text)).map(
+ (f) => f.path,
+ );
+ expect(
+ offenders,
+ "Response.redirect() requires an absolute URL. Use react-router's " +
+ "`replace('/path')` (auth guards and index bounces, so the bounced URL does " +
+ "not linger in history) or `redirect('/path')` - both resolve relative to the " +
+ "live origin and basename.",
+ ).toEqual([]);
+ });
+});
diff --git a/apps/api/src/modules/mail/webmail/webmail-install.service.ts b/apps/api/src/modules/mail/webmail/webmail-install.service.ts
index 6ee685787..568a0da80 100644
--- a/apps/api/src/modules/mail/webmail/webmail-install.service.ts
+++ b/apps/api/src/modules/mail/webmail/webmail-install.service.ts
@@ -26,13 +26,20 @@
* so the operator cannot repoint it and we front it for them.
*/
-import { AppError, getAppEndpoints, safeErrorMessage, type AppTemplate } from "@repo/core";
+import {
+ AppError,
+ getAppEndpoints,
+ safeErrorMessage,
+ type AppTemplate,
+ type ComposeAdvanced,
+ type OpenshipReadiness, mailHostname } from "@repo/core";
import { repos, type Domain, type Project } from "@repo/db";
import { assertResourceInOrg } from "../../../lib/controller-helpers";
-import { pickCanonicalDomainRow } from "../../../lib/public-endpoints";
+import { pickCanonicalDomainRow, resolveServicePublicEndpoints } from "../../../lib/public-endpoints";
import type { RequestContext } from "../../../lib/request-context";
import { sshManager } from "../../../lib/ssh-manager";
import {
+ ensureGeneratedAppSecrets,
installApp,
planInstallRouting,
serviceRoutingPatch,
@@ -205,10 +212,25 @@ interface WebmailInstallPlan {
settings: AppSettingChange[];
deployTarget: "server" | "cloud";
serverId?: string;
- /** An already-deployed project to redeploy instead of installing fresh. */
+ /**
+ * An already-LINKED project to redeploy instead of installing fresh. Not
+ * necessarily a deployed one: `beforeDeploy` stamps the mail server's FK before the
+ * build is queued, so a retry after a failed first deploy arrives here too — which
+ * is why the generated-secret backfill cannot live in the else branch.
+ */
reuse?: Project;
/** Runs after the project exists and BEFORE the deploy is queued. */
beforeDeploy?: (projectId: string) => Promise;
+ /**
+ * Apply `routes` only AFTER `beforeDeploy` has run.
+ *
+ * For the mail server's own hostname the order is the authorization: the claim is
+ * allowed on the strength of `mail_servers.webmail_project_id` pointing at this
+ * project, and `beforeDeploy` is what stamps that link (#566). Routing first would be
+ * refused as a foreign hostname — the mail row belongs to the mail install's
+ * certificate renewal, not to any project.
+ */
+ routeAfterLink?: boolean;
}
async function runWebmailInstall(
@@ -220,16 +242,18 @@ async function runWebmailInstall(
if (plan.reuse) {
projectId = plan.reuse.id;
- await reapplyRouting(ctx, template, plan.reuse, plan.routes);
+ if (!plan.routeAfterLink) await reapplyRouting(ctx, template, plan.reuse, plan.routes);
} else {
// The generic installer owns everything about the app itself: project row,
- // service rows, declared volumes, and the generated SESSION_ENCRYPTION_KEY /
- // BRANDING_ADMIN_TOKEN. A same-named draft from a failed attempt is adopted
- // here rather than duplicated, which is why the name is derived, not typed.
+ // service rows, declared volumes, and the FIRST write of the generated
+ // SESSION_ENCRYPTION_KEY / BRANDING_ADMIN_TOKEN — first, not only: adoption skips
+ // that write for rows it did not create, so the guarantee comes from
+ // `ensureGeneratedAppSecrets` below. A same-named draft from a failed attempt is
+ // adopted here rather than duplicated, which is why the name is derived, not typed.
const installed = await installApp(ctx, {
templateId: WEBMAIL_TEMPLATE_ID,
name: plan.name,
- routes: plan.routes,
+ routes: plan.routeAfterLink ? [] : plan.routes,
});
if (installed.kind !== "template") {
throw new AppError("The webmail app isn't installable on this instance.", 409);
@@ -244,10 +268,31 @@ async function runWebmailInstall(
await updateAppProjectSettings(ctx, projectId, plan.settings);
}
+ // Both branches, one call. The image treats SESSION_ENCRYPTION_KEY as fatal, so a
+ // project that reached the container without one crash-loops forever (issue #566) —
+ // and the reuse branch, which every retry takes, installs nothing. Idempotent: a
+ // stored key is reused, never rotated. Deliberately after the settings write, which
+ // is what proves this project really is the webmail app before we write its env.
+ await ensureGeneratedAppSecrets(projectId, template);
+
+ // A crash loop must not report success. The restart-loop watch already exists
+ // (#335) but is OFF by default and webmail never asked for it, so a container that
+ // exited on a missing SESSION_ENCRYPTION_KEY and restarted ten times still finished
+ // the deploy as "deployed and running" (#566).
+ await enableRestartLoopWatch(ctx, projectId, template);
+
// Before the deploy, never after: the deploy's success hook resolves the mail
// server FROM the project, and a build can finish before a later write lands.
await plan.beforeDeploy?.(projectId);
+ // The mail host's route, now that the link authorizing it exists. Same plan builder
+ // and same per-service write as a first install — the only difference is that it could
+ // not have been accepted a few lines earlier.
+ if (plan.routeAfterLink) {
+ const project = await repos.project.findById(projectId);
+ if (project) await reapplyRouting(ctx, template, project, plan.routes);
+ }
+
const dep = await requestBuildAccess(ctx, {
projectId,
serviceDeploymentMode: "services",
@@ -258,6 +303,46 @@ async function runWebmailInstall(
return { projectId, deploymentId: dep.deployment_id };
}
+/**
+ * Webmail's readiness gate: watch for a restart loop, and let it veto the deploy.
+ *
+ * No TCP probe. That asks a different question, and an enabled one adds up to 45s to
+ * the critical path; what distinguishes "it started" from "it kept starting" is the
+ * restart count. `onFailure: "fail"` because the deploy status vocabulary has no
+ * "started but unhealthy" — for a container that is bouncing, `failed` is the only
+ * honest verdict, and a warn would leave the deploy green.
+ */
+const WEBMAIL_READINESS: OpenshipReadiness = { stabilization: true, onFailure: "fail" };
+
+/**
+ * Opt the webmail service into the restart-loop watch, once.
+ *
+ * Best-effort by design: a readiness row we could not write must not abort a deploy
+ * that would otherwise queue. Losing the watch costs honesty on a failure; throwing
+ * here costs the deploy. An `advanced.readiness` that already exists is left alone —
+ * that is an operator's explicit choice about their own project.
+ *
+ * Scoped to the endpoint service rather than every row: the gate is per-service, and a
+ * future multi-service webmail should not have a sidecar's restarts veto the deploy.
+ */
+async function enableRestartLoopWatch(
+ ctx: RequestContext,
+ projectId: string,
+ template: AppTemplate,
+): Promise {
+ try {
+ const name = webmailEndpoint(template).service;
+ const row = (await repos.service.listByProject(projectId)).find((r) => r.name === name);
+ if (!row) return;
+ if ((row.advanced as ComposeAdvanced | null)?.readiness) return;
+ await updateService(ctx, projectId, row.id, { advanced: { readiness: WEBMAIL_READINESS } });
+ } catch (err) {
+ console.warn(
+ `[webmail] could not enable the restart-loop watch on ${projectId}: ${safeErrorMessage(err)}`,
+ );
+ }
+}
+
/**
* Re-apply the chosen routing to a project that already exists — the branch
* `installApp` can't take, because its adoption only matches never-deployed
@@ -321,7 +406,7 @@ export async function startWebmailDeploy(
);
}
- const mailHost = `mail.${installDomain}`;
+ const mailHost = mailHostname(installDomain);
const isOwnMailSubdomain = input.hostname === mailHost;
// `mail.`'s A record is pinned to the mail box — it carries IMAP,
@@ -396,6 +481,9 @@ export async function startWebmailDeploy(
serverId: input.target.kind === "self" ? input.target.serverId : undefined,
// The legacy row is gone by now, so this is a fresh install, not a redeploy.
reuse: legacy ? undefined : (linked ?? undefined),
+ // `mail.` is routable only by the mail server's LINKED webmail, and the
+ // link is stamped in `beforeDeploy` — so this one route has to wait for it (#566).
+ routeAfterLink: isOwnMailSubdomain && !useProxyVariant,
beforeDeploy: (projectId) =>
repos.mailServer.setWebmailProject(input.mailServerId, projectId),
});
@@ -530,7 +618,14 @@ export async function resolveWebmailSummary(
// No route of its own + running on the cloud = the mail VPS fronts it (see
// the proxy variant above). Derived, so nothing has to be stored.
const proxied = rows !== null && !routed && !!project.cloudWorkspaceId;
- const hostname = routed?.hostname ?? (proxied ? `mail.${mailServer.domain}` : "");
+ // A webmail on the mail server's OWN hostname has no domain row by design — that row
+ // belongs to the mail install's certificate renewal and must stay project-less (#566,
+ // see lib/mail-host-claim). Its address therefore has to come from the SERVICE's routing
+ // instead, or a webmail that is deployed and serving reads as "not installed" and the
+ // card offers to deploy it again.
+ const hostname =
+ routed?.hostname ??
+ (proxied ? mailHostname(mailServer.domain) : rows === null ? "" : await routedServiceHostname(project));
return {
installed: await isLiveDeploymentReady(project.activeDeploymentId),
@@ -546,6 +641,26 @@ export async function resolveWebmailSummary(
};
}
+/**
+ * The hostname this project's services actually route, read from the service rows.
+ *
+ * Only consulted when the project has no domain row at all. Restricted to `custom`
+ * endpoints: a free `*.opsh.io` route always has a row, so a rowless free endpoint would
+ * mean a routing state we could not have written, and guessing one is how a stale address
+ * ends up on the card.
+ */
+async function routedServiceHostname(project: Pick): Promise {
+ const rows = await repos.service.listByProject(project.id).catch(() => []);
+ for (const row of rows) {
+ const endpoints = resolveServicePublicEndpoints(row, {
+ projectSlug: project.slug ?? project.name,
+ });
+ const custom = endpoints.find((e) => e.domainType === "custom" && e.customDomain);
+ if (custom?.customDomain) return custom.customDomain;
+ }
+ return "";
+}
+
async function isLiveDeploymentReady(deploymentId: string | null): Promise {
if (!deploymentId) return false;
const dep = await repos.deployment.findById(deploymentId).catch(() => null);
@@ -582,7 +697,7 @@ export async function onWebmailDeployed(
const platform = await resolveMailVpsPlatform(mailServer.serverId, project.organizationId);
await platform.routing.registerRoute({
- domain: `mail.${mailServer.domain}`,
+ domain: mailHostname(mailServer.domain),
tls: true,
// We issue this host's cert right below, so the edge must keep a :443
// listener up meanwhile — a routed host with no TLS listener refuses
@@ -592,7 +707,7 @@ export async function onWebmailDeployed(
});
// The mail box already holds certs for IMAP/SMTP; this adds the HTTPS
// one for the webmail UI, through the same Let's Encrypt feature.
- await platform.ssl.provisionCert(`mail.${mailServer.domain}`);
+ await platform.ssl.provisionCert(mailHostname(mailServer.domain));
} catch (err) {
console.warn(
`[webmail] could not front mail. for project ${project.id}: ${safeErrorMessage(err)}`,
@@ -629,7 +744,7 @@ export async function cleanupWebmailInstall(project: Project): Promise []);
if (rows.length > 0 || !project.cloudWorkspaceId) return null;
- const hostname = `mail.${mailServer.domain}`;
+ const hostname = mailHostname(mailServer.domain);
const platform = await resolveMailVpsPlatform(mailServer.serverId, project.organizationId);
await platform.routing.removeRoute(hostname);
return `removed ${hostname} proxy`;
diff --git a/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts b/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts
index 26dfaa1bb..88fbe369b 100644
--- a/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts
+++ b/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts
@@ -61,6 +61,7 @@ const h = vi.hoisted(() => ({
requestBuildAccess: vi.fn(async () => ({ deployment_id: "dep-new" })),
setWebmailProject: vi.fn(async () => {}),
updateService: vi.fn(async () => {}),
+ ensureGeneratedAppSecrets: vi.fn(async () => [] as string[]),
}));
vi.mock("@repo/db", () => ({
@@ -88,6 +89,7 @@ vi.mock("../../apps/catalog-source", () => ({
vi.mock("../../apps/app-install.service", () => ({
installApp: h.installApp,
planInstallRouting: vi.fn(() => new Map()),
+ ensureGeneratedAppSecrets: h.ensureGeneratedAppSecrets,
}));
vi.mock("../../apps/app-settings.service", () => ({
updateAppProjectSettings: h.updateAppProjectSettings,
@@ -161,9 +163,13 @@ describe("legacy webmail replace", () => {
force: true,
wipeVolumes: true,
});
- // Fresh install, NOT a redeploy of the row that just went.
+ // Fresh install, NOT a redeploy of the row that just went: `installApp` owns the
+ // routing, so no service row is patched with a routing plan. The one updateService
+ // call here is the restart-loop watch (#566), which every path arms.
expect(h.installApp).toHaveBeenCalledOnce();
- expect(h.updateService).not.toHaveBeenCalled();
+ for (const call of h.updateService.mock.calls as unknown as unknown[][]) {
+ expect(Object.keys(call[3] as object)).toEqual(["advanced"]);
+ }
// Link stamped before the build is queued, so the deploy hook can resolve it.
expect(h.setWebmailProject).toHaveBeenCalledWith("mail-1", WEBMAIL_PROJECT.id);
expect(res).toEqual({ projectId: WEBMAIL_PROJECT.id, deploymentId: "dep-new" });
diff --git a/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts b/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts
index d6f3d8129..6ba014f49 100644
--- a/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts
+++ b/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts
@@ -44,6 +44,8 @@ const h = vi.hoisted(() => ({
project: null as Record | null,
/** null = the read REJECTED; an array = rows we actually have. */
rows: [] as Array> | null,
+ /** Service rows, consulted only when the project has no domain row at all. */
+ services: [] as Array>,
}));
vi.mock("@repo/db", () => ({
@@ -53,6 +55,7 @@ vi.mock("@repo/db", () => ({
findFirstBySlug: vi.fn(async () => null),
},
mailServer: { setWebmailProject: vi.fn(async () => {}) },
+ service: { listByProject: vi.fn(async () => h.services) },
deployment: { findById: vi.fn(async () => ({ status: "ready" })) },
},
}));
@@ -73,6 +76,7 @@ vi.mock("../../apps/catalog-source", () => ({ getTemplateForOrg: vi.fn(async ()
vi.mock("../../apps/app-install.service", () => ({
installApp: vi.fn(),
planInstallRouting: vi.fn(() => new Map()),
+ ensureGeneratedAppSecrets: vi.fn(async () => []),
}));
vi.mock("../../apps/app-settings.service", () => ({ updateAppProjectSettings: vi.fn() }));
vi.mock("../../deployments/build.service", () => ({ requestBuildAccess: vi.fn() }));
@@ -135,6 +139,8 @@ describe("webmail summary routing", () => {
h.project = SELF_WEBMAIL;
const server = { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id };
+ // No domain row AND no routed service: genuinely addressless.
+ h.services = [];
h.rows = [];
const unrouted = await resolveWebmailSummary("org1", server);
h.rows = null;
@@ -149,6 +155,7 @@ describe("webmail summary routing", () => {
// The proxy variant is cloud-only, so `mail.` must not appear here in
// either direction — the failed-read path and the empty-list path agree.
h.project = SELF_WEBMAIL;
+ h.services = [];
const server = { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id };
expect(await resolveWebmailSummary("org1", server)).toMatchObject({ hostname: "", url: "" });
@@ -156,4 +163,42 @@ describe("webmail summary routing", () => {
h.rows = null;
expect(await resolveWebmailSummary("org1", server)).toMatchObject({ hostname: "", url: "" });
});
+
+ /**
+ * A webmail on the mail server's OWN hostname owns no domain row on purpose — that row
+ * belongs to the mail install's certificate renewal (#566). Without this fallback the
+ * card reads a deployed, serving webmail as "not installed" and offers to deploy it
+ * again.
+ */
+ it("reads the address off the service when the mail host owns no domain row", async () => {
+ h.project = SELF_WEBMAIL;
+ h.rows = [];
+ h.services = [
+ {
+ name: "webmail",
+ exposed: true,
+ publicEndpoints: [{ port: 4080, domainType: "custom", customDomain: "mail.example.com" }],
+ },
+ ];
+
+ expect(await resolveWebmailSummary("org1", { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id }))
+ .toMatchObject({ hostname: "mail.example.com", url: "https://mail.example.com" });
+ });
+
+ it("still refuses to guess when the route read failed", async () => {
+ // routingUnknown must win over the service fallback: an unreadable domain list is
+ // not evidence that the service's hostname is the live one.
+ h.project = SELF_WEBMAIL;
+ h.rows = null;
+ h.services = [
+ {
+ name: "webmail",
+ exposed: true,
+ publicEndpoints: [{ port: 4080, domainType: "custom", customDomain: "mail.example.com" }],
+ },
+ ];
+
+ expect(await resolveWebmailSummary("org1", { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id }))
+ .toMatchObject({ hostname: "", routingUnknown: true });
+ });
});
diff --git a/apps/api/src/modules/mail/webmail/webmail.controller.ts b/apps/api/src/modules/mail/webmail/webmail.controller.ts
index e0f1fe1d2..8d0ccc51e 100644
--- a/apps/api/src/modules/mail/webmail/webmail.controller.ts
+++ b/apps/api/src/modules/mail/webmail/webmail.controller.ts
@@ -14,6 +14,7 @@ import type { Context } from "hono";
import { AppError, isRelayProviderId, RELAY_PROVIDER_IDS } from "@repo/core";
import { env } from "../../../config";
import { getRequestContext } from "../../../lib/request-context";
+import { requestTag } from "../../../middleware/error-handler";
import { listWebmailTargets } from "./webmail.service";
import {
startWebmailDeploy,
@@ -37,6 +38,9 @@ function deployError(c: Context, err: unknown) {
return c.json({ error: err.message, code: err.code }, err.statusCode as 400);
}
const message = err instanceof Error ? err.message : "Failed to start deploy";
+ // Answered here, so `app.onError` never logs it — same blind spot as the other two
+ // self-answered mail 500s.
+ console.error(`[WEBMAIL ERROR] ${requestTag(c)}`, err);
return c.json({ error: message }, 500);
}
diff --git a/apps/api/src/modules/mcp/mcp-audit.ts b/apps/api/src/modules/mcp/mcp-audit.ts
new file mode 100644
index 000000000..5dcf4f796
--- /dev/null
+++ b/apps/api/src/modules/mcp/mcp-audit.ts
@@ -0,0 +1,94 @@
+/**
+ * What an agent did, in the audit log.
+ *
+ * A dispatched tool call runs through the real HTTP stack, so a write/admin route
+ * already emits its own audit row on success — the secureRouter auto-emitter in
+ * route-permission.ts — now carrying `source: "mcp"` and the calling client. Two
+ * kinds of call that emitter never covers are exactly the two that were missing:
+ *
+ * - READS. A `read`/`list` route audits nothing, deliberately: for a human
+ * clicking around a dashboard, logging every page view is noise. For an
+ * autonomous agent it is the whole question — "what did it look at" had no
+ * answer at all.
+ * - FAILURES. The emitter fires only on 2xx/3xx, so a permission-denied write
+ * left no trace. An agent probing the edge of its scope was invisible
+ * precisely when someone would want to see it.
+ *
+ * Keeping the rule here, as one predicate, is what stops the two failure modes it
+ * sits between: record everything and every mutation is logged twice; record
+ * nothing extra and reads go dark again.
+ */
+
+import { audit } from "../../lib/audit";
+
+/** One executed tool call — the facts the audit decision is made from. */
+export interface ToolCallRecord {
+ tool: string;
+ method: string;
+ /** Route path with :params — never the filled-in one, which carries ids. */
+ path: string;
+ /** The route's permission action ("read" | "list" | "write" | "admin" | …). */
+ action: string;
+ status: number;
+ ok: boolean;
+}
+
+/** Who made the call, resolved once per MCP request by the route. */
+export interface ToolCallActor {
+ /** Null → no org resolved; there is nothing to attribute a row to. */
+ organizationId: string | null;
+ userId: string;
+ /** Canonical principal id — `oauth:` / `pat:`. */
+ clientId: string;
+ /** The personal_access_token row behind this caller (the audit row's resource). */
+ tokenId: string;
+ ipAddress: string | null;
+ userAgent: string | null;
+}
+
+/**
+ * True when this call would otherwise leave no trace. A successful write already
+ * recorded itself as the change it made, which is a better row than a tool-call
+ * row: it names the resource and carries the diff.
+ */
+export function needsOwnAuditRow(record: Pick): boolean {
+ const selfAuditing = record.action === "write" || record.action === "admin";
+ return !(record.ok && selfAuditing);
+}
+
+/**
+ * Record a tool call, if it needs recording.
+ *
+ * The row carries the tool name, the route it hit and the status. NOT the
+ * arguments: they are unbounded and routinely hold env values and secrets, and
+ * the same reasoning already keeps grant tuples out of `mcp.scope_changed`.
+ */
+export function recordToolCall(actor: ToolCallActor, record: ToolCallRecord): void {
+ if (!actor.organizationId) return;
+ if (!needsOwnAuditRow(record)) return;
+
+ audit.recordAsync(
+ {
+ organizationId: actor.organizationId,
+ actorUserId: actor.userId,
+ ipAddress: actor.ipAddress,
+ userAgent: actor.userAgent,
+ // Stated, not derived: `resolveCallSource` reads the credential, and a
+ // PAT-backed MCP client resolves to "api"/"cli" on the outer request. This
+ // request IS the MCP endpoint — there is nothing to infer.
+ source: "mcp",
+ sourceClientId: actor.clientId,
+ },
+ {
+ eventType: "mcp.tool_called",
+ resourceType: "mcp_client",
+ resourceId: actor.tokenId,
+ after: {
+ tool: record.tool,
+ route: `${record.method} ${record.path}`,
+ status: record.status,
+ ok: record.ok,
+ },
+ },
+ );
+}
diff --git a/apps/api/src/modules/mcp/mcp-dispatch.ts b/apps/api/src/modules/mcp/mcp-dispatch.ts
index ec951436f..1e6c7852b 100644
--- a/apps/api/src/modules/mcp/mcp-dispatch.ts
+++ b/apps/api/src/modules/mcp/mcp-dispatch.ts
@@ -1,5 +1,5 @@
import { app } from "../../app";
-import { internalSourceHeader } from "../../lib/call-source";
+import { internalClientHeader, internalSourceHeader } from "../../lib/call-source";
import type { McpToolDef } from "./mcp-tools";
/**
@@ -15,6 +15,24 @@ export interface DispatchResult {
data: unknown;
}
+/**
+ * What the sub-request can't work out for itself.
+ *
+ * An in-process dispatch has no TCP peer and no browser to send headers, so
+ * without this the audit row for an MCP-driven write recorded the loopback
+ * address, no user agent, and no way to tell one connected assistant from
+ * another. All three are read off the OUTER request, which is a real HTTP request
+ * from the real client.
+ */
+export interface DispatchOrigin {
+ /** Canonical principal id — `oauth:` / `pat:`. */
+ principalId: string;
+ /** The outer request's resolved client IP. */
+ clientIp: string | null;
+ /** The outer request's user agent (the MCP client's, e.g. `claude-desktop/1.2`). */
+ userAgent: string | null;
+}
+
// Base host is irrelevant — Hono routes on the path. No Origin header is set,
// so the PAT (a non-browser credential) is accepted by authMiddleware.
const INTERNAL_BASE = "http://mcp.internal";
@@ -23,6 +41,7 @@ export async function dispatchTool(
tool: McpToolDef,
args: Record,
bearerToken: string,
+ origin: DispatchOrigin,
): Promise {
// Fill path params.
let path = tool.path;
@@ -48,11 +67,17 @@ export async function dispatchTool(
// The nonce-signed source marker is the only thing that tells the audit log an
// action came from an AI assistant rather than a script — everything else about
- // this sub-request looks like an ordinary token call, by design.
+ // this sub-request looks like an ordinary token call, by design. The client
+ // marker is signed the same way; the IP and UA are plain, because for an
+ // in-process dispatch `x-real-ip` is already trusted (no TCP peer ⇒ it can only
+ // have come from us — see client-ip.ts) and the UA is not a gate anywhere.
const headers: Record = {
authorization: `Bearer ${bearerToken}`,
...internalSourceHeader("mcp"),
+ ...internalClientHeader(origin.principalId),
};
+ if (origin.clientIp) headers["x-real-ip"] = origin.clientIp;
+ if (origin.userAgent) headers["user-agent"] = origin.userAgent;
const orgId = args.organizationId;
if (typeof orgId === "string" && orgId) headers["x-organization-id"] = orgId;
diff --git a/apps/api/src/modules/mcp/mcp-server.ts b/apps/api/src/modules/mcp/mcp-server.ts
index 6fb9f66ea..8879dfaa8 100644
--- a/apps/api/src/modules/mcp/mcp-server.ts
+++ b/apps/api/src/modules/mcp/mcp-server.ts
@@ -1,5 +1,6 @@
import { getMcpTools, toClientTool, filterToolsForPrincipal, type McpPrincipal } from "./mcp-tools";
-import { dispatchTool } from "./mcp-dispatch";
+import { dispatchTool, type DispatchOrigin } from "./mcp-dispatch";
+import type { ToolCallRecord } from "./mcp-audit";
import { listPrompts, getPrompt } from "./mcp-prompts";
/**
@@ -30,15 +31,24 @@ export function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: st
return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } };
}
+export interface McpMessageContext {
+ /** The caller's credential, forwarded to dispatch so sub-requests re-auth. */
+ bearerToken: string;
+ /** Effective capability, for `tools/list` filtering. */
+ principal: McpPrincipal;
+ /** Facts only the outer HTTP request knows — see DispatchOrigin. */
+ origin: DispatchOrigin;
+ /** Called once per executed tool call, after it returns. */
+ onToolCall?: (record: ToolCallRecord) => void;
+}
+
/**
* Handle one JSON-RPC message. Returns the response object, or null for
- * notifications (no `id` → no reply). `bearerToken` is the caller's PAT,
- * forwarded to tool dispatch so sub-requests re-authenticate.
+ * notifications (no `id` → no reply).
*/
export async function handleMcpMessage(
msg: JsonRpcRequest,
- bearerToken: string,
- principal: McpPrincipal,
+ { bearerToken, principal, origin, onToolCall }: McpMessageContext,
): Promise {
const isNotification = msg.id === undefined || msg.id === null;
@@ -83,7 +93,15 @@ export async function handleMcpMessage(
const tool = getMcpTools().find((t) => t.name === name);
if (!tool) return jsonRpcError(msg.id, -32602, `Unknown tool: ${name}`);
- const dispatched = await dispatchTool(tool, args, bearerToken);
+ const dispatched = await dispatchTool(tool, args, bearerToken, origin);
+ onToolCall?.({
+ tool: tool.name,
+ method: tool.method,
+ path: tool.path,
+ action: tool.perm.action,
+ status: dispatched.status,
+ ok: dispatched.ok,
+ });
return result(msg.id, {
content: [{ type: "text", text: JSON.stringify(dispatched.data, null, 2) }],
isError: !dispatched.ok,
diff --git a/apps/api/src/modules/mcp/mcp.routes.ts b/apps/api/src/modules/mcp/mcp.routes.ts
index 40a8c5245..da5650882 100644
--- a/apps/api/src/modules/mcp/mcp.routes.ts
+++ b/apps/api/src/modules/mcp/mcp.routes.ts
@@ -19,11 +19,28 @@ import {
import { readTokenAudience } from "../../lib/mcp-token";
import { resolveActiveOrganizationId } from "../../middleware/active-organization";
import { resolveBearerIdentity } from "../../middleware/auth";
+import { recordToolCall } from "./mcp-audit";
import { handleMcpMessage, jsonRpcError } from "./mcp-server";
import type { McpPrincipal } from "./mcp-tools";
const r = secureRouter(new Hono(), { module: "mcp", basePath: "/api/mcp" });
+/**
+ * The caller, as this endpoint needs them: their effective capability plus the
+ * identity facts the audit trail is written from.
+ */
+interface McpCaller {
+ principal: McpPrincipal;
+ /** Canonical principal id — `oauth:` / `pat:`. */
+ principalId: string;
+ userId: string;
+ /** Org the call acts in, default-resolved. Null → the user has none. */
+ organizationId: string | null;
+ /** A real personal_access_token row backs this caller (usage is countable). */
+ hasBinding: boolean;
+ tokenId: string;
+}
+
/**
* Resolve the caller's effective capability for `tools/list` filtering. NOT the
* authorization gate — every `tools/call` re-auths through the real stack
@@ -31,7 +48,7 @@ const r = secureRouter(new Hono(), { module: "mcp", basePath: "/api/mcp" });
* advertise tools the token can't use. Returns null on an invalid credential
* (→ 401). Mirrors how authMiddleware resolves a bearer principal (same repos).
*/
-async function resolveMcpPrincipal(token: string, headers: Headers): Promise {
+async function resolveMcpCaller(token: string, headers: Headers): Promise {
// Same credential→identity lookup authMiddleware uses — one resolver, no fork.
const id = await resolveBearerIdentity(token, headers);
if (!id) return null;
@@ -83,12 +100,19 @@ async function resolveMcpPrincipal(token: string, headers: Headers): Promise {
if (!audienceAccepted(token, c.req.raw)) {
return unauthorized(c, "Access token was issued for a different resource");
}
- const principal = await resolveMcpPrincipal(token, c.req.raw.headers);
- if (!principal) return unauthorized(c, "Missing or invalid access token");
+ const caller = await resolveMcpCaller(token, c.req.raw.headers);
+ if (!caller) return unauthorized(c, "Missing or invalid access token");
return c.body(null, 405);
});
@@ -162,8 +186,8 @@ r.public("post", "/", { reason: PUBLIC_REASON, rateLimit: "mcp" }, async (c) =>
// filtering AND gates the request (null → 401). It is NOT the per-tool
// authorization — the real check runs on the dispatched sub-request through
// authMiddleware (see tryPatAuth / tryOAuthMcpAuth); tools/call re-auths.
- const principal = await resolveMcpPrincipal(token, c.req.raw.headers);
- if (!principal) return unauthorized(c, "Missing or invalid access token");
+ const caller = await resolveMcpCaller(token, c.req.raw.headers);
+ if (!caller) return unauthorized(c, "Missing or invalid access token");
let payload: unknown;
try {
@@ -177,7 +201,41 @@ r.public("post", "/", { reason: PUBLIC_REASON, rateLimit: "mcp" }, async (c) =>
return c.json(jsonRpcError(null, -32600, "Batch requests are not supported"), 400);
}
- const res = await handleMcpMessage(payload as Parameters[0], token, principal);
+ const message = payload as Parameters[0];
+
+ // Usage is stamped HERE for everything that does NOT dispatch — `initialize`,
+ // `tools/list`, `prompts/*`, `ping`. Those never reach authMiddleware (this route
+ // is public and resolves the credential itself, side-effect free), so a client
+ // that connected and read the catalog used its credential and still read as
+ // never-used in Settings. `tools/call` is excluded because its sub-request stamps
+ // it in authMiddleware — counting both would double every tool call.
+ if (caller.hasBinding && message?.method !== "tools/call") {
+ void repos.personalAccessToken.touchLastUsed(caller.tokenId).catch(() => {});
+ }
+
+ // Read off the OUTER request — a real HTTP request from the real client. The
+ // in-process sub-request has no peer and no browser, so this is the only place
+ // the assistant's own address and user agent exist.
+ const clientIp = c.var.clientIp ?? null;
+ const userAgent = c.req.header("user-agent") ?? null;
+
+ const res = await handleMcpMessage(message, {
+ bearerToken: token,
+ principal: caller.principal,
+ origin: { principalId: caller.principalId, clientIp, userAgent },
+ onToolCall: (record) =>
+ recordToolCall(
+ {
+ organizationId: caller.organizationId,
+ userId: caller.userId,
+ clientId: caller.principalId,
+ tokenId: caller.tokenId,
+ ipAddress: clientIp,
+ userAgent,
+ },
+ record,
+ ),
+ });
// Notification (no id) → 202 Accepted with no body (per JSON-RPC).
if (!res) return c.body(null, 202);
return c.json(res);
diff --git a/apps/api/src/modules/migration/docker-inspect.service.ts b/apps/api/src/modules/migration/docker-inspect.service.ts
index 3f8ca1cfb..d5c7402e8 100644
--- a/apps/api/src/modules/migration/docker-inspect.service.ts
+++ b/apps/api/src/modules/migration/docker-inspect.service.ts
@@ -341,6 +341,7 @@ export async function discoverServerStack(
* declaration, then run the SAME `toDiscoveredService` merge — so the record is
* byte-for-byte the keys the wizard shows masked (`maskDiscoveredStack`), only
* with the real values. One round-trip, not a full re-scan. Read-only on the box.
+ * Returns the full map; the controller narrows it to the requested keys.
*
* Write-gated at the route (`server:write`): the masked scan is a `:read`,
* revealing the real secret is a `:write`, the same split as the service-env
diff --git a/apps/api/src/modules/migration/migration.controller.ts b/apps/api/src/modules/migration/migration.controller.ts
index 1d38d1c3a..c08b6c28c 100644
--- a/apps/api/src/modules/migration/migration.controller.ts
+++ b/apps/api/src/modules/migration/migration.controller.ts
@@ -12,6 +12,7 @@ import { repos } from "@repo/db";
import { safeErrorMessage } from "@repo/core";
import { getRequestContext } from "../../lib/request-context";
import { permission } from "../../lib/permission";
+import { parseRevealKeys, pickRevealed } from "../../lib/env-reveal";
import { isServerInOrg, param } from "../../lib/controller-helpers";
import { streamRunSSE } from "../../lib/run-sse";
import { streamSSE } from "../../lib/sse";
@@ -181,18 +182,22 @@ export async function scanServerStream(c: Context) {
}
/**
- * POST /migration/reveal-env { serverId, containerId }
+ * POST /migration/reveal-env { serverId, containerId, keys: string[] }
*
* #336: on-demand reveal of ONE discovered container's real env for the wizard's
- * env viewer. The scan masks env (`maskDiscoveredStack`); this returns it UNMASKED
- * for a single container the user chose to reveal. Write-gated (`server:write`, the
- * route tag) — same read/write split as the service-env reveal — so the masked
- * scan stays a `:read` and only a `:write` holder can pull the real secrets.
+ * env viewer. The scan masks env (`maskDiscoveredStack`); this returns UNMASKED
+ * plaintext for the keys the body names — one eye-press, one secret, not the
+ * container's whole env. Write-gated (`server:write`, the route tag) — same
+ * read/write split as the service-env reveal — so the masked scan stays a `:read`
+ * and only a `:write` holder can pull the real secrets.
*/
export async function revealServiceEnv(c: Context) {
- const { serverId, containerId } = await c.req.json<{ serverId?: string; containerId?: string }>();
+ type RevealBody = { serverId?: string; containerId?: string; keys?: unknown };
+ const body = await c.req.json().catch(() => ({}) as RevealBody);
+ const { serverId, containerId } = body;
if (!serverId) return c.json({ error: "serverId is required" }, 400);
if (!containerId) return c.json({ error: "containerId is required" }, 400);
+ const keys = parseRevealKeys(body.keys);
const ctx = getRequestContext(c);
await permission.assert(ctx, {
@@ -205,7 +210,9 @@ export async function revealServiceEnv(c: Context) {
}
try {
- const environment = await revealContainerEnv(serverId, ctx.organizationId, containerId);
+ const full = await revealContainerEnv(serverId, ctx.organizationId, containerId);
+ const environment = pickRevealed(full, keys);
+ c.set("auditAfter", { containerId, revealedEnvKeys: Object.keys(environment) });
return c.json({ success: true, environment });
} catch (err) {
return c.json({ error: `Reveal failed: ${safeErrorMessage(err)}` }, 502);
diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts
index b6a24d093..fd3f1f4fc 100644
--- a/apps/api/src/modules/notifications/notifications.controller.ts
+++ b/apps/api/src/modules/notifications/notifications.controller.ts
@@ -42,21 +42,24 @@ const VALID_CHANNEL_KINDS = new Set([
/**
* GET /categories — the static registry, plus the groups the Settings UI tabs by.
*
- * Billing is dropped outside CLOUD_MODE: those two categories are fed by
- * Stripe/Oblien, so on a self-hosted box they are toggles that can never fire.
- * The filter lives HERE and not in `CATEGORIES` on purpose — `findCategory`
- * supplies the title and body of every delivered alert
- * (notification-workers.ts) and the dispatcher's `defaultEnabled` fallback, so
- * the registry has to stay complete or an org that already holds a billing row
- * would start rendering the raw category id.
+ * Each group is dropped in the mode that can never produce it: `billing` is fed by
+ * Stripe/Oblien so it is cloud-only, and `mail` is fed by the self-hosted mail engine
+ * (the whole mail module is absent in cloud) so it is the mirror image. Either way a
+ * toggle that can never fire is worse than no toggle.
+ *
+ * The filter lives HERE and not in `CATEGORIES` on purpose — `findCategory` supplies the
+ * title and body of every delivered alert (notification-workers.ts) and the dispatcher's
+ * `defaultEnabled` fallback, so the registry has to stay complete or an org that already
+ * holds a row for a hidden category would start rendering the raw category id.
+ *
+ * Both lists are filtered symmetrically: a category whose group is gone would render
+ * under no tab at all.
*/
export async function listCategories(c: Context) {
- if (env.CLOUD_MODE) {
- return c.json({ categories: CATEGORIES, groups: CATEGORY_GROUPS });
- }
+ const hidden = new Set(env.CLOUD_MODE ? ["mail"] : ["billing"]);
return c.json({
- categories: CATEGORIES.filter((cat) => cat.group !== "billing"),
- groups: CATEGORY_GROUPS.filter((g) => g.id !== "billing"),
+ categories: CATEGORIES.filter((cat) => !hidden.has(cat.group)),
+ groups: CATEGORY_GROUPS.filter((g) => !hidden.has(g.id)),
});
}
diff --git a/apps/api/src/modules/projects/folder/folder.controller.ts b/apps/api/src/modules/projects/folder/folder.controller.ts
index 6c0a2fa08..d8f33c38f 100644
--- a/apps/api/src/modules/projects/folder/folder.controller.ts
+++ b/apps/api/src/modules/projects/folder/folder.controller.ts
@@ -1,6 +1,7 @@
import type { Context } from "hono";
import { safeErrorMessage } from "@repo/core";
import { getRequestContext } from "../../../lib/request-context";
+import { parseRevealKeys, pickRevealed } from "../../../lib/env-reveal";
import { requestApiPublicUrl } from "../../../lib/public-url";
import { projectInfoToScanResponse } from "../../deployments/prepare.service";
import { createFolderSession, acceptRelayUpload, scanFolderSession } from "./folder.service";
@@ -87,22 +88,33 @@ export async function scanSession(c: Context) {
}
/**
- * GET /projects/folder/scan/:sessionId/env-reveal
+ * POST /projects/folder/scan/:sessionId/env-reveal { service, keys: string[] }
* #336: the scan response masks compose env, so the wizard's "show values"
* toggle fetches the REAL values here. Backed by `session.services`, which the
* scan captured PRE-mask. Write-gated at the route (project:write) so a
- * read-only caller can't reveal. Returns real env keyed by service name.
+ * read-only caller can't reveal.
+ *
+ * Scoped to ONE service and the keys it names: this used to answer with every
+ * key of every service in the session, so revealing a single row of one service
+ * shipped the whole stack's secrets to the browser.
*/
export async function revealSessionEnv(c: Context) {
const { organizationId } = getRequestContext(c);
const sessionId = c.req.param("sessionId");
+ type RevealBody = { service?: unknown; keys?: unknown };
+ const body = await c.req.json().catch(() => ({}) as RevealBody);
+ const serviceName = typeof body.service === "string" ? body.service : "";
+ if (!serviceName) return c.json({ error: "service is required" }, 400);
+ const keys = parseRevealKeys(body.keys);
+
const session = sessionId ? getFolderSession(sessionId) : undefined;
if (!session || session.orgId !== organizationId) {
return c.json({ error: "Upload session not found" }, 404);
}
- const environments: Record> = {};
- for (const s of session.services ?? []) {
- if (s.name) environments[s.name] = s.environment ?? {};
- }
- return c.json({ success: true, environments });
+ const match = (session.services ?? []).find((s) => s.name === serviceName);
+ if (!match) return c.json({ error: "Service not found in this upload session" }, 404);
+
+ const environment = pickRevealed(match.environment, keys);
+ c.set("auditAfter", { service: serviceName, revealedEnvKeys: Object.keys(environment) });
+ return c.json({ success: true, environment });
}
diff --git a/apps/api/src/modules/projects/project.routes.ts b/apps/api/src/modules/projects/project.routes.ts
index 95f932916..04ea622a9 100644
--- a/apps/api/src/modules/projects/project.routes.ts
+++ b/apps/api/src/modules/projects/project.routes.ts
@@ -117,9 +117,10 @@ r.post(
},
folder.scanSession,
);
-r.get(
+r.post(
// #336: real (unmasked) compose env for the folder-scan wizard's reveal
- // toggle. Write-gated (project:write); no mcp — reveal is a dashboard action.
+ // toggle — one service, only the keys the body names. Write-gated
+ // (project:write); no mcp — reveal is a dashboard action.
"/folder/scan/:sessionId/env-reveal",
{ tag: "project:write", collection: true },
folder.revealSessionEnv,
diff --git a/apps/api/src/modules/services/service.controller.ts b/apps/api/src/modules/services/service.controller.ts
index 62e6acd03..c231688db 100644
--- a/apps/api/src/modules/services/service.controller.ts
+++ b/apps/api/src/modules/services/service.controller.ts
@@ -13,6 +13,7 @@ import { AppError } from "@repo/core";
import { streamSSE } from "../../lib/sse";
import { param } from "../../lib/controller-helpers";
import { getRequestContext } from "../../lib/request-context";
+import { parseRevealKeys, pickRevealed } from "../../lib/env-reveal";
import { audit, auditContextFrom } from "../../lib/audit";
import { sshManager } from "../../lib/ssh-manager";
import * as serviceService from "./service.service";
@@ -57,13 +58,27 @@ export async function getById(c: Context) {
// ─── Reveal real env (#336) — write-gated, backs the "show values" toggle ─────
+/**
+ * POST /projects/:id/services/:serviceId/env-reveal { keys: string[] }
+ *
+ * Per-key: returns plaintext for the named keys ONLY, so pressing one row's eye
+ * discloses one secret. `keys` is required (see parseRevealKeys) — no request can
+ * ask for the whole map. The auto-emitted `project:service:write` audit row
+ * carries the disclosed key names via `auditAfter`.
+ */
export async function revealEnv(c: Context) {
const ctx = getRequestContext(c);
const projectId = param(c, "id");
const serviceId = param(c, "serviceId");
+ // Outside the try: a 400 from key validation must not be reported as a
+ // reveal failure. Body may be absent on a malformed client call.
+ const body = await c.req.json<{ keys?: unknown }>().catch(() => ({}) as { keys?: unknown });
+ const keys = parseRevealKeys(body.keys);
try {
- const environment = await serviceService.revealServiceEnv(ctx, projectId, serviceId);
+ const stored = await serviceService.revealServiceEnv(ctx, projectId, serviceId);
+ const environment = pickRevealed(stored, keys);
+ c.set("auditAfter", { revealedEnvKeys: Object.keys(environment) });
return c.json({ success: true, environment });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to reveal service env";
@@ -231,6 +246,8 @@ export async function syncFromCompose(c: Context) {
environment?: Record;
volumes?: string[];
command?: string;
+ /** #332: exact argv — no `sh -c`. Wins over the lossy `command` string. */
+ commandArgv?: string[];
restart?: string;
exposed?: boolean;
exposedPort?: string;
diff --git a/apps/api/src/modules/services/service.routes.ts b/apps/api/src/modules/services/service.routes.ts
index a2b82d865..0a3b28ba1 100644
--- a/apps/api/src/modules/services/service.routes.ts
+++ b/apps/api/src/modules/services/service.routes.ts
@@ -83,10 +83,13 @@ r.get(
cloudProjectProxy,
ctrl.getById,
);
-r.get(
- // #336: real (unmasked) compose env. Write-gated on purpose — read-only
- // callers only ever see the masked map from GET /:serviceId. No mcp block:
- // revealing secrets stays a dashboard action, off the automation surface.
+r.post(
+ // #336: real (unmasked) compose env for the keys named in the body — never the
+ // whole map. Write-gated on purpose: read-only callers only ever see the masked
+ // map from GET /:serviceId. POST, not GET, because the requested key names are
+ // a body (out of proxy access logs and browser history) and are unbounded by
+ // URL length. No mcp block: revealing secrets stays a dashboard action, off the
+ // automation surface.
"/:serviceId/env-reveal",
{ tag: "project:service:write" },
cloudProjectProxy,
diff --git a/apps/api/src/modules/services/service.schema.ts b/apps/api/src/modules/services/service.schema.ts
index 183455cfd..c097be658 100644
--- a/apps/api/src/modules/services/service.schema.ts
+++ b/apps/api/src/modules/services/service.schema.ts
@@ -264,7 +264,21 @@ export const SyncServicesBody = Type.Object({
dependsOn: Type.Optional(Type.Array(Type.String())),
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
volumes: Type.Optional(Type.Array(Type.String())),
- command: Type.Optional(Type.String()),
+ command: Type.Optional(
+ Type.String({
+ description:
+ "Shell-string form. Stored for display and shell-word-split into argv; prefer `commandArgv` for a list command, whose string form here is a LOSSY join.",
+ }),
+ ),
+ // #332: without this the only way to express a command was the string above,
+ // so a list command round-tripped through this endpoint lost its quoting
+ // (`["sh","-c","a && b"]` → five words). The CLI has always sent it.
+ commandArgv: Type.Optional(
+ Type.Array(Type.String(), {
+ description:
+ "Exact container argv (docker-compose Cmd semantics: overrides the image CMD, keeps its ENTRYPOINT, no implicit `sh -c`). Wins over `command`. `[]` clears the image CMD.",
+ }),
+ ),
restart: Type.Optional(
Type.String({
description: 'Compose restart policy, e.g. "unless-stopped" or "on-failure:3".',
diff --git a/apps/api/src/modules/services/service.service.ts b/apps/api/src/modules/services/service.service.ts
index 03101c430..5b0196e72 100644
--- a/apps/api/src/modules/services/service.service.ts
+++ b/apps/api/src/modules/services/service.service.ts
@@ -3,7 +3,7 @@
*/
import { normalizeRoutingFields, repos, composeSpecDiff, type Project, type Service, type ServicePublicEndpoint } from "@repo/db";
-import { aliasConflictsWithSiblings, getProjectType, mergeAdvanced, normalizeServiceLabel, normalizeAliasStrict, safeErrorMessage, withTimeout, type ComposeAdvanced, type ServiceContainerState, type StackId } from "@repo/core";
+import { aliasConflictsWithSiblings, getProjectType, mergeAdvanced, normalizeServiceLabel, normalizeAliasStrict, resolveCommandArgv, safeErrorMessage, withTimeout, type ComposeAdvanced, type ServiceContainerState, type StackId } from "@repo/core";
import {
BuildLogger,
DockerRuntime,
@@ -231,6 +231,9 @@ export async function getService(
* write-gated reveal that backs the "show values" toggle — the route tag is
* `project:service:write`, so a read-only caller can never reach the plaintext
* (the whole point: read = masked). `getService` above always masks.
+ *
+ * Returns the FULL stored map; the controller narrows it to the keys the request
+ * named (`pickRevealed`) so only those cross the wire.
*/
export async function revealServiceEnv(
ctx: RequestContext,
@@ -496,7 +499,13 @@ export async function createService(
environment: data.environment ?? {},
volumes: data.volumes ?? [],
command: trimOrNull(data.command),
- commandArgv: data.commandArgv ?? null, // #332
+ // #332: derive argv from the text command when the client didn't send one, or
+ // the row falls back to the `sh -c` wrap that breaks entrypoint+CMD images.
+ commandArgv:
+ resolveCommandArgv({
+ incomingArgv: data.commandArgv,
+ incomingCommand: data.command,
+ }) ?? null,
restart: data.restart ?? "unless-stopped",
advanced,
...routing,
@@ -590,6 +599,18 @@ export async function updateService(
patch[key] = trimOrNull(patch[key]);
}
}
+ // #332: `commandArgv` wins over `command` at deploy time, so a command edit that
+ // leaves a stale argv behind silently keeps running the OLD command — the form's
+ // command field did nothing on any row imported from a compose file. Re-derive on
+ // a real edit; an echoed-back identical string keeps the stored argv (it may be a
+ // list command whose display join can't be re-split).
+ const nextCommandArgv = resolveCommandArgv({
+ incomingArgv: patch.commandArgv,
+ incomingCommand: "command" in patch ? patch.command : undefined,
+ storedCommand: svc.command,
+ storedArgv: svc.commandArgv as string[] | null,
+ });
+ if (nextCommandArgv !== undefined) patch.commandArgv = nextCommandArgv;
// Monorepo sub-app build settings: same trim-or-null treatment so empty
// strings become null in DB (matches the rest of the service columns).
for (const key of [
@@ -1040,6 +1061,11 @@ export async function syncComposeServices(
// holding a bad one (persisted before #342) must not be refused wholesale.
assertValidCustomDomains(parsed, { known: customHostnamesOf(stored) });
+ // #332: argv needs no restoring here. This endpoint accepts `command` as a string
+ // whose stored form is a lossy join, but `syncFromCompose` (composeWritePatch →
+ // resolveCommandArgv) is the ONE place that decides whether an unchanged string
+ // keeps the stored argv or a changed one re-derives — so every writer into that
+ // path, including the deploy request's own service list, gets the same rule.
const reconciled = parsed.map((svc) =>
svc.environment
? { ...svc, environment: unmaskEnv(svc.environment, storedEnvByName.get(svc.name) ?? null) }
diff --git a/apps/api/src/modules/system/server-check.controller.ts b/apps/api/src/modules/system/server-check.controller.ts
index 339f85964..c81527ef5 100644
--- a/apps/api/src/modules/system/server-check.controller.ts
+++ b/apps/api/src/modules/system/server-check.controller.ts
@@ -22,6 +22,7 @@ import {
COMPONENT_UNINSTALLERS,
ensureEdge,
getRemovalSupport,
+ invalidateHostChannelAuth,
isHostChannelUnavailableError,
isSshAuthError,
recoverInterruptedTakeover,
@@ -385,10 +386,6 @@ export async function checkServer(c: Context) {
) {
return c.json({ error: "no_server", message }, 400);
}
- if (isSshAuthError(err)) {
- return c.json({ error: "auth_failed", message }, 400);
- }
-
// A connect failure on THIS box is almost never "the server is down" — it's the
// container→host SSH channel, and the row's display sshHost (127.0.0.1) names
// neither the right machine nor the right port (#490). Hand the UI the address
@@ -397,7 +394,22 @@ export async function checkServer(c: Context) {
// Only for failures that came from the transport: a component check that threw for
// its own reasons is not evidence about the channel, and diagnosing it anyway costs
// a TCP probe to tell the operator about a firewall that was never involved.
- if (isHostChannelUnavailableError(err) || isTransportFailure(err)) {
+ //
+ // #527 added `isSshAuthError` here and moved the generic `auth_failed` answer BELOW
+ // this block. An auth rejection on the local row was being claimed by that branch
+ // before this one could run, and on that row the answer is always wrong: its stored
+ // credentials are display-only, nothing dials with them, so the operator got an
+ // edit-credentials form that could not change the outcome. Reordering is safe for
+ // remote servers because `host_channel_blocked` only ever comes back for a row that
+ // resolves to THIS box — a remote box with a genuinely rejected key still falls
+ // through to `auth_failed`.
+ if (isSshAuthError(err) || isHostChannelUnavailableError(err) || isTransportFailure(err)) {
+ // A rejection we are HOLDING beats a memoized "the key worked 20s ago". Without
+ // this, a health probe that cached success just before the key stopped working
+ // would answer `ok`, the diagnosis would not name the channel, and the failure
+ // would fall through to the generic credentials answer this branch exists to
+ // prevent — the #527 card, restored by a cache.
+ if (isSshAuthError(err)) invalidateHostChannelAuth();
const d = await sshManager.diagnoseReachability(serverId).catch(() => null);
if (d?.code === "host_channel_blocked") {
return c.json(
@@ -417,6 +429,11 @@ export async function checkServer(c: Context) {
);
}
}
+ // Reached only when the diagnosis did NOT name the host channel — i.e. a real remote
+ // server whose key or password the far end refused, which is what this answer is for.
+ if (isSshAuthError(err)) {
+ return c.json({ error: "auth_failed", message }, 400);
+ }
return c.json({ error: "connection_failed", message }, 502);
}
}
diff --git a/apps/api/src/modules/system/server-containers.controller.ts b/apps/api/src/modules/system/server-containers.controller.ts
index 8f575d40d..710ba7b0e 100644
--- a/apps/api/src/modules/system/server-containers.controller.ts
+++ b/apps/api/src/modules/system/server-containers.controller.ts
@@ -25,6 +25,7 @@ import {
} from "./server-containers.service";
import {
getActiveContainerApplySession,
+ listContainerApplySessions,
subscribeContainerApplySession,
} from "../../lib/server-container-session";
@@ -130,6 +131,89 @@ export async function containerIssues(c: Context) {
return c.json(await loadOrgContainerIssues(ctx.organizationId));
}
+/** How long a settled apply keeps reporting its outcome to the fleet view. */
+const SETTLED_WINDOW_MS = 90_000;
+
+/**
+ * GET /system/containers/applying — what the org has in flight right now, and what
+ * just settled.
+ *
+ * The progress read behind the fleet roll-up. Two sources, because neither alone is
+ * the whole truth:
+ * - the cached rows say which (server, component) pairs were ACCEPTED, including
+ * the ones a bulk run has queued but not started (they carry no session yet);
+ * - the in-memory sessions say how far the ones actually running have got, and are
+ * the only place an outcome exists — a drift row clears `behind` and its
+ * in-progress flag in the same write, so a reader watching rows alone sees work
+ * vanish and can never tell that it succeeded.
+ *
+ * `intent` comes from the row's own state (behind → update, otherwise a restart),
+ * which is what the operator pressed; sessions don't record it.
+ */
+export async function listApplyingContainers(c: Context) {
+ const cloudGuard = assertNotCloud(c); if (cloudGuard) return cloudGuard;
+ const ctx = getRequestContext(c);
+ const [servers, rows] = await Promise.all([
+ repos.server.listByOrganization(ctx.organizationId),
+ repos.serverContainerStatus.listByOrg(ctx.organizationId),
+ ]);
+ const names = new Map(servers.map((s) => [s.id, s.name ?? s.sshHost]));
+ const sessions = listContainerApplySessions({ settledWithinMs: SETTLED_WINDOW_MS }).filter((s) =>
+ names.has(s.serverId),
+ );
+ const running = new Map(
+ sessions.filter((s) => s.status === "running").map((s) => [`${s.serverId}:${s.component}`, s]),
+ );
+ const rowFor = new Map(rows.map((r) => [`${r.serverId}:${r.component}`, r]));
+
+ // Flagged rows first (stable, and the only source that knows the intent), then any
+ // running session whose row went missing — a swap mid-flight is still in flight
+ // even if its cached row was dropped.
+ const active = [
+ ...rows
+ .filter((r) => r.latestInProgress)
+ .map((r) => {
+ const key = `${r.serverId}:${r.component}`;
+ const session = running.get(key);
+ return {
+ serverId: r.serverId,
+ serverName: names.get(r.serverId) ?? r.serverId,
+ component: r.component,
+ state: session ? ("running" as const) : ("queued" as const),
+ intent: r.behind ? ("update" as const) : ("repair" as const),
+ ...(session
+ ? { sessionId: session.id, steps: session.steps, startedAt: new Date(session.startedAt).toISOString() }
+ : {}),
+ };
+ }),
+ ...[...running.entries()]
+ .filter(([key]) => !rowFor.get(key)?.latestInProgress)
+ .map(([, session]) => ({
+ serverId: session.serverId,
+ serverName: names.get(session.serverId) ?? session.serverId,
+ component: session.component,
+ state: "running" as const,
+ intent: null,
+ sessionId: session.id,
+ steps: session.steps,
+ startedAt: new Date(session.startedAt).toISOString(),
+ })),
+ ];
+
+ const recent = sessions
+ .filter((s) => s.status !== "running")
+ .map((s) => ({
+ serverId: s.serverId,
+ serverName: names.get(s.serverId) ?? s.serverId,
+ component: s.component,
+ ok: s.status === "completed",
+ ...(s.error ? { error: s.error } : {}),
+ finishedAt: new Date(s.finishedAt ?? Date.now()).toISOString(),
+ }));
+
+ return c.json({ active, recent });
+}
+
/**
* POST /system/containers/apply-all — update every behind container and restart
* every stopped one across the org, in one click.
diff --git a/apps/api/src/modules/system/server-containers.service.ts b/apps/api/src/modules/system/server-containers.service.ts
index 9370923af..0c80aca5a 100644
--- a/apps/api/src/modules/system/server-containers.service.ts
+++ b/apps/api/src/modules/system/server-containers.service.ts
@@ -259,7 +259,9 @@ function upsertView(server: Server, view: ServerContainerView, lastError?: strin
runningVersion: view.runningVersion,
pinnedVersion: view.pinnedVersion,
behind: view.behind,
- latestInProgress: false,
+ // Deliberately NOT written: a probe knows what the box runs, not whether an
+ // apply is mid-flight. `upsert` preserves the flag when it's omitted, so a scan
+ // landing during a swap can no longer erase the state every surface renders.
detail,
});
}
@@ -663,7 +665,23 @@ export async function applyAllContainers(
});
}
- // Detached on purpose — the response is the classification, not the outcome.
+ // Flag EVERY accepted target — queued ones included — and await it before the
+ // response. `applyServerContainer` sets the same flag itself, but only when a
+ // worker slot picks the target up: with BULK_APPLY_CONCURRENCY at 3, targets 4..N
+ // were indistinguishable from "never started" for as long as the first swaps took,
+ // so a fleet view had nothing to render and a second click re-queued containers
+ // that were already accepted (the session dedup only catches a target a worker
+ // has actually reached). One flag per target, written up front, is the record that
+ // the work was taken on.
+ await Promise.all(
+ targets.map((t) =>
+ repos.serverContainerStatus.setInProgress(t.server.id, t.component, true).catch(() => {}),
+ ),
+ );
+
+ // Detached on purpose — the response is the classification, not the outcome. Each
+ // apply clears its own flag in `applyServerContainer`'s finally; a target whose
+ // worker never runs (process death mid-run) is cleared at the next boot.
void mapWithLimit(targets, BULK_APPLY_CONCURRENCY, async (t) => {
await runContainerApply(t.server, t.component, t.intent).done.catch(() => {});
}).catch(() => {});
diff --git a/apps/api/src/modules/system/servers.controller.ts b/apps/api/src/modules/system/servers.controller.ts
index e2ed5493f..be901d50d 100644
--- a/apps/api/src/modules/system/servers.controller.ts
+++ b/apps/api/src/modules/system/servers.controller.ts
@@ -242,6 +242,24 @@ export async function createServer(c: Context) {
}
/** PATCH /servers/:id - update a server */
+/**
+ * Everything on a server row that describes HOW to dial it — i.e. everything an isLocal
+ * row does not use. Listed once so a new credential field can't quietly become writable
+ * on the one row where writing it means nothing.
+ */
+const LOCAL_ROW_READONLY_FIELDS = [
+ "sshHost",
+ "sshPort",
+ "sshUser",
+ "sshAuthMethod",
+ "sshPassword",
+ "sshKeyPath",
+ "sshPrivateKey",
+ "sshKeyPassphrase",
+ "sshJumpHost",
+ "sshArgs",
+] as const;
+
export async function updateServer(c: Context) {
const cloudGuard = assertNotCloud(c); if (cloudGuard) return cloudGuard;
@@ -254,6 +272,33 @@ export async function updateServer(c: Context) {
if (!existing) return c.json({ error: "Server not found" }, 404);
const body = await c.req.json();
+
+ // #527: an isLocal row's ssh* fields are DISPLAY-ONLY. Every operation on this box goes
+ // through the container→host channel, whose credentials come from OPENSHIP_HOST_SSH_*
+ // and never from this row (see lib/startup/self-server.ts). Accepting them stored a
+ // credential nothing reads, displayed it back as though it were in use, and let
+ // `ensureLocalServer`'s reconcile silently revert ssh_user on the next `GET /servers`.
+ //
+ // That combination is most of what #527 cost its reporter: told their credentials were
+ // rejected, they came here, entered a username and key, watched it change nothing, and
+ // tried three more key paths. Refusing with the reason is the only answer that ends
+ // that loop. `name` stays editable — renaming this row is meaningful and harmless.
+ if (existing.isLocal) {
+ const attempted = LOCAL_ROW_READONLY_FIELDS.filter((f) => body[f] !== undefined);
+ if (attempted.length > 0) {
+ return c.json(
+ {
+ error:
+ "This row is the machine Openship runs on, so its SSH details are display-only " +
+ "— the connection to this host uses the channel key provisioned by " +
+ "`openship up`, not credentials stored here. Re-run `openship up` to change it.",
+ fields: attempted,
+ },
+ 400,
+ );
+ }
+ }
+
const patch: Record = {};
if (body.name !== undefined) patch.name = body.name?.trim() || null;
diff --git a/apps/api/src/modules/system/system-health.controller.ts b/apps/api/src/modules/system/system-health.controller.ts
index 9cae5979c..bb7c88d4d 100644
--- a/apps/api/src/modules/system/system-health.controller.ts
+++ b/apps/api/src/modules/system/system-health.controller.ts
@@ -19,7 +19,11 @@
import type { Context } from "hono";
import { db, getDriver, sql, count, eq, schema } from "@repo/db";
import { hostChannelHealth, type HostChannelHealth } from "@repo/adapters";
-import { HOST_CHANNEL_NOT_PROVISIONED, safeErrorMessage } from "@repo/core";
+import {
+ HOST_CHANNEL_AUTH_REJECTED,
+ HOST_CHANNEL_NOT_PROVISIONED,
+ safeErrorMessage,
+} from "@repo/core";
import { env } from "../../config";
@@ -60,13 +64,17 @@ function reportHostChannel(h: HostChannelHealth): HostChannelReport {
ok: h.ok,
state: h.code,
...(h.cause ? { cause: h.cause } : {}),
- // `key_unreadable`'s own hint names the key PATH, which never goes on the wire —
- // and the remedy is the same one anyway: reprovision, then verify.
+ // `key_unreadable`'s own hint names the key PATH and `auth_rejected`'s names the
+ // channel TARGET; neither goes on the wire, for the reason the address and the
+ // firewall rule don't — this payload is for monitoring, not for an operator standing
+ // at a terminal. Both fall back to the shared prose, which is the same remedy anyway.
...(h.code === "key_unreadable"
? { remedy: HOST_CHANNEL_NOT_PROVISIONED }
- : h.hint
- ? { remedy: h.hint }
- : {}),
+ : h.code === "auth_rejected"
+ ? { remedy: `${HOST_CHANNEL_AUTH_REJECTED} ${HOST_CHANNEL_NOT_PROVISIONED}` }
+ : h.hint
+ ? { remedy: h.hint }
+ : {}),
};
}
diff --git a/apps/api/src/modules/system/system.routes.ts b/apps/api/src/modules/system/system.routes.ts
index 8326734c9..b8a6d38e8 100644
--- a/apps/api/src/modules/system/system.routes.ts
+++ b/apps/api/src/modules/system/system.routes.ts
@@ -183,6 +183,10 @@ r.get("/containers/issues", { tag: "server:read", collection: true }, serverCont
// Global infra view — every server × component. No :id, so collection:true scopes
// the check to the active org (same as /containers/behind). Scan is detect-only.
r.get("/containers", { tag: "server:read", collection: true }, serverContainers.listAllContainers);
+// Live progress for the fleet view: what's queued/running right now (cached rows ×
+// in-memory sessions) plus what just settled, which is the only place an outcome
+// lives — a finished row clears its drift and its in-progress flag together.
+r.get("/containers/applying", { tag: "server:read", collection: true }, serverContainers.listApplyingContainers);
r.post("/containers/scan", { tag: "server:write", collection: true }, serverContainers.scanAllContainers);
// Fleet bulk apply — targets are derived from the cache server-side, so the body
// only carries which intents to run ("update" swaps, "repair" restarts).
diff --git a/apps/api/src/modules/tokens/token.controller.ts b/apps/api/src/modules/tokens/token.controller.ts
index f43b8f2c3..cde091652 100644
--- a/apps/api/src/modules/tokens/token.controller.ts
+++ b/apps/api/src/modules/tokens/token.controller.ts
@@ -32,6 +32,8 @@ function serialize(t: PublicPersonalAccessToken) {
scoped: t.scoped,
expiresAt: t.expiresAt,
lastUsedAt: t.lastUsedAt,
+ /** Requests made with this token. Approximate — the write is fire-and-forget. */
+ useCount: t.useCount,
revokedAt: t.revokedAt,
createdAt: t.createdAt,
};
@@ -514,7 +516,7 @@ export async function authorizeMcpClient(c: Context) {
* reproduced here.
*/
function serializeBinding(
- b: { oauthClientId: string | null; name: string; organizationId: string | null; readOnly: boolean; scoped: boolean; createdAt: Date; lastUsedAt: Date | null },
+ b: { id: string; oauthClientId: string | null; name: string; organizationId: string | null; readOnly: boolean; scoped: boolean; createdAt: Date; lastUsedAt: Date | null; useCount: number },
name: string,
organizationName: string | null,
grants: Array<{ resourceType: string; resourceId: string; permissions: Permission[]; scope?: SourceAccessScope }>,
@@ -530,6 +532,10 @@ function serializeBinding(
grantCount: grants.length,
authorizedAt: b.createdAt,
lastUsedAt: b.lastUsedAt,
+ /** Requests this client has made. Approximate — the write is fire-and-forget. */
+ useCount: b.useCount,
+ /** Audit key for "everything this client did" (audit_event.source_client_id). */
+ auditClientId: b.oauthClientId ? `oauth:${b.oauthClientId}` : `pat:${b.id}`,
...(opts.includeGrants
? {
grants: grants.map((g) => ({
@@ -633,10 +639,38 @@ export async function disconnectMcpClient(c: Context) {
const clientId = param(c, "clientId").trim();
if (!clientId) return c.json({ error: "clientId required", code: "CLIENT_ID_REQUIRED" }, 400);
+ // Read the binding BEFORE the teardown: it is the only place the scope the
+ // client held is still recorded, and an audit row that can only say "something
+ // named X was disconnected" answers none of the questions asked after the fact.
+ const binding = await repos.personalAccessToken.findOAuthBinding(ctx.userId, clientId);
+ const grantCount = binding ? (await repos.patGrant.listByToken(binding.id)).length : 0;
+
// Atomic: tokens + consent + binding + grants are torn down in one
// transaction (see oauth repo). Self-scoped to ctx.userId, so a client
// shared across users keeps working for everyone else.
await repos.oauth.disconnectMcpClient(ctx.userId, clientId);
+ // Authorizing and re-scoping an agent were both recorded; REVOKING it was not,
+ // which left the one MCP lifecycle event with no trace — and the log reading as
+ // though a client that is long gone still holds its scope.
+ const organizationId = binding?.organizationId ?? ctx.organizationId;
+ if (organizationId) {
+ audit.recordAsync(auditContextFrom(c, organizationId, ctx.userId), {
+ eventType: "mcp.disconnected",
+ resourceType: "mcp_client",
+ resourceId: binding?.id ?? clientId,
+ before: binding
+ ? {
+ clientId,
+ scoped: binding.scoped,
+ readOnly: binding.readOnly,
+ grantCount,
+ useCount: binding.useCount,
+ }
+ : { clientId },
+ after: null,
+ });
+ }
+
return c.json({ data: { ok: true } });
}
diff --git a/apps/api/test/e2e/mail-db-bootstrap.e2e.test.ts b/apps/api/test/e2e/mail-db-bootstrap.e2e.test.ts
new file mode 100644
index 000000000..e2451750b
--- /dev/null
+++ b/apps/api/test/e2e/mail-db-bootstrap.e2e.test.ts
@@ -0,0 +1,244 @@
+/**
+ * The mail engine's DB bootstrap, against a REAL PostgreSQL sidecar (GH-562).
+ *
+ * The bug this exists for could not be caught by any unit test, because the defect
+ * was the script's *failure semantics* rather than its SQL. `db-bootstrap.sh` ran
+ * under `set -uo pipefail` with no `-e`, so when it started before the sidecar was
+ * ready every psql failed in turn, the script reached its closing
+ * "── DB bootstrap complete ──" and exited 0. The caller's `|| log "ERROR"` could
+ * therefore never fire. Operators saw dovecot, iredapd and amavis crash-loop against
+ * an empty database with nothing anywhere explaining why, and the only recovery was
+ * to run the script by hand.
+ *
+ * A second, quieter variant: `doveadm pw` ran with `2>/dev/null` and its output
+ * unvalidated, so a missing `doveadm` seeded the postmaster with an EMPTY password
+ * and still reported success.
+ *
+ * What this asserts is therefore mostly about EXIT CODES and the absence of false
+ * success — the three cases below all passed (exit 0) before the fix.
+ *
+ * Why not boot `openship-mail` itself: that image is not published, and building it
+ * runs the full iRedMail installer — minutes to tens of minutes, which is not a test.
+ * The script's real collaborators are bash, psql, perl and the engine's SQL samples,
+ * so a Debian `postgres:16` runner with the repo's actual `engine/samples` copied in
+ * exercises the real script against a real database. `doveadm` is the one collaborator
+ * that must be stubbed, and stubbing it is what lets us drive its failure mode
+ * deliberately. The Dockerfile smoke gate covers doveadm's real presence.
+ *
+ * Files travel by `docker cp`, not a bind mount: a mount only works when the daemon
+ * can see the host path, which is false for a macOS temp dir under Colima and true in
+ * CI — i.e. the one setup where a gate must not evaporate. Same reasoning as
+ * edge-not-found-page.e2e.test.ts.
+ *
+ * Skips without a reachable daemon, FAILS under RUN_DOCKER_E2E=1 (what CI sets).
+ * See test/helpers/docker-e2e.ts.
+ */
+
+import { it, expect, beforeAll, afterAll } from "vitest";
+import { execFile } from "node:child_process";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e";
+
+const execFileAsync = promisify(execFile);
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
+const EMAIL_DIR = join(REPO_ROOT, "apps/email");
+
+/** Unique per run so a leftover container from a killed run can't collide. */
+const SUFFIX = process.pid.toString(36);
+const NET = `openship-e2e-mailboot-${SUFFIX}`;
+const DB = `openship-e2e-mailboot-db-${SUFFIX}`;
+const RUNNER = `openship-e2e-mailboot-run-${SUFFIX}`;
+
+const DB_IMAGE = "postgres:16-alpine";
+/** Debian-based: the script is bash and uses perl for iRedMail's PH_ substitutions. */
+const RUNNER_IMAGE = "postgres:16";
+
+const PG_PASSWORD = "e2e-root-pw";
+const BIND_PASSWORD = "e2e-bind-pw";
+const POSTMASTER_PLAIN = "e2e-postmaster-pw";
+const FIRST_DOMAIN = "e2e-mail.example";
+/** Any well-formed SSHA512 value; the script only validates the shape. */
+const STUB_HASH = "{SSHA512}ZTJlLXN0dWItaGFzaC12YWx1ZQ==";
+
+const SCRIPT_IN_RUNNER = "/opt/openship-mail/db-bootstrap.sh";
+
+async function docker(args: string[], opts: { allowFail?: boolean } = {}) {
+ try {
+ const { stdout, stderr } = await execFileAsync("docker", args, {
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ return { code: 0, stdout, stderr };
+ } catch (err) {
+ const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
+ if (!opts.allowFail) {
+ throw new Error(
+ `docker ${args.slice(0, 3).join(" ")} failed: ${e.stderr || e.message || "unknown"}`,
+ );
+ }
+ return { code: typeof e.code === "number" ? e.code : 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
+ }
+}
+
+/** Run db-bootstrap.sh in the runner. Never throws — the exit code IS the assertion. */
+async function runBootstrap(env: Record = {}) {
+ const envArgs = Object.entries({
+ FIRST_DOMAIN,
+ VMAIL_DB_BIND_PASSWD: BIND_PASSWORD,
+ DOMAIN_ADMIN_PASSWD_PLAIN: POSTMASTER_PLAIN,
+ PGSQL_ROOT_PASSWD: PG_PASSWORD,
+ OPENSHIP_MAIL_DB_HOST: DB,
+ OPENSHIP_MAIL_DB_PORT: "5432",
+ ...env,
+ }).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
+
+ const r = await docker(
+ ["exec", ...envArgs, RUNNER, "bash", SCRIPT_IN_RUNNER],
+ { allowFail: true },
+ );
+ return { ...r, log: `${r.stdout}\n${r.stderr}` };
+}
+
+/** A one-shot query as the postgres superuser, from inside the runner. */
+async function query(sql: string, db = "vmail"): Promise {
+ const r = await docker([
+ "exec",
+ "--env", `PGPASSWORD=${PG_PASSWORD}`,
+ RUNNER,
+ "psql", "-h", DB, "-U", "postgres", "-d", db, "-tAc", sql,
+ ]);
+ return r.stdout.trim();
+}
+
+/**
+ * Replace the stubbed `doveadm` so its failure mode can be driven deliberately.
+ *
+ * `%b`, not `%s`: printf only interprets `\n` in the FORMAT for %b, so `%s` wrote the
+ * two-character sequence and produced a one-line file that is not a runnable script.
+ * That silently turned every case into the "doveadm is broken" case.
+ */
+async function setDoveadmStub(body: string): Promise {
+ await docker([
+ "exec", RUNNER, "bash", "-c",
+ `printf '%b\\n' ${JSON.stringify(body)} > /usr/bin/doveadm && chmod +x /usr/bin/doveadm`,
+ ]);
+ // Prove the stub is executable and behaves, so a broken stub can never masquerade
+ // as a finding about the script under test.
+ await docker(["exec", RUNNER, "/usr/bin/doveadm", "pw", "-s", "SSHA512", "-p", "x"], {
+ allowFail: true,
+ });
+}
+
+describeDockerE2E("mail engine DB bootstrap (real postgres)", () => {
+ beforeAll(async () => {
+ await requireDocker();
+
+ await docker(["network", "create", NET]);
+
+ // vmail is PRE-CREATED here exactly as the real sidecar does it (POSTGRES_DB),
+ // because the script loads schema into it rather than creating it.
+ await docker([
+ "run", "-d", "--name", DB, "--network", NET,
+ "--env", `POSTGRES_PASSWORD=${PG_PASSWORD}`,
+ "--env", "POSTGRES_DB=vmail",
+ DB_IMAGE,
+ ]);
+
+ await docker([
+ "run", "-d", "--name", RUNNER, "--network", NET,
+ "--entrypoint", "sleep", RUNNER_IMAGE, "3600",
+ ]);
+
+ // The REAL script and the REAL iRedMail SQL samples.
+ await docker(["exec", RUNNER, "mkdir", "-p", "/opt/openship-mail", "/opt/iRedMail-engine"]);
+ await docker(["cp", join(EMAIL_DIR, "docker/db-bootstrap.sh"), `${RUNNER}:${SCRIPT_IN_RUNNER}`]);
+ await docker(["cp", join(EMAIL_DIR, "engine/samples"), `${RUNNER}:/opt/iRedMail-engine/samples`]);
+
+ await setDoveadmStub(`#!/bin/sh\necho '${STUB_HASH}'`);
+ }, 300_000);
+
+ afterAll(async () => {
+ await docker(["rm", "-f", RUNNER], { allowFail: true });
+ await docker(["rm", "-f", DB], { allowFail: true });
+ await docker(["network", "rm", NET], { allowFail: true });
+ }, 120_000);
+
+ // Ordered on purpose: each case leaves the database in the state the next expects,
+ // and the doveadm case must run while the schema is still absent.
+ it("waits for a database that is not ready yet instead of failing against it", async () => {
+ // The runner and the sidecar started together, so this first call races postgres's
+ // own initdb — which is the GH-562 race, reproduced rather than simulated. The old
+ // `nc -z` probe returned as soon as the port bound and the bootstrap proceeded.
+ // Nothing is asserted about timing; the point is that it SUCCEEDS below.
+ const r = await runBootstrap();
+ expect(r.log).toMatch(/waiting for the mail database|mail database answered|bootstrapping/i);
+ expect(r.code).toBe(0);
+ }, 300_000);
+
+ it("seeded the first domain and a postmaster with a NON-EMPTY password", async () => {
+ expect(await query("SELECT count(*) FROM domain")).toBe("1");
+ expect(await query("SELECT domain FROM domain LIMIT 1")).toBe(FIRST_DOMAIN);
+
+ // The whole point of the hash validation: this column must never be blank.
+ const blank = await query(
+ `SELECT count(*) FROM mailbox WHERE coalesce(password,'') = ''`,
+ );
+ expect(blank).toBe("0");
+ expect(await query(`SELECT password FROM mailbox WHERE username = 'postmaster@${FIRST_DOMAIN}'`))
+ .toBe(STUB_HASH);
+ }, 60_000);
+
+ it("is idempotent: a second run skips instead of re-seeding", async () => {
+ const r = await runBootstrap();
+ expect(r.code).toBe(0);
+ expect(r.log).toMatch(/already present — skipping/i);
+ expect(await query("SELECT count(*) FROM domain")).toBe("1");
+ }, 120_000);
+
+ it("FAILS instead of reporting success when doveadm produces nothing", async () => {
+ // Reproduces a mail image without doveadm. Before the fix this seeded an empty
+ // password and exited 0; the operator learned about it when auth silently failed.
+ await setDoveadmStub("#!/bin/sh\nexit 127");
+ try {
+ // Drop the gate so the run gets past the idempotency check to the hash step.
+ await docker(["exec", "--env", `PGPASSWORD=${PG_PASSWORD}`, RUNNER,
+ "psql", "-h", DB, "-U", "postgres", "-d", "vmail", "-c", "DROP TABLE mailbox CASCADE"]);
+
+ const r = await runBootstrap();
+
+ expect(r.code).not.toBe(0);
+ expect(r.log).toMatch(/doveadm pw produced no output|is doveadm installed/i);
+ // And it must not have claimed success on the way out.
+ expect(r.log).not.toMatch(/DB bootstrap complete/);
+ } finally {
+ await setDoveadmStub(`#!/bin/sh\necho '${STUB_HASH}'`);
+ }
+ }, 180_000);
+
+ it("FAILS with a diagnosable message when the database never answers", async () => {
+ const r = await runBootstrap({
+ // TEST-NET-3 drops rather than refuses, which is the realistic bad case (a
+ // firewall, a wrong host). It is also the case that proves PGCONNECT_TIMEOUT is
+ // doing its job: without it libpq blocks for the OS default and the wait budget
+ // below never gets a second iteration.
+ OPENSHIP_MAIL_DB_HOST: "203.0.113.1",
+ OPENSHIP_MAIL_DB_WAIT_SECS: "4",
+ PGCONNECT_TIMEOUT: "2",
+ });
+
+ expect(r.code).not.toBe(0);
+ expect(r.log).toMatch(/did not accept queries within 4s/);
+ // The message has to point somewhere. A bare "failed" is what made this a
+ // multi-hour hunt in the field.
+ expect(r.log).toMatch(/docker logs openship-mail-db/);
+ expect(r.log).not.toMatch(/DB bootstrap complete/);
+ }, 180_000);
+
+ it("negative control: the harness can still observe a failing script", async () => {
+ // A container test that quietly stopped running the script would look exactly
+ // like a passing one. Prove a non-zero exit is actually detected.
+ const r = await docker(["exec", RUNNER, "bash", "-c", "exit 3"], { allowFail: true });
+ expect(r.code).toBe(3);
+ }, 60_000);
+});
diff --git a/apps/api/test/e2e/update-from-previous-release.e2e.test.ts b/apps/api/test/e2e/update-from-previous-release.e2e.test.ts
new file mode 100644
index 000000000..fa1865e61
--- /dev/null
+++ b/apps/api/test/e2e/update-from-previous-release.e2e.test.ts
@@ -0,0 +1,444 @@
+/**
+ * THE UPDATE ITSELF: last release's stack, real data in it, recreated onto this
+ * release's image. Real daemon, real Postgres, real containers.
+ *
+ * This is the case every other suite in the repo approximates and none of them runs.
+ * `migrate-chain` (packages/db) proves the migration chain applies to a populated
+ * database, but on PGlite — Postgres compiled to WASM, close but not the
+ * `postgres:16-alpine` an operator actually has. Nothing anywhere booted the PREVIOUS
+ * RELEASE and then updated it, which is the only sequence an existing user ever
+ * performs, and the one that produced "openship-api-1 enters a crash loop after
+ * `openship update`" from an operator whose 0.6.1 install was already unhealthy.
+ *
+ * What it does:
+ * 1. Brings up postgres + redis + the PREVIOUS release's api image, waits for
+ * /api/health, and confirms that api actually migrated the database.
+ * 2. Seeds rows into core tables, so what follows runs against a NON-EMPTY schema.
+ * 3. Repins the api image to THIS release's and runs the same command
+ * `openship update` runs — `up -d --force-recreate api` — against the same
+ * volume, same .env, same network.
+ * 4. Asserts the new api reaches /api/health, applied the remaining migrations,
+ * kept every seeded row, and did NOT bounce on the way (RestartCount 0 — a
+ * container that crash-looped its way to healthy is the failure being hunted).
+ *
+ * Deliberately narrower than `openship up`: postgres + redis + api, not edge/mail/
+ * dashboard. The compose file below mirrors the shipped template for those three
+ * services (apps/cli/src/lib/compose.ts) — how the CLI GENERATES that file is covered
+ * by apps/cli/test/unit; what this owns is whether the update survives.
+ *
+ * Two inputs, because a checkout has neither side of an upgrade in it:
+ * OPENSHIP_E2E_NEW_API_IMAGE the api image for the NEW side. CI passes the digest
+ * `build-images` just pushed, so the bytes under test are
+ * the bytes about to be tagged. Unset → built from this
+ * checkout (slow, but makes the test runnable anywhere).
+ * OPENSHIP_E2E_OLD_VERSION the release to upgrade FROM. Unset → the newest
+ * `v*.*.*` tag that isn't the one at HEAD.
+ *
+ * Opt-in via `E2E_SCOPE=update` (see vitest.e2e.config.ts): it pulls a published
+ * release, which a plain local checkout has no reason to do on every run.
+ */
+
+import { beforeAll, afterAll, expect, it } from "vitest";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createServer } from "node:net";
+import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e";
+
+const REGISTRY = process.env.OPENSHIP_E2E_IMAGE_REGISTRY ?? "ghcr.io/oblien";
+const PROJECT = `openship-e2e-update-${process.pid}`;
+const SEED_ID = "e2e-upgrade-seed";
+const SEEDED_TABLES = ["organization", "project", "deployment", "servers"];
+
+/** Long enough for a cold pull + initdb + the whole migration chain on a CI runner. */
+const BOOT_TIMEOUT_MS = 240_000;
+
+/**
+ * Budgets, as a hierarchy rather than three independent numbers.
+ *
+ * The first version of this file had them uncoordinated, and the arithmetic bit
+ * immediately: 3 pull attempts × 10min == the 30min `beforeAll` ceiling exactly, so on a
+ * slow link the setup could burn its ENTIRE budget fetching and leave nothing for the
+ * upgrade it exists to run — which is precisely what happened (three attempts killed
+ * mid-download, zero seconds spent testing). Fetching must be a fraction of setup, and
+ * visibly so.
+ */
+const PULL_TIMEOUT_MS = 600_000;
+const PULL_ATTEMPTS = 2;
+const BUILD_TIMEOUT_MS = 1_800_000;
+/** Fetching (pull + a possible local build) plus boot, with room left over. */
+const SETUP_TIMEOUT_MS = PULL_TIMEOUT_MS * PULL_ATTEMPTS + BUILD_TIMEOUT_MS + BOOT_TIMEOUT_MS;
+
+let workdir = "";
+let apiPort = 0;
+let oldVersion = "";
+let newApiImage = "";
+let migrationsAfterOld = 0;
+let oldApiImageId = "";
+
+function sh(
+ cmd: string,
+ args: string[],
+ opts: { cwd?: string; timeoutMs?: number } = {},
+): { code: number; stdout: string; stderr: string } {
+ const res = spawnSync(cmd, args, {
+ cwd: opts.cwd,
+ encoding: "utf8",
+ timeout: opts.timeoutMs ?? 300_000,
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
+}
+
+function compose(args: string[]): { code: number; stdout: string; stderr: string } {
+ return sh("docker", ["compose", "-p", PROJECT, ...args], { cwd: workdir });
+}
+
+/** Fail with the command's own output — a red release gate must say why on line one. */
+function composeOrThrow(args: string[], what: string): string {
+ const r = compose(args);
+ if (r.code !== 0) {
+ throw new Error(`${what} failed (exit ${r.code})\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}`);
+ }
+ return r.stdout;
+}
+
+async function freePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const srv = createServer();
+ srv.once("error", reject);
+ srv.listen(0, "127.0.0.1", () => {
+ const port = (srv.address() as { port: number }).port;
+ srv.close(() => resolve(port));
+ });
+ });
+}
+
+/**
+ * The release to upgrade from: newest `v*.*.*` tag that isn't the one being built.
+ * Prereleases are skipped — an rc is not what an operator is upgrading from.
+ */
+function resolvePreviousVersion(): string {
+ const override = process.env.OPENSHIP_E2E_OLD_VERSION?.trim();
+ if (override) return override.replace(/^v/, "");
+
+ const atHead = new Set(
+ sh("git", ["tag", "--points-at", "HEAD"]).stdout.split("\n").map((t) => t.trim()).filter(Boolean),
+ );
+ const tags = sh("git", ["tag", "--list", "v*.*.*", "--sort=-v:refname"]).stdout
+ .split("\n")
+ .map((t) => t.trim())
+ .filter((t) => t && !t.includes("-") && !atHead.has(t));
+
+ if (!tags.length) {
+ throw new Error(
+ "No previous v*.*.* tag to upgrade from. CI must check out with fetch-depth: 0, " +
+ "or set OPENSHIP_E2E_OLD_VERSION.",
+ );
+ }
+ return tags[0]!.replace(/^v/, "");
+}
+
+/**
+ * Pull the old side up front, with retries.
+ *
+ * Registry reads flake — a `manifest inspect` against a tag that demonstrably exists
+ * returned "not found" once while this was being written. A gate that blocks releases
+ * must not turn that into a red release, but it must not paper over a genuinely missing
+ * image either: retry, then fail naming the ref and the override. Deliberately NOT
+ * "walk back to an older release that does resolve" — that would quietly test an
+ * upgrade nobody asked about.
+ */
+function pullOrThrow(ref: string, attempts = PULL_ATTEMPTS): void {
+ let last = "";
+ for (let i = 1; i <= attempts; i += 1) {
+ const r = sh("docker", ["pull", ref], { timeoutMs: PULL_TIMEOUT_MS });
+ if (r.code === 0) return;
+ last = `${r.stdout}\n${r.stderr}`.trim();
+ }
+ throw new Error(
+ `Could not pull ${ref} after ${attempts} attempts — the previous release's image has to ` +
+ `exist for there to be an upgrade to test. Override with OPENSHIP_E2E_OLD_VERSION ` +
+ `if this release genuinely has no published predecessor.\n${last}`,
+ );
+}
+
+/**
+ * The api image for the new side. CI hands us the digest `build-images` pushed; without
+ * one we build this checkout, so the test is runnable on a laptop.
+ */
+function resolveNewApiImage(): string {
+ const override = process.env.OPENSHIP_E2E_NEW_API_IMAGE?.trim();
+ if (override) return override;
+
+ const tag = `openship/e2e-update-api:${process.pid}`;
+ const repoRoot = join(import.meta.dirname, "..", "..", "..", "..");
+ const built = sh(
+ "docker",
+ ["build", "-f", join("apps", "api", "Dockerfile"), "-t", tag, "."],
+ { cwd: repoRoot, timeoutMs: BUILD_TIMEOUT_MS },
+ );
+ if (built.code !== 0) {
+ throw new Error(
+ `Could not build the api image for the new side.\n${built.stdout}\n${built.stderr}`,
+ );
+ }
+ return tag;
+}
+
+/**
+ * postgres + redis + api, mirroring the shipped template: PGDATA in a SUBDIRECTORY of
+ * the volume (#350), a pg_isready healthcheck, and — the part this test exists to
+ * exercise — `depends_on: {condition: service_healthy}` on the api.
+ *
+ * The api image is a variable so phase 2 can repin it the way `openship update` repins
+ * OPENSHIP_VERSION, and recreate against the same volume.
+ */
+const COMPOSE_YAML = `services:
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ PGDATA: /var/lib/postgresql/data/pgdata
+ POSTGRES_USER: \${POSTGRES_USER}
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
+ POSTGRES_DB: \${POSTGRES_DB}
+ expose: ["5432"]
+ volumes: [postgres_data:/var/lib/postgresql/data]
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ command: ["redis-server", "--appendonly", "yes"]
+ expose: ["6379"]
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+
+ api:
+ image: \${OPENSHIP_API_IMAGE}
+ restart: unless-stopped
+ ports: ["127.0.0.1:\${API_PORT}:\${API_PORT}"]
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env_file: [.env]
+ environment:
+ NODE_ENV: production
+ PORT: "\${API_PORT}"
+ DATABASE_URL: postgresql://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@postgres:5432/\${POSTGRES_DB}
+ REDIS_URL: redis://redis:6379
+ depends_on:
+ postgres: { condition: service_healthy }
+ redis: { condition: service_healthy }
+
+volumes:
+ postgres_data:
+`;
+
+function writeEnv(apiImage: string): void {
+ writeFileSync(
+ join(workdir, ".env"),
+ [
+ `OPENSHIP_API_IMAGE=${apiImage}`,
+ `API_PORT=${apiPort}`,
+ "POSTGRES_USER=openship",
+ "POSTGRES_PASSWORD=e2e-upgrade-password",
+ "POSTGRES_DB=openship",
+ // Stable across both phases, exactly as `openship update` carries them forward:
+ // a new BETTER_AUTH_SECRET would make every stored env var undecryptable (#488).
+ "BETTER_AUTH_SECRET=e2e0000000000000000000000000000000000000000000000000000000000000f",
+ "INTERNAL_TOKEN=e2e-internal-token",
+ "",
+ ].join("\n"),
+ );
+}
+
+/** One psql statement batch as the cluster superuser. */
+function psql(sql: string): string {
+ const r = compose([
+ "exec",
+ "-T",
+ "postgres",
+ "psql",
+ "-v",
+ "ON_ERROR_STOP=1",
+ "-U",
+ "openship",
+ "-d",
+ "openship",
+ "-tAc",
+ sql,
+ ]);
+ if (r.code !== 0) {
+ throw new Error(`psql failed (exit ${r.code})\nSQL: ${sql}\n${r.stdout}\n${r.stderr}`);
+ }
+ return r.stdout.trim();
+}
+
+function appliedMigrations(): number {
+ return Number(psql(`select count(*) from drizzle."__drizzle_migrations"`));
+}
+
+/**
+ * Insert one row into `table`, with the INSERT built from the schema AS THE OLD RELEASE
+ * LEFT IT. Introspection rather than a column list, for the same reason as the
+ * migrate-chain suite: the old side moves every release, and a hardcoded list would
+ * break on the first migration that touched any of these tables.
+ */
+function seedRow(table: string): void {
+ psql(`
+ SET session_replication_role = replica;
+ DO $seed$
+ DECLARE cols text; vals text;
+ BEGIN
+ SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position),
+ string_agg(
+ CASE
+ WHEN column_name = 'id' THEN quote_literal('${SEED_ID}')
+ WHEN data_type IN ('text','character varying','character') THEN quote_literal('seed')
+ WHEN data_type = 'uuid' THEN quote_literal('00000000-0000-0000-0000-000000000001')
+ WHEN data_type LIKE 'timestamp%' OR data_type = 'date' THEN 'now()'
+ WHEN data_type = 'boolean' THEN 'false'
+ WHEN data_type IN ('integer','bigint','smallint','numeric','real','double precision') THEN '0'
+ WHEN data_type IN ('json','jsonb') THEN quote_literal('{}')
+ WHEN data_type = 'ARRAY' THEN quote_literal('{}')
+ ELSE quote_literal('seed')
+ END, ', ' ORDER BY ordinal_position)
+ INTO cols, vals
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = '${table}'
+ AND is_generated = 'NEVER'
+ AND identity_generation IS NULL
+ -- id is forced in even when it has a default: the assertions find the row by it.
+ AND (column_name = 'id' OR (is_nullable = 'NO' AND column_default IS NULL));
+ EXECUTE format('INSERT INTO %I (%s) VALUES (%s)', '${table}', cols, vals);
+ END
+ $seed$;
+ `);
+}
+
+function rowCount(table: string): number {
+ return Number(psql(`select count(*) from "${table}" where id = '${SEED_ID}'`));
+}
+
+async function waitForHealth(label: string): Promise {
+ const deadline = Date.now() + BOOT_TIMEOUT_MS;
+ let lastErr = "";
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${apiPort}/api/health`);
+ if (res.ok) return;
+ lastErr = `HTTP ${res.status}`;
+ } catch (err) {
+ lastErr = err instanceof Error ? err.message : String(err);
+ }
+ await new Promise((r) => setTimeout(r, 1_000));
+ }
+ // The api's own logs, or the failure names nothing actionable.
+ const logs = compose(["logs", "--tail", "60", "api"]).stdout;
+ throw new Error(
+ `${label}: /api/health never became ready within ${BOOT_TIMEOUT_MS / 1000}s ` +
+ `(last: ${lastErr})\n--- api logs ---\n${logs}`,
+ );
+}
+
+function apiContainerId(): string {
+ const id = compose(["ps", "-q", "api"]).stdout.trim().split("\n")[0] ?? "";
+ expect(id, "api container should exist").not.toBe("");
+ return id;
+}
+
+function apiRestartCount(): number {
+ return Number(
+ sh("docker", ["inspect", "--format", "{{.RestartCount}}", apiContainerId()]).stdout.trim(),
+ );
+}
+
+/**
+ * The image the api container is actually RUNNING, as a content ID.
+ *
+ * Compared before/after rather than the image reference: both sides are the same repo,
+ * so a name check ("does it contain openship-api") passes identically for the old
+ * release and proves nothing about which one is up.
+ */
+function apiImageId(): string {
+ return sh("docker", ["inspect", "--format", "{{.Image}}", apiContainerId()]).stdout.trim();
+}
+
+describeDockerE2E("update from the previous release", () => {
+ beforeAll(async () => {
+ await requireDocker();
+
+ oldVersion = resolvePreviousVersion();
+ apiPort = await freePort();
+ workdir = mkdtempSync(join(tmpdir(), "openship-e2e-update-"));
+ writeFileSync(join(workdir, "docker-compose.yml"), COMPOSE_YAML);
+
+ // ── Phase 1: the previous release, exactly as an operator is running it now.
+ pullOrThrow(`${REGISTRY}/openship-api:${oldVersion}`);
+ writeEnv(`${REGISTRY}/openship-api:${oldVersion}`);
+ composeOrThrow(["up", "-d"], `bringing up the ${oldVersion} stack`);
+ await waitForHealth(`previous release (${oldVersion})`);
+
+ migrationsAfterOld = appliedMigrations();
+ oldApiImageId = apiImageId();
+
+ // ── Phase 2's precondition: rows, so the migrations below run against data.
+ for (const table of SEEDED_TABLES) seedRow(table);
+
+ newApiImage = resolveNewApiImage();
+ }, SETUP_TIMEOUT_MS);
+
+ afterAll(() => {
+ if (workdir) {
+ compose(["down", "-v", "--remove-orphans"]);
+ rmSync(workdir, { recursive: true, force: true });
+ }
+ }, 300_000);
+
+ it("the previous release migrated the database and holds the seeded rows", () => {
+ // Anti-vacuity: if the old api never migrated, or the seeding quietly did nothing,
+ // then "the upgrade worked" below would be a statement about an empty database.
+ expect(migrationsAfterOld).toBeGreaterThan(10);
+ for (const table of SEEDED_TABLES) {
+ expect(rowCount(table), `${table} was not seeded on ${oldVersion}`).toBe(1);
+ }
+ });
+
+ it("recreates onto this release's image and comes up healthy, with the data intact", async () => {
+ // The update: repin the image and force-recreate ONLY the api, which is what
+ // `openship update` does (composeUpdate → composeUp → up -d --force-recreate).
+ writeEnv(newApiImage);
+ composeOrThrow(["up", "-d", "--force-recreate", "api"], "recreating the api onto the new image");
+
+ await waitForHealth("new release");
+
+ // Migrations moved forward (or were already complete) and nothing was lost.
+ const after = appliedMigrations();
+ expect(after).toBeGreaterThanOrEqual(migrationsAfterOld);
+ for (const table of SEEDED_TABLES) {
+ expect(rowCount(table), `${table} lost its row across the update`).toBe(1);
+ }
+
+ // Healthy is not enough: `restart: unless-stopped` will bounce a container until
+ // something works, so a crash loop that eventually succeeded still looks healthy
+ // here. It must have come up on the FIRST attempt.
+ expect(apiRestartCount(), "the api restarted on the way up — that is the crash loop").toBe(0);
+
+ // And the running image actually CHANGED, or everything above is a statement about
+ // the old release still being up.
+ expect(oldApiImageId, "phase 1 image id should have been captured").not.toBe("");
+ expect(apiImageId(), "the api is still running the previous release's image").not.toBe(
+ oldApiImageId,
+ );
+ }, 600_000);
+});
diff --git a/apps/api/test/lib/call-source.test.ts b/apps/api/test/lib/call-source.test.ts
index b03b840be..1123f64ef 100644
--- a/apps/api/test/lib/call-source.test.ts
+++ b/apps/api/test/lib/call-source.test.ts
@@ -3,8 +3,11 @@ import { Hono } from "hono";
import type { Context } from "hono";
import {
ambientCallSource,
+ internalClientHeader,
internalSourceHeader,
+ isAuditClientId,
isAuditSource,
+ resolveCallClientId,
resolveCallSource,
runWithCallSource,
} from "../../src/lib/call-source";
@@ -38,6 +41,20 @@ async function sourceFor(opts: {
return ((await res.json()) as { source: string }).source;
}
+/** Resolve the source CLIENT for one synthetic request. */
+async function clientFor(opts: {
+ headers?: Record;
+ ctx?: Record;
+}): Promise {
+ const app = new Hono();
+ app.get("/probe", (c: Context) => {
+ if (opts.ctx) c.set("ctx" as never, opts.ctx as never);
+ return c.json({ client: resolveCallClientId(c) });
+ });
+ const res = await app.request("/probe", { headers: opts.headers });
+ return ((await res.json()) as { client: string | null }).client;
+}
+
const CLI_UA = "openship-cli/0.5.0";
const BROWSER_UA = "Mozilla/5.0 (Macintosh)";
@@ -163,6 +180,55 @@ describe("ambient source for emitters outside the handler chain", () => {
});
});
+describe("which client of the surface", () => {
+ it("trusts the client id the MCP dispatcher signs", async () => {
+ expect(await clientFor({ headers: internalClientHeader("oauth:cli_abc") })).toBe("oauth:cli_abc");
+ expect(await clientFor({ headers: internalClientHeader("pat:pat_123") })).toBe("pat:pat_123");
+ });
+
+ it("is null when nobody claimed one — never guessed from the credential", async () => {
+ // A browser and the CLI have one client per request; a value here would be
+ // invented rather than reported.
+ expect(await clientFor({ ctx: cookieCtx })).toBeNull();
+ expect(await clientFor({ ctx: patCtx, headers: { "user-agent": CLI_UA } })).toBeNull();
+ });
+
+ it("ignores a forged claim — attribution a caller can write is worse than none", async () => {
+ const forged = { "x-openship-call-client": "oauth:someone-elses-agent.deadbeefdeadbeef" };
+ expect(await clientFor({ headers: forged })).toBeNull();
+ expect(await clientFor({ headers: { "x-openship-call-client": "oauth:x" } })).toBeNull();
+ });
+
+ it("rejects a correctly-signed id that is not a principal id", async () => {
+ // The nonce proves we sent it; it does not prove we assembled it from
+ // something the column should hold, and the value reaches the audit UI.
+ const nonce = internalSourceHeader("mcp")["x-openship-call-source"]!.split(".").pop()!;
+ for (const bad of ["cli_abc", "oauth:", "session:abc", `oauth:${"x".repeat(129)}`, "oauth:a b"]) {
+ expect(await clientFor({ headers: { "x-openship-call-client": `${bad}.${nonce}` } }), bad).toBeNull();
+ }
+ });
+
+ it("travels independently of the source claim", async () => {
+ // The dispatcher sends both; a request carrying only one is still coherent.
+ expect(
+ await sourceFor({ headers: internalClientHeader("oauth:cli_abc"), ctx: patCtx }),
+ ).toBe("api");
+ expect(await clientFor({ headers: internalSourceHeader("mcp") })).toBeNull();
+ });
+});
+
+describe("isAuditClientId", () => {
+ it("accepts the two principal-id shapes and nothing else", () => {
+ expect(isAuditClientId("oauth:cli_abc")).toBe(true);
+ expect(isAuditClientId("pat:pat_9f2.a-b")).toBe(true);
+ expect(isAuditClientId("oauth:")).toBe(false);
+ expect(isAuditClientId("pat")).toBe(false);
+ expect(isAuditClientId("user:u1")).toBe(false);
+ expect(isAuditClientId("")).toBe(false);
+ expect(isAuditClientId(null)).toBe(false);
+ });
+});
+
describe("isAuditSource", () => {
it("accepts the stored values and nothing else", () => {
for (const s of ["dashboard", "mcp", "cli", "api", "webhook", "system"]) {
diff --git a/apps/api/test/lib/domain-claims.test.ts b/apps/api/test/lib/domain-claims.test.ts
new file mode 100644
index 000000000..54960191d
--- /dev/null
+++ b/apps/api/test/lib/domain-claims.test.ts
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The GENERAL question the general routing paths ask.
+ *
+ * Both of them — the deploy path (`lib/routing-domains`) and the service-domain edit path
+ * (`modules/domains/domain.service`) — used to call the MAIL predicate directly, each
+ * carrying its own copy of the mail-specific reasoning inside logic that is otherwise
+ * subsystem-blind. `domain.owner_type` was already general (`project`, `webhook`, `mail`);
+ * the code just had no matching question.
+ *
+ * What is pinned here is the DISPATCH, not mail's rules — those stay in
+ * mail-host-claim.test.ts. Namely: a hostname no subsystem speaks for is refused without
+ * touching the database, a claim's answer is passed through in both directions, and one
+ * claim throwing cannot turn the caller's conflict error into a 500.
+ */
+
+const { findByDomain, getServer, findProject } = vi.hoisted(() => ({
+ findByDomain: vi.fn(),
+ getServer: vi.fn(),
+ findProject: vi.fn(),
+}));
+
+vi.mock("@repo/db", () => ({
+ repos: {
+ mailServer: { findByDomain },
+ server: { get: getServer },
+ project: { findById: findProject },
+ },
+}));
+
+import { routableWithoutOwnership } from "../../src/lib/domain-claims";
+
+const MAIL_ROW = {
+ hostname: "mail.example.com",
+ ownerType: "mail",
+ projectId: null,
+} as never;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ findByDomain.mockResolvedValue({
+ serverId: "srv_mail",
+ domain: "example.com",
+ webmailProjectId: "prj_webmail",
+ });
+ getServer.mockResolvedValue({ id: "srv_mail", organizationId: "org_1" });
+ findProject.mockResolvedValue({ id: "prj_webmail", organizationId: "org_1" });
+});
+
+describe("routableWithoutOwnership", () => {
+ it("passes a registered claim's yes through", async () => {
+ expect(await routableWithoutOwnership("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(true);
+ });
+
+ it("passes a registered claim's no through", async () => {
+ // Same hostname, a project the mail server does not link to. The dispatcher must not
+ // soften a claim's refusal — the caller's hijack guard depends on getting `false`.
+ expect(await routableWithoutOwnership("mail.example.com", "prj_other", MAIL_ROW)).toBe(false);
+ });
+
+ it("refuses a hostname no subsystem speaks for, without a query", async () => {
+ // The general paths call this for EVERY hostname on every deploy and domain edit, so
+ // "no claim applies" has to be the cheap answer. If a claim ever starts hitting the
+ // database before ruling itself out, this catches it as a cost regression.
+ expect(await routableWithoutOwnership("app.example.com", "prj_webmail", null)).toBe(false);
+ expect(findByDomain).not.toHaveBeenCalled();
+ expect(getServer).not.toHaveBeenCalled();
+ });
+
+ it("still answers for a hostname with no row at all", async () => {
+ // A claim can legitimately apply where nothing was ever recorded (mail's cert row is
+ // written best-effort). If the dispatcher required a row, the caller would fall
+ // through and MINT a project-owned row for the very host the claim protects.
+ expect(await routableWithoutOwnership("mail.example.com", "prj_webmail", null)).toBe(true);
+ });
+
+ it("does not let a claim's failure become the caller's error", async () => {
+ // Consulted on the way to a ConflictError. A thrown lookup must leave that refusal
+ // standing, not replace a clear 409 with a 500 on an unrelated deploy.
+ findByDomain.mockRejectedValue(new Error("db down"));
+ await expect(
+ routableWithoutOwnership("mail.example.com", "prj_webmail", MAIL_ROW),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/apps/api/test/lib/env-reveal-gating.test.ts b/apps/api/test/lib/env-reveal-gating.test.ts
index 379b1f1d8..600e1cf18 100644
--- a/apps/api/test/lib/env-reveal-gating.test.ts
+++ b/apps/api/test/lib/env-reveal-gating.test.ts
@@ -25,17 +25,20 @@ describe("#336 env reveal is write-gated; masked reads need only read", () => {
return isPublicSpec(spec) ? "PUBLIC" : spec.tag;
};
- // Reveal endpoints — write-gated.
+ // Reveal endpoints — write-gated. All POST: the requested key names travel in
+ // the body (not a URL that lands in proxy logs and browser history).
expect(
tagOf(
- (r) => r.method === "GET" && r.path.endsWith("/env-reveal") && r.path.includes("/services/"),
+ (r) =>
+ r.method === "POST" && r.path.endsWith("/env-reveal") && r.path.includes("/services/"),
"service env-reveal",
),
).toBe("project:service:write");
expect(
tagOf(
- (r) => r.method === "GET" && r.path.endsWith("/env-reveal") && r.path.includes("/folder/scan/"),
+ (r) =>
+ r.method === "POST" && r.path.endsWith("/env-reveal") && r.path.includes("/folder/scan/"),
"folder-scan env-reveal",
),
).toBe("project:write");
diff --git a/apps/api/test/lib/env-reveal-keys.test.ts b/apps/api/test/lib/env-reveal-keys.test.ts
new file mode 100644
index 000000000..f298cc575
--- /dev/null
+++ b/apps/api/test/lib/env-reveal-keys.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import { AppError } from "@repo/core";
+import { MAX_REVEAL_KEYS, parseRevealKeys, pickRevealed } from "../../src/lib/env-reveal";
+
+/**
+ * Per-key reveal (#336 follow-up): opening ONE row's eye must disclose ONE secret.
+ * Before this, every reveal endpoint answered with the source's whole env map, so
+ * seeing `SMTP_HOST` also handed the browser `SMTP_PASS` and 30 others.
+ *
+ * Two properties are locked in here: a reveal request must NAME what it wants
+ * (there is no "give me everything" shape), and the response carries nothing but
+ * those names.
+ */
+describe("env reveal keys", () => {
+ const env = { NODE_ENV: "production", SMTP_PASS: "s3cret", EMPTY: "" };
+
+ it("returns only the requested keys", () => {
+ expect(pickRevealed(env, ["SMTP_PASS"])).toEqual({ SMTP_PASS: "s3cret" });
+ expect(pickRevealed(env, ["NODE_ENV", "EMPTY"])).toEqual({ NODE_ENV: "production", EMPTY: "" });
+ });
+
+ it("omits keys the source doesn't have, rather than erroring", () => {
+ expect(pickRevealed(env, ["NOPE"])).toEqual({});
+ expect(pickRevealed(null, ["NODE_ENV"])).toEqual({});
+ });
+
+ it("never resolves a key off the prototype chain", () => {
+ // `key in env` would answer with Object.prototype.constructor here.
+ expect(pickRevealed(env, ["constructor", "toString", "__proto__"])).toEqual({});
+ });
+
+ it("rejects a request that names nothing", () => {
+ for (const bad of [undefined, null, [], {}, "SMTP_PASS"]) {
+ expect(() => parseRevealKeys(bad)).toThrow(AppError);
+ }
+ });
+
+ it("rejects non-string, empty and oversized key names", () => {
+ expect(() => parseRevealKeys(["OK", 1])).toThrow(AppError);
+ expect(() => parseRevealKeys([""])).toThrow(AppError);
+ expect(() => parseRevealKeys(["x".repeat(513)])).toThrow(AppError);
+ });
+
+ it("bounds one request and dedupes", () => {
+ expect(parseRevealKeys(["A", "A", "B"])).toEqual(["A", "B"]);
+ expect(() => parseRevealKeys(Array.from({ length: MAX_REVEAL_KEYS + 1 }, (_, i) => `K${i}`))).toThrow(
+ AppError,
+ );
+ });
+
+ it("answers 400, not 500 — a bad keys list is a client error", () => {
+ try {
+ parseRevealKeys([]);
+ expect.unreachable();
+ } catch (err) {
+ expect((err as AppError).statusCode).toBe(400);
+ }
+ });
+});
diff --git a/apps/api/test/lib/loopback-publish.test.ts b/apps/api/test/lib/loopback-publish.test.ts
index 060455907..ca36fb547 100644
--- a/apps/api/test/lib/loopback-publish.test.ts
+++ b/apps/api/test/lib/loopback-publish.test.ts
@@ -1,5 +1,82 @@
import { describe, it, expect } from "vitest";
-import { specContainerPort, withLoopbackPublish } from "../../src/lib/loopback-publish";
+import {
+ specContainerPort,
+ withLoopbackPublish,
+ withLoopbackPublishAll,
+ upstreamHostPortFor,
+} from "../../src/lib/loopback-publish";
+
+describe("a service with SEVERAL routes gets one host port per routed port", () => {
+ // Regression: minio routes 9001 (console) and 9000 (s3 API). Pinning only the
+ // first route's port and then reusing that single host port for every route made
+ // the `s3` subdomain serve the console.
+ const pinned = new Map([
+ [9001, 20500],
+ [9000, 20501],
+ ]);
+
+ it("publishes a DISTINCT loopback binding per routed container port", () => {
+ expect(withLoopbackPublishAll([], pinned)).toEqual([
+ "127.0.0.1:20500:9001",
+ "127.0.0.1:20501:9000",
+ ]);
+ });
+
+ it("replaces a template's own binding for each routed port and keeps the rest", () => {
+ const out = withLoopbackPublishAll(["9000:9000", "9001:9001", "1234:1234"], pinned);
+ expect(out).toContain("127.0.0.1:20500:9001");
+ expect(out).toContain("127.0.0.1:20501:9000");
+ expect(out).toContain("1234:1234");
+ expect(out.filter((s) => s.endsWith(":9000"))).toHaveLength(1);
+ expect(out.filter((s) => s.endsWith(":9001"))).toHaveLength(1);
+ });
+
+ it("resolves each route to ITS OWN host port, never the primary's", () => {
+ expect(upstreamHostPortFor({ port: 9001, pinned, primaryPort: 9001, sameService: true })).toBe(
+ 20500,
+ );
+ expect(upstreamHostPortFor({ port: 9000, pinned, primaryPort: 9001, sameService: true })).toBe(
+ 20501,
+ );
+ });
+
+ it("never lends the daemon's single reported port to a SECONDARY route", () => {
+ const only = new Map();
+ // The primary may fall back to what the deploy reported...
+ expect(
+ upstreamHostPortFor({
+ port: 9001,
+ pinned: only,
+ primaryPort: 9001,
+ resultHostPort: 33333,
+ sameService: true,
+ }),
+ ).toBe(33333);
+ // ...but a secondary port must resolve to nothing, so the caller uses
+ // container-IP addressing rather than proxying to the wrong service.
+ expect(
+ upstreamHostPortFor({
+ port: 9000,
+ pinned: only,
+ primaryPort: 9001,
+ resultHostPort: 33333,
+ sameService: true,
+ }),
+ ).toBeUndefined();
+ });
+
+ it("ignores a reported port from a DIFFERENT container", () => {
+ expect(
+ upstreamHostPortFor({
+ port: 9001,
+ pinned: new Map(),
+ primaryPort: 9001,
+ resultHostPort: 33333,
+ sameService: false,
+ }),
+ ).toBeUndefined();
+ });
+});
describe("specContainerPort", () => {
it("parses every docker port-spec form", () => {
diff --git a/apps/api/test/lib/mail-host-claim.test.ts b/apps/api/test/lib/mail-host-claim.test.ts
new file mode 100644
index 000000000..818ef572f
--- /dev/null
+++ b/apps/api/test/lib/mail-host-claim.test.ts
@@ -0,0 +1,133 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The mail server's own hostname is the one host a project may route without owning a
+ * row — and it must be exactly one project, exactly one host.
+ *
+ * `mail. ` is recorded with `ownerType='mail', project_id=NULL` so the renewal sweep
+ * can find its certificate. Both hijack guards compare `owner.projectId !== projectId`,
+ * which NULL never satisfies, so the webmail the mail module itself deploys onto that host
+ * was refused at install and silently unrouted at deploy (#566).
+ *
+ * What is pinned here is the TIGHTNESS. The link — `mail_servers.webmail_project_id` — is
+ * the authorization, not "a project that looks like a webmail", because the loose version
+ * would let an ordinary service edit take over the mail hostname.
+ */
+
+const { findByDomain, getServer, findProject } = vi.hoisted(() => ({
+ findByDomain: vi.fn(),
+ getServer: vi.fn(),
+ findProject: vi.fn(),
+}));
+
+vi.mock("@repo/db", () => ({
+ repos: {
+ mailServer: { findByDomain },
+ server: { get: getServer },
+ project: { findById: findProject },
+ },
+}));
+
+import { mailHostBaseDomain } from "@repo/core";
+import { mailHostRoutableByProject } from "../../src/lib/mail-host-claim";
+
+const MAIL_ROW = {
+ hostname: "mail.example.com",
+ ownerType: "mail",
+ projectId: null,
+} as never;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ findByDomain.mockResolvedValue({
+ serverId: "srv_mail",
+ domain: "example.com",
+ webmailProjectId: "prj_webmail",
+ });
+ getServer.mockResolvedValue({ id: "srv_mail", organizationId: "org_1" });
+ findProject.mockResolvedValue({ id: "prj_webmail", organizationId: "org_1" });
+});
+
+// The local `mailBaseDomain` this used to cover is gone: it carried its own
+// `/^mail\./` regex while ~40 other sites hand-wrote the forward template, so the
+// build and the parse were two independent facts. Both now derive from
+// MAIL_HOST_LABEL in @repo/core — same behaviour, one definition.
+describe("mailHostBaseDomain", () => {
+ it("strips only a leading mail label", () => {
+ expect(mailHostBaseDomain("mail.example.com")).toBe("example.com");
+ expect(mailHostBaseDomain("MAIL.Example.COM")).toBe("example.com");
+ expect(mailHostBaseDomain("example.com")).toBeNull();
+ expect(mailHostBaseDomain("webmail.example.com")).toBeNull();
+ });
+
+ it("rejects a host whose base is not a domain", () => {
+ // `mail.com` used to strip to the bare TLD `com` and then cost a pointless
+ // mail-server lookup. No install's user domain is a single label.
+ expect(mailHostBaseDomain("mail.com")).toBeNull();
+ });
+});
+
+describe("mailHostRoutableByProject", () => {
+ it("lets the LINKED webmail route its mail server's host", async () => {
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(true);
+ });
+
+ it("answers for a mail host with no row at all", async () => {
+ // recordMailCertDomain is best-effort, so the row can be missing. Without this the
+ // caller mints a PROJECT-owned row for the mail host and the next mail install
+ // refuses to record its certificate against it.
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", null)).toBe(true);
+ });
+
+ it("refuses a project that is not the linked webmail", async () => {
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_other", MAIL_ROW)).toBe(false);
+ });
+
+ it("refuses when no webmail is linked yet", async () => {
+ findByDomain.mockResolvedValue({
+ serverId: "srv_mail",
+ domain: "example.com",
+ webmailProjectId: null,
+ });
+
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(false);
+ });
+
+ it("refuses across organizations even when the link points here", async () => {
+ // The link is a pointer the mail module writes; org authority comes from the server.
+ findProject.mockResolvedValue({ id: "prj_webmail", organizationId: "org_2" });
+
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(false);
+ });
+
+ it("refuses a row another project already owns", async () => {
+ const owned = { hostname: "mail.example.com", ownerType: null, projectId: "prj_other" } as never;
+
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", owned)).toBe(false);
+ });
+
+ it("refuses a row this project already owns — that row should not exist", async () => {
+ const mine = { hostname: "mail.example.com", ownerType: "mail", projectId: "prj_webmail" } as never;
+
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", mine)).toBe(false);
+ });
+
+ it("has nothing to say about a hostname that is not a mail host", async () => {
+ expect(await mailHostRoutableByProject("webmail.example.com", "prj_webmail", null)).toBe(false);
+ expect(findByDomain).not.toHaveBeenCalled();
+ });
+
+ it("refuses when no mail server owns that base domain", async () => {
+ findByDomain.mockResolvedValue(undefined);
+
+ expect(await mailHostRoutableByProject("mail.nothere.com", "prj_webmail", null)).toBe(false);
+ });
+
+ // It is consulted on the way to an error, so a failed lookup must leave the caller's
+ // refusal in place rather than replace it with a 500.
+ it("refuses rather than throwing when a lookup fails", async () => {
+ findByDomain.mockRejectedValue(new Error("db down"));
+
+ expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(false);
+ });
+});
diff --git a/apps/api/test/lib/mail-image-seed-mounts.test.ts b/apps/api/test/lib/mail-image-seed-mounts.test.ts
new file mode 100644
index 000000000..b0a74dac8
--- /dev/null
+++ b/apps/api/test/lib/mail-image-seed-mounts.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { MAIL_CONTAINER_MOUNTS } from "@repo/adapters";
+
+/**
+ * Three shipped artifacts have to agree about the mail engine's seeded mounts, and no
+ * compiler checks any of it: the mount table (TypeScript), the entrypoint that copies
+ * baked defaults onto those mounts, and the Dockerfile that bakes what it copies FROM.
+ *
+ * Issue #565 was one broken leg of exactly that. `/var/lib/clamav` was bind-mounted
+ * without `seed: true`, the entrypoint never seeded it, and the Dockerfile baked nothing
+ * to seed it from — so the signature database the installer wrote sat in the image layer
+ * where the mount hid it, clamd exited 1, and supervisord's default three retries were
+ * spent in ~6s and marked it FATAL for the life of the container. Amavis has no backup
+ * scanner, so that defers all inbound mail.
+ *
+ * Building the image to catch this is not an option here: the iRedMail installer plus a
+ * signature fetch is minutes to tens of minutes (see test/e2e/mail-db-bootstrap.e2e.test.ts).
+ */
+
+const EMAIL_DIR = join(import.meta.dirname, "../../../../apps/email");
+const read = (p: string) => readFileSync(join(EMAIL_DIR, p), "utf8");
+
+const ENTRYPOINT = read("docker/entrypoint.sh");
+const SUPERVISORD = read("docker/supervisord.conf");
+const DOCKERFILE = read("Dockerfile");
+const BUILD_CONFIG = read("docker/build-config");
+
+/**
+ * The `seed ` calls the entrypoint actually makes. The
+ * optional `|| …` tail matters: the ClamAV seed is deliberately non-fatal, and a regex
+ * that stopped at end-of-line would silently stop seeing it.
+ */
+const seedCalls = [
+ ...ENTRYPOINT.matchAll(/^seed[ \t]+(\S+)[ \t]+(\S+)[ \t]*(?:\\|\|\|.*)?$/gm),
+].map(([, dir, path]) => ({ dir, path }));
+
+/** One `[program:x]` block, up to the next section. */
+function programBlock(name: string): string {
+ const block = SUPERVISORD.split(/^\[/m).find((b) => b.startsWith(`program:${name}]`));
+ if (!block) throw new Error(`no [program:${name}] in supervisord.conf`);
+ return block;
+}
+
+function numericSetting(program: string, key: string): number {
+ const found = new RegExp(`^${key}=(\\d+)$`, "m").exec(programBlock(program));
+ if (!found) throw new Error(`[program:${program}] has no ${key}`);
+ return Number(found[1]);
+}
+
+describe("openship-mail seed wiring", () => {
+ it("seeds every mount marked seed:true, and marks every mount it seeds", () => {
+ const declared = MAIL_CONTAINER_MOUNTS.filter((m) => m.seed)
+ .map((m) => m.container)
+ .sort();
+ expect(seedCalls.map((c) => c.path).sort()).toEqual(declared);
+ });
+
+ it("bakes a seed dir in the image for every seed call", () => {
+ // A seed call reading from a directory no build step creates is a silent no-op:
+ // seed() skips when the source is absent.
+ for (const { dir } of seedCalls) {
+ expect(DOCKERFILE).toContain(`/opt/openship-mail/seed/${dir}`);
+ }
+ });
+
+ it("seeds the ClamAV signature database and gates the build on it being there", () => {
+ expect(seedCalls.some((c) => c.path === "/var/lib/clamav")).toBe(true);
+ expect(DOCKERFILE).toContain("freshclam --datadir=/opt/openship-mail/seed/clamav");
+ // The gate is what turns "shipped with no loadable database" into a build failure.
+ expect(DOCKERFILE).toMatch(/FATAL: no ClamAV signature database/);
+ // And the installer must not fetch a second copy into the mounted path.
+ expect(BUILD_CONFIG).toMatch(/^export FRESHCLAM_UPDATE_IMMEDIATELY='NO'$/m);
+ });
+
+ it("hands the signature mount and clamd's socket dir to the clamav user", () => {
+ // The mount arrives root-owned from ensure-container-mail and both daemons drop
+ // privileges; /var/run/clamav holds clamd's socket and freshclam's pid file, and
+ // nothing in a container creates it.
+ expect(ENTRYPOINT).toContain("chown -R clamav:clamav /var/lib/clamav");
+ expect(ENTRYPOINT).toContain("/var/run/clamav");
+ });
+
+ it("keeps the ClamAV seed non-fatal", () => {
+ // Copying hundreds of MB onto a bind mount can fail on a full or read-only disk.
+ // The entrypoint runs under `set -e`, so an unguarded seed would take Postfix and
+ // Dovecot down over a virus scanner.
+ const clamavSeed = /^seed clamav \/var\/lib\/clamav.*$/m.exec(ENTRYPOINT)?.[0] ?? "";
+ expect(clamavSeed).toMatch(/\\$|\|\|/);
+ });
+});
+
+describe("clamd's start is not terminal", () => {
+ it("gives clamd and freshclam far more than supervisord's default 3 retries", () => {
+ // 3 retries is ~6 seconds, which is how a clamd that started before its signatures
+ // ended up FATAL — a state supervisord never retries.
+ expect(numericSetting("clamav-daemon", "startretries")).toBeGreaterThan(3);
+ // A freshclam that FATALs on a boot with no network yet never fetches the
+ // signatures clamd is waiting for.
+ expect(numericSetting("clamav-freshclam", "startretries")).toBeGreaterThan(3);
+ });
+
+ it("starts freshclam before clamd", () => {
+ expect(numericSetting("clamav-freshclam", "priority")).toBeLessThan(
+ numericSetting("clamav-daemon", "priority"),
+ );
+ });
+
+ it("still launches clamd under the name the API probes", () => {
+ expect(programBlock("clamav-daemon")).toContain("command=/usr/sbin/clamd");
+ });
+});
diff --git a/apps/api/test/lib/notification-categories.test.ts b/apps/api/test/lib/notification-categories.test.ts
index e1b51cf8c..4823ab54f 100644
--- a/apps/api/test/lib/notification-categories.test.ts
+++ b/apps/api/test/lib/notification-categories.test.ts
@@ -72,6 +72,7 @@ describe("notification category registry", () => {
"member.added",
"member.removed",
"invitation.sent",
+ "mail.inbound_received",
"billing.alert",
"quota.warning",
]);
diff --git a/apps/api/test/lib/server-container-session.test.ts b/apps/api/test/lib/server-container-session.test.ts
new file mode 100644
index 000000000..7c12dbb81
--- /dev/null
+++ b/apps/api/test/lib/server-container-session.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+
+/**
+ * The container-apply session store — the in-memory transport behind an edge/mail
+ * image swap, and now also the source a FLEET view reads progress and outcomes from.
+ *
+ * These pin the enumerator: what it exposes (steps and outcome, never the subscriber
+ * set or the log ring), and the settled window that exists because a finished apply
+ * is the only place a "done" beat can come from — the drift row clears its update and
+ * its in-progress mark in one write, so a reader watching rows only sees work vanish.
+ */
+
+import {
+ advanceStep,
+ createContainerApplySession,
+ finishContainerApplySession,
+ getActiveContainerApplySession,
+ listContainerApplySessions,
+} from "../../src/lib/server-container-session";
+
+/** Unique per case: the store is module-global and shared across this file. */
+let next = 0;
+const serverId = () => `srv_${++next}`;
+
+describe("listContainerApplySessions", () => {
+ it("reports a running apply with its component and steps", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "edge");
+
+ const mine = listContainerApplySessions().filter((s) => s.serverId === id);
+ expect(mine).toHaveLength(1);
+ expect(mine[0]).toMatchObject({ id: session.id, component: "edge", status: "running" });
+ expect(mine[0]!.steps.map((s) => s.id)).toEqual(["pull", "recreate", "verify"]);
+ });
+
+ it("carries the live step model, so a reader can render progress", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "edge");
+ advanceStep(session.id, "Updating the edge to ghcr.io/oblien/openship-edge:0.5.0");
+
+ const mine = listContainerApplySessions().find((s) => s.serverId === id)!;
+ expect(mine.steps.find((s) => s.id === "pull")!.status).toBe("running");
+ });
+
+ it("hands out a copy — a reader can never mutate the live session's steps", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "edge");
+
+ const snap = listContainerApplySessions().find((s) => s.serverId === id)!;
+ snap.steps[0]!.status = "error";
+
+ expect(session.steps[0]!.status).toBe("pending");
+ // And nothing about the transport leaks into the read shape.
+ expect(snap).not.toHaveProperty("subscribers");
+ expect(snap).not.toHaveProperty("logs");
+ expect(snap).not.toHaveProperty("donePromise");
+ });
+
+ it("drops a finished apply unless a settled window asks for it", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "mail");
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+
+ expect(listContainerApplySessions().find((s) => s.serverId === id)).toBeUndefined();
+
+ const settled = listContainerApplySessions({ settledWithinMs: 60_000 }).find(
+ (s) => s.serverId === id,
+ );
+ expect(settled).toMatchObject({ status: "completed", result: { updated: true, down: false } });
+ // A completed run has no step left short of done — the beat is unambiguous.
+ expect(settled!.steps.every((s) => s.status === "done")).toBe(true);
+ });
+
+ it("keeps the failure reason on a settled apply", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "edge");
+ finishContainerApplySession(session.id, "failed", undefined, "could not pull the image");
+
+ const settled = listContainerApplySessions({ settledWithinMs: 60_000 }).find(
+ (s) => s.serverId === id,
+ );
+ expect(settled).toMatchObject({ status: "failed", error: "could not pull the image" });
+ });
+
+ it("excludes a finish that is older than the window", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "edge");
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+ // Backdate the finish rather than waiting for the window to lapse.
+ session.finishedAt = Date.now() - 120_000;
+
+ expect(
+ listContainerApplySessions({ settledWithinMs: 60_000 }).find((s) => s.serverId === id),
+ ).toBeUndefined();
+ });
+
+ it("agrees with the per-(server, component) lookup the stream uses", () => {
+ const id = serverId();
+ const session = createContainerApplySession(id, "edge");
+
+ expect(getActiveContainerApplySession(id, "edge")?.id).toBe(session.id);
+ expect(getActiveContainerApplySession(id, "mail")).toBeNull();
+
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+ expect(getActiveContainerApplySession(id, "edge")).toBeNull();
+ });
+
+ it("lists a fleet's runs oldest-first, so a queue reads in the order it was taken", () => {
+ const a = serverId();
+ const b = serverId();
+ const first = createContainerApplySession(a, "edge");
+ const second = createContainerApplySession(b, "edge");
+
+ const ids = listContainerApplySessions()
+ .filter((s) => s.serverId === a || s.serverId === b)
+ .map((s) => s.id);
+ expect(ids).toEqual([first.id, second.id]);
+ });
+});
diff --git a/apps/api/test/modules/apps/app-install-routing.test.ts b/apps/api/test/modules/apps/app-install-routing.test.ts
index 66d31cb8f..b7267c33f 100644
--- a/apps/api/test/modules/apps/app-install-routing.test.ts
+++ b/apps/api/test/modules/apps/app-install-routing.test.ts
@@ -20,7 +20,12 @@ const { createProjectMock, createServiceMock, setEnvMock, requireCloudMock, draf
vi.mock("@repo/db", () => ({
repos: {
- project: { findDraftByAppTemplate: draftMock },
+ project: {
+ findDraftByAppTemplate: draftMock,
+ // installApp now ends in `ensureGeneratedAppSecrets`; nothing here asserts on it.
+ getEnvMap: async () => ({}),
+ mergeEnvVars: async () => {},
+ },
service: {
listByProject: async () => [
{ id: "svc-backend", name: "backend" },
diff --git a/apps/api/test/modules/apps/app-secret-backfill.test.ts b/apps/api/test/modules/apps/app-secret-backfill.test.ts
new file mode 100644
index 000000000..bd37e754d
--- /dev/null
+++ b/apps/api/test/modules/apps/app-secret-backfill.test.ts
@@ -0,0 +1,206 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * A generated config value must EXIST on every path that reaches a container.
+ *
+ * `installApp` writes them only for the services one call creates, so an adopted draft
+ * — every retry after a failed first attempt — kept whatever the first pass had managed
+ * to write. Webmail then deployed a container with no `SESSION_ENCRYPTION_KEY`, which
+ * its image treats as fatal: a crash loop the deploy reported as success (issue #566).
+ *
+ * The two properties that make the repair safe are as important as the repair:
+ * - presence is decided from the RAW stored keys, so an undecryptable row (after a
+ * BETTER_AUTH_SECRET rotation) is never overwritten — `mergeEnvVars` deletes before
+ * it inserts, and the value it would destroy can be a database password;
+ * - a key the template inlines into some OTHER string is never minted, because those
+ * copies were written once and would keep contradicting the new value.
+ */
+
+const { getEnvMap, mergeEnvVars, listByProject } = vi.hoisted(() => ({
+ getEnvMap: vi.fn(async () => ({}) as Record),
+ mergeEnvVars: vi.fn(async () => {}),
+ listByProject: vi.fn(async () => [{ id: "svc-webmail", name: "webmail" }]),
+}));
+
+vi.mock("@repo/db", () => ({
+ repos: {
+ project: { getEnvMap, mergeEnvVars, findDraftByAppTemplate: vi.fn() },
+ service: { listByProject },
+ customAppTemplate: { findByAppId: async () => undefined, listByOrg: async () => [] },
+ },
+}));
+
+// Stubbed for the same reason the sibling install test stubs them: importing the real
+// service layer drags in lib/auth, which needs a full `schema` export off @repo/db.
+vi.mock("../../../src/modules/projects/project-crud.service", () => ({
+ createProject: vi.fn(),
+}));
+vi.mock("../../../src/modules/services/service.service", () => ({
+ createService: vi.fn(),
+ updateService: vi.fn(),
+ setServiceEnvVars: vi.fn(),
+}));
+vi.mock("../../../src/lib/cloud/require-cloud", () => ({ requireCloud: vi.fn() }));
+
+import { getAppTemplate } from "@repo/core";
+import { ensureGeneratedAppSecrets } from "../../../src/modules/apps/app-install.service";
+import { decrypt, encrypt } from "../../../src/lib/encryption";
+
+const WEBMAIL = getAppTemplate("webmail")!;
+
+/** The upsert list from the single mergeEnvVars call, keyed by env var name. */
+function upserted() {
+ const [, , upserts] = mergeEnvVars.mock.calls[0] as unknown as [
+ string,
+ string,
+ { key: string; value: string; isSecret: boolean }[],
+ ];
+ return new Map(upserts.map((u) => [u.key, u]));
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ getEnvMap.mockResolvedValue({});
+ listByProject.mockResolvedValue([{ id: "svc-webmail", name: "webmail" }]);
+});
+
+describe("ensureGeneratedAppSecrets", () => {
+ it("mints the webmail secrets the image refuses to boot without", async () => {
+ const written = await ensureGeneratedAppSecrets("prj_1", WEBMAIL);
+
+ expect(written).toContain("SESSION_ENCRYPTION_KEY");
+ expect(written).toContain("BRANDING_ADMIN_TOKEN");
+ const vars = upserted();
+ // Stored encrypted, and a real value — a blank row boots no better than none.
+ expect(decrypt(vars.get("SESSION_ENCRYPTION_KEY")!.value).length).toBeGreaterThan(16);
+ expect(vars.get("SESSION_ENCRYPTION_KEY")!.isSecret).toBe(true);
+ expect(mergeEnvVars).toHaveBeenCalledTimes(1);
+ // Merge, never replace: the settings the app already has must survive.
+ expect(mergeEnvVars.mock.calls[0][3]).toEqual([]);
+ expect(mergeEnvVars.mock.calls[0][4]).toBe("svc-webmail");
+ });
+
+ it("reuses a stored key instead of rotating it", async () => {
+ getEnvMap.mockResolvedValue({ SESSION_ENCRYPTION_KEY: "ciphertext-we-cannot-read" });
+
+ const written = await ensureGeneratedAppSecrets("prj_1", WEBMAIL);
+
+ expect(written).not.toContain("SESSION_ENCRYPTION_KEY");
+ expect(written).toContain("BRANDING_ADMIN_TOKEN");
+ expect(upserted().has("SESSION_ENCRYPTION_KEY")).toBe(false);
+ });
+
+ /**
+ * The destructive case. `decryptEnvMap` DROPS keys it cannot decrypt, so deciding
+ * presence from decrypted values would report every secret as missing after a
+ * BETTER_AUTH_SECRET rotation and overwrite the only copy.
+ */
+ it("leaves an undecryptable row alone rather than overwriting it", async () => {
+ getEnvMap.mockResolvedValue({
+ SESSION_ENCRYPTION_KEY: "not-valid-ciphertext",
+ BRANDING_ADMIN_TOKEN: "also-not-valid",
+ });
+
+ expect(await ensureGeneratedAppSecrets("prj_1", WEBMAIL)).toEqual([]);
+ expect(mergeEnvVars).not.toHaveBeenCalled();
+ });
+
+ it("does nothing for a service row that does not exist yet", async () => {
+ listByProject.mockResolvedValue([]);
+
+ expect(await ensureGeneratedAppSecrets("prj_1", WEBMAIL)).toEqual([]);
+ expect(mergeEnvVars).not.toHaveBeenCalled();
+ });
+
+ it("is a no-op for a template with no generated fields", async () => {
+ const plain = { ...WEBMAIL, configFields: [] };
+
+ expect(await ensureGeneratedAppSecrets("prj_1", plain)).toEqual([]);
+ expect(getEnvMap).not.toHaveBeenCalled();
+ });
+
+ /**
+ * Redis inlines `VALKEY_PASSWORD` into a mounted `redis.conf`. Minting a new one here
+ * would write password A into the env row while the file still says B — a server
+ * nobody can authenticate against, with nothing logged.
+ */
+ it("refuses to mint a key the template inlines elsewhere", async () => {
+ const redis = getAppTemplate("redis")!;
+ const inlinedKeys = (redis.configFields ?? [])
+ .filter((f) => f.generate)
+ .map((f) => f.key);
+ listByProject.mockResolvedValue(
+ (redis.services ?? []).map((s, i) => ({ id: `svc-${i}`, name: s.name })),
+ );
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const written = await ensureGeneratedAppSecrets("prj_1", redis);
+
+ for (const key of inlinedKeys) expect(written).not.toContain(key);
+ expect(mergeEnvVars).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ /**
+ * Ghost's `ghostdb` group spans TWO services: MYSQL_ROOT_PASSWORD on ghost-db and
+ * database__connection__password on ghost. They are the same password, so a
+ * per-service view of the group repairs one half with a value the other half does not
+ * know — an app that cannot authenticate to its own MySQL, silently.
+ */
+ it("gives one value to a generate group that spans services", async () => {
+ const ghost = getAppTemplate("ghost")!;
+ listByProject.mockResolvedValue(
+ (ghost.services ?? []).map((s) => ({ id: `svc-${s.name}`, name: s.name })),
+ );
+
+ await ensureGeneratedAppSecrets("prj_1", ghost);
+
+ const all = new Map(
+ (mergeEnvVars.mock.calls as unknown as [string, string, { key: string; value: string }[]][])
+ .flatMap(([, , ups]) => ups.map((u) => [u.key, decrypt(u.value)] as const)),
+ );
+ expect(all.get("MYSQL_ROOT_PASSWORD")).toBeDefined();
+ expect(all.get("MYSQL_ROOT_PASSWORD")).toBe(all.get("database__connection__password"));
+ });
+
+ /**
+ * Half the pair already stored: the missing half must be REPAIRED to the stored
+ * value, never rotated to a fresh one — rotating would break the half that works.
+ */
+ it("repairs the missing half of a group from the stored half", async () => {
+ const ghost = getAppTemplate("ghost")!;
+ listByProject.mockResolvedValue(
+ (ghost.services ?? []).map((s) => ({ id: `svc-${s.name}`, name: s.name })),
+ );
+ getEnvMap.mockImplementation(async (_p: string, _e: string, serviceId: string) =>
+ serviceId === "svc-ghost-db" ? { MYSQL_ROOT_PASSWORD: encrypt("the-live-password") } : {},
+ );
+
+ const written = await ensureGeneratedAppSecrets("prj_1", ghost);
+
+ expect(written).toEqual(["database__connection__password"]);
+ const [, , upserts] = mergeEnvVars.mock.calls[0] as unknown as [
+ string,
+ string,
+ { key: string; value: string }[],
+ ];
+ expect(decrypt(upserts[0].value)).toBe("the-live-password");
+ });
+
+ it("leaves a group alone when the stored half cannot be read", async () => {
+ const ghost = getAppTemplate("ghost")!;
+ listByProject.mockResolvedValue(
+ (ghost.services ?? []).map((s) => ({ id: `svc-${s.name}`, name: s.name })),
+ );
+ getEnvMap.mockImplementation(async (_p: string, _e: string, serviceId: string) =>
+ serviceId === "svc-ghost-db" ? { MYSQL_ROOT_PASSWORD: "unreadable-ciphertext" } : {},
+ );
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ // Anything minted here would disagree with the copy MySQL is already using.
+ expect(await ensureGeneratedAppSecrets("prj_1", ghost)).toEqual([]);
+ expect(mergeEnvVars).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+});
diff --git a/apps/api/test/modules/audit/_harness.ts b/apps/api/test/modules/audit/_harness.ts
index 621ad3391..07eff7230 100644
--- a/apps/api/test/modules/audit/_harness.ts
+++ b/apps/api/test/modules/audit/_harness.ts
@@ -183,6 +183,8 @@ export interface SeedEventInput {
resourceType?: string | null;
resourceId?: string | null;
source?: string | null;
+ /** `oauth:` / `pat:` — which agent, for MCP rows. */
+ sourceClientId?: string | null;
createdAt?: Date;
}
@@ -203,6 +205,7 @@ export async function seedEvent(input: SeedEventInput): Promise {
resourceType: input.resourceType ?? null,
resourceId: input.resourceId ?? null,
source: input.source ?? null,
+ sourceClientId: input.sourceClientId ?? null,
createdAt: input.createdAt ?? new Date(),
});
return id;
diff --git a/apps/api/test/modules/audit/audit-agent-attribution.test.ts b/apps/api/test/modules/audit/audit-agent-attribution.test.ts
new file mode 100644
index 000000000..ad56e22e9
--- /dev/null
+++ b/apps/api/test/modules/audit/audit-agent-attribution.test.ts
@@ -0,0 +1,289 @@
+/**
+ * "What did THIS agent do."
+ *
+ * `source` could already answer "what did an AI assistant do", which was enough
+ * while one connection per user was the norm. With two — Claude Desktop and Cursor,
+ * or a personal client beside a CI one — every row still read identically, so the
+ * question a reader actually has when something unexpected appears ("which of these
+ * do I revoke?") had no answer, and the log could not distinguish an agent that
+ * deployed on request from one looping unattended.
+ *
+ * `source_client_id` holds the canonical principal id the auth layer already mints
+ * (`oauth:` / `pat:`), so a name comes from a table that already
+ * exists. These run against the real router and a real (in-memory) Postgres because
+ * the parts most likely to break are the ones a mock hides: a partial index, a
+ * facet counted without its own filter, and two name lookups across two tables.
+ */
+
+import { beforeAll, describe, expect, it } from "vitest";
+import { db, schema } from "@repo/db";
+import { repos } from "@repo/db";
+import { mintPatToken } from "../../../src/lib/pat";
+import { makeApp, req, seedEvent, seedOwner } from "./_harness";
+import type { SeededOwner } from "./_harness";
+
+const app = makeApp();
+
+let owner: SeededOwner;
+let other: SeededOwner;
+/** `pat:` for a static-token MCP connection — no OAuth app to name it. */
+let patClient: string;
+
+// The stored column holds the PREFIXED principal id; the oauth_application row is
+// keyed by the bare client_id. Keeping both spellings explicit is deliberate —
+// conflating them is exactly how a name lookup silently resolves to nothing.
+const DESKTOP_CLIENT = "cli_desktop";
+const CURSOR_CLIENT = "cli_cursor";
+const DESKTOP = `oauth:${DESKTOP_CLIENT}`;
+const CURSOR = `oauth:${CURSOR_CLIENT}`;
+/** A client id with nothing behind it — a disconnected, still-audited agent. */
+const GHOST = "oauth:cli_gone";
+
+/** Register an OAuth application so its clientId resolves to a display name. */
+async function seedOauthApp(clientId: string, name: string, userId: string) {
+ await db.insert(schema.oauthApplication).values({
+ id: `app_${clientId}`,
+ name,
+ clientId,
+ redirectUrls: "http://localhost/callback",
+ type: "public",
+ userId,
+ });
+}
+
+beforeAll(async () => {
+ owner = await seedOwner("Ada Owner");
+ other = await seedOwner("Other Org Owner");
+
+ await seedOauthApp(DESKTOP_CLIENT, "Claude Desktop", owner.userId);
+ await seedOauthApp(CURSOR_CLIENT, "Cursor", owner.userId);
+
+ const pat = mintPatToken();
+ const row = await repos.personalAccessToken.create({
+ userId: owner.userId,
+ organizationId: owner.orgId,
+ name: "CI agent token",
+ tokenPrefix: pat.tokenPrefix,
+ tokenHash: pat.tokenHash,
+ readOnly: false,
+ scoped: false,
+ expiresAt: null,
+ });
+ patClient = `pat:${row.id}`;
+
+ // Desktop: three calls, one of them a refused write. Cursor: one read.
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "mcp.tool_called",
+ actorUserId: owner.userId,
+ resourceType: "mcp_client",
+ source: "mcp",
+ sourceClientId: DESKTOP,
+ });
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "mcp.tool_called",
+ actorUserId: owner.userId,
+ source: "mcp",
+ sourceClientId: DESKTOP,
+ });
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "project:write",
+ actorUserId: owner.userId,
+ resourceType: "project",
+ source: "mcp",
+ sourceClientId: DESKTOP,
+ });
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "mcp.tool_called",
+ actorUserId: owner.userId,
+ source: "mcp",
+ sourceClientId: CURSOR,
+ });
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "mcp.tool_called",
+ actorUserId: owner.userId,
+ source: "mcp",
+ sourceClientId: patClient,
+ });
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "mcp.tool_called",
+ actorUserId: owner.userId,
+ source: "mcp",
+ sourceClientId: GHOST,
+ });
+ // An MCP row from before attribution existed, plus an ordinary dashboard row.
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "mcp.authorized",
+ actorUserId: owner.userId,
+ source: "mcp",
+ });
+ await seedEvent({
+ organizationId: owner.orgId,
+ eventType: "deployment.succeeded",
+ actorUserId: owner.userId,
+ source: "dashboard",
+ });
+ // Another org's agent — must never appear, however the filter is spelled.
+ await seedEvent({
+ organizationId: other.orgId,
+ eventType: "mcp.tool_called",
+ actorUserId: other.userId,
+ source: "mcp",
+ sourceClientId: DESKTOP,
+ });
+});
+
+describe("filtering to one agent", () => {
+ it("returns only that client's rows", async () => {
+ const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(DESKTOP)}`, {
+ auth: owner.auth,
+ });
+ expect(res.status).toBe(200);
+ expect(res.body.total).toBe(3);
+ for (const row of res.body.data) expect(row.sourceClientId).toBe(DESKTOP);
+ });
+
+ it("excludes the other agent, and MCP rows with no attribution at all", async () => {
+ const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(CURSOR)}`, {
+ auth: owner.auth,
+ });
+ expect(res.body.total).toBe(1);
+ expect(res.body.data[0].eventType).toBe("mcp.tool_called");
+ });
+
+ it("never crosses the org boundary", async () => {
+ // The same clientId is connected in both orgs — the only thing keeping them
+ // apart is the org predicate, so this is the case worth stating.
+ const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(DESKTOP)}`, {
+ auth: other.auth,
+ });
+ expect(res.body.total).toBe(1);
+ expect(res.body.data[0].actorUserId).toBe(other.userId);
+ });
+
+ it("composes with the other filters", async () => {
+ const res = await req(
+ app,
+ "GET",
+ `/?sourceClientId=${encodeURIComponent(DESKTOP)}&category=agent`,
+ { auth: owner.auth },
+ );
+ // The refused-write row (project:write) is Desktop's but not in the agent
+ // category, so the two filters intersect rather than either winning.
+ expect(res.body.total).toBe(2);
+ });
+
+ it("degrades to unfiltered on a malformed id rather than 400-ing", async () => {
+ // Same contract as an unknown category: a stale bookmark still renders a feed.
+ const res = await req(app, "GET", "/?sourceClientId=not-a-principal-id", { auth: owner.auth });
+ expect(res.status).toBe(200);
+ expect(res.body.total).toBe(8);
+ });
+});
+
+describe("naming the agent", () => {
+ it("resolves an OAuth client id to the registered application name", async () => {
+ const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(DESKTOP)}`, {
+ auth: owner.auth,
+ });
+ for (const row of res.body.data) expect(row.sourceClientName).toBe("Claude Desktop");
+ });
+
+ it("resolves a static-token connection to the token's name", async () => {
+ // A PAT-backed client has no OAuth application, so the token name is the only
+ // label there is — and without it these rows would show a raw pat_ id.
+ const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(patClient)}`, {
+ auth: owner.auth,
+ });
+ expect(res.body.data[0].sourceClientName).toBe("CI agent token");
+ });
+
+ it("keeps a row whose client no longer exists, with a null name", async () => {
+ const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(GHOST)}`, {
+ auth: owner.auth,
+ });
+ expect(res.body.total).toBe(1);
+ expect(res.body.data[0].sourceClientId).toBe(GHOST);
+ expect(res.body.data[0].sourceClientName).toBeNull();
+ });
+
+ it("leaves unattributed rows null instead of inventing an agent", async () => {
+ const res = await req(app, "GET", "/?eventType=deployment.succeeded", { auth: owner.auth });
+ expect(res.body.data[0].sourceClientId).toBeNull();
+ expect(res.body.data[0].sourceClientName).toBeNull();
+ });
+
+ it("resolves both id kinds in ONE page without an N+1", async () => {
+ const res = await req(app, "GET", "/?source=mcp", { auth: owner.auth });
+ const byClient = new Map(
+ res.body.data.map((r: { sourceClientId: string | null; sourceClientName: string | null }) => [
+ r.sourceClientId ?? "none",
+ r.sourceClientName,
+ ]),
+ );
+ expect(byClient.get(DESKTOP)).toBe("Claude Desktop");
+ expect(byClient.get(CURSOR)).toBe("Cursor");
+ expect(byClient.get(patClient)).toBe("CI agent token");
+ expect(byClient.get("none")).toBeNull();
+ });
+});
+
+describe("the agent facet", () => {
+ it("lists the agents that appear, busiest first, with counts and names", async () => {
+ const res = await req(app, "GET", "/facets", { auth: owner.auth });
+ const clients = res.body.clients as { id: string; name: string | null; count: number }[];
+ expect(clients[0]).toMatchObject({ id: DESKTOP, name: "Claude Desktop", count: 3 });
+ expect(clients.map((c) => c.id).sort()).toEqual([CURSOR, DESKTOP, GHOST, patClient].sort());
+ expect(clients.find((c) => c.id === GHOST)).toMatchObject({ name: null, count: 1 });
+ });
+
+ it("never lists a null client — an unattributed row is not an agent", async () => {
+ const res = await req(app, "GET", "/facets", { auth: owner.auth });
+ const clients = res.body.clients as { id: string | null }[];
+ expect(clients.every((c) => !!c.id)).toBe(true);
+ });
+
+ it("counts each agent WITHOUT its own filter, so the choice is reversible", async () => {
+ // The same rule the source facet follows: picking Desktop must leave Cursor
+ // clickable, or the filter is a one-way door.
+ const res = await req(app, "GET", `/facets?sourceClientId=${encodeURIComponent(DESKTOP)}`, {
+ auth: owner.auth,
+ });
+ const clients = res.body.clients as { id: string; count: number }[];
+ expect(clients.find((c) => c.id === CURSOR)?.count).toBe(1);
+ // ...while the category counts DO respect it.
+ const counts = Object.fromEntries(
+ res.body.categories.map((c: { id: string; count: number }) => [c.id, c.count]),
+ );
+ expect(counts.agent).toBe(2);
+ expect(counts.deployments).toBe(0);
+ });
+
+ it("respects the other filters", async () => {
+ const res = await req(app, "GET", "/facets?category=deployments", { auth: owner.auth });
+ expect(res.body.clients).toEqual([]);
+ });
+
+ it("is empty for an org with no agents", async () => {
+ const res = await req(app, "GET", "/facets", { auth: other.auth });
+ expect((res.body.clients as unknown[]).length).toBe(1); // other org has its own one row
+ });
+});
+
+describe("the agent category", () => {
+ it("collects the whole MCP lifecycle, tool calls included", async () => {
+ const res = await req(app, "GET", "/?category=agent", { auth: owner.auth });
+ const types = new Set(res.body.data.map((r: { eventType: string }) => r.eventType));
+ expect(types).toContain("mcp.tool_called");
+ expect(types).toContain("mcp.authorized");
+ // A write an agent made is filed under what it changed, not under the agent —
+ // that row already says "project:write" and names the project.
+ expect(types).not.toContain("project:write");
+ });
+});
diff --git a/apps/api/test/modules/mail/dns-scan.service.test.ts b/apps/api/test/modules/mail/dns-scan.service.test.ts
index d8fda6fb3..c4cc595d6 100644
--- a/apps/api/test/modules/mail/dns-scan.service.test.ts
+++ b/apps/api/test/modules/mail/dns-scan.service.test.ts
@@ -81,7 +81,9 @@ vi.mock("../../../src/modules/mail/mail-state", () => ({
readState: async () => state,
}));
-import { scanDns } from "../../../src/modules/mail/admin/dns-scan.service";
+import { scanDns,
+ looksSyntheticAddress,
+} from "../../../src/modules/mail/admin/dns-scan.service";
beforeEach(() => {
state = BASE_STATE;
@@ -592,3 +594,33 @@ describe("resolver selection", () => {
expect(dns.setServers).toHaveBeenCalledWith(expect.arrayContaining(["1.1.1.1"]));
});
});
+
+/**
+ * GH-240 FP2: pinning the resolver to 1.1.1.1/8.8.8.8 does not escape a fake-IP TUN.
+ * Clash/sing-box capture UDP:53 to ANY destination and synthesise a per-hostname address,
+ * so the query never leaves the machine. Comparing that handle to the real public IP told
+ * operators their DNS was wrong when only the scanning host could not see it.
+ */
+describe("synthetic address detection (GH-240)", () => {
+ test("recognises the fake-IP ranges proxies actually use", () => {
+ // Clash / sing-box default: 198.18.0.0/15 (RFC 2544).
+ expect(looksSyntheticAddress("198.18.0.7")).toBe(true);
+ expect(looksSyntheticAddress("198.19.255.254")).toBe(true);
+ // RFC 1112 class-E reserved.
+ expect(looksSyntheticAddress("240.0.0.1")).toBe(true);
+ // IPv6 ULA, covering sing-box's fd00::/18.
+ expect(looksSyntheticAddress("fd00::1")).toBe(true);
+ expect(looksSyntheticAddress("fc00::1")).toBe(true);
+ });
+
+ test("does not mistake a real public address for a synthetic one", () => {
+ // The neighbours of 198.18/15 must not be swept in.
+ expect(looksSyntheticAddress("198.17.0.1")).toBe(false);
+ expect(looksSyntheticAddress("198.20.0.1")).toBe(false);
+ // Ordinary public IPs, RFC1918, loopback and a real v6 address are all "not synthetic":
+ // 127.0.1.1 is a DIFFERENT false positive, already handled by pinning the resolver.
+ for (const ip of ["1.2.3.4", "203.0.113.10", "192.168.1.1", "10.0.0.1", "127.0.1.1", "2606:4700::1111"]) {
+ expect(looksSyntheticAddress(ip), ip).toBe(false);
+ }
+ });
+});
diff --git a/apps/api/test/modules/mail/inbound-capture.test.ts b/apps/api/test/modules/mail/inbound-capture.test.ts
new file mode 100644
index 000000000..65eb3c82e
--- /dev/null
+++ b/apps/api/test/modules/mail/inbound-capture.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import {
+ collectorAddress,
+ collectorFolderPath,
+ collectorMailbox,
+ generateToken,
+ tokenFromBcc,
+} from "../../../src/modules/mail/inbound/capture";
+
+/**
+ * The pure half of arming. Two of these are load-bearing beyond tidiness:
+ *
+ * - token CASE. The Dovecot LDA pipe runs `flags=DRh`, whose `h` folds the DOMAIN to
+ * lowercase but NOT the `-m ${extension}`, while the userdb query LOWER()s the whole
+ * home path. A mixed-case token names a folder the computed path cannot find.
+ * - FOREIGN BCC detection. `recipient_bcc_domain` has one slot per domain, shared with
+ * operator archiving and compliance copies. Misreading somebody else's value as ours
+ * means overwriting it and silently switching their archiving off.
+ */
+
+describe("token round trip", () => {
+ it("builds the collector address and reads the token back out", () => {
+ const addr = collectorAddress("Acme.COM", "abc123");
+ expect(addr).toBe("openship-hook+abc123@acme.com");
+ expect(tokenFromBcc(addr)).toBe("abc123");
+ });
+
+ it("names one collector mailbox per domain, lowercased", () => {
+ expect(collectorMailbox("Acme.COM")).toBe("openship-hook@acme.com");
+ });
+
+ it("generates lowercase [a-z0-9] tokens with real entropy", () => {
+ for (let i = 0; i < 40; i++) {
+ const t = generateToken();
+ // Anything outside this class would name a folder we cannot find again.
+ expect(t).toMatch(/^[a-z0-9]+$/);
+ expect(t.length).toBeGreaterThanOrEqual(16);
+ }
+ // Not a constant.
+ expect(new Set([generateToken(), generateToken(), generateToken()]).size).toBe(3);
+ });
+});
+
+describe("tokenFromBcc — foreign values are never ours", () => {
+ it("rejects an operator's own BCC", () => {
+ // The case that must never be overwritten: archiving, compliance, iRedAdmin-Pro.
+ expect(tokenFromBcc("archive@acme.com")).toBeNull();
+ expect(tokenFromBcc("compliance+legal@acme.com")).toBeNull();
+ expect(tokenFromBcc("openship@acme.com")).toBeNull();
+ });
+
+ it("rejects a near-miss that only looks like ours", () => {
+ // A prefix match without the delimiter is a DIFFERENT mailbox.
+ expect(tokenFromBcc("openship-hooks+abc@acme.com")).toBeNull();
+ expect(tokenFromBcc("openship-hook@acme.com")).toBeNull();
+ // Empty extension carries no folder, so it is not a usable token.
+ expect(tokenFromBcc("openship-hook+@acme.com")).toBeNull();
+ });
+
+ it("rejects a token with characters the LDA would not fold", () => {
+ expect(tokenFromBcc("openship-hook+ABC123@acme.com")).toBeNull();
+ expect(tokenFromBcc("openship-hook+abc.123@acme.com")).toBeNull();
+ expect(tokenFromBcc("openship-hook+abc-123@acme.com")).toBeNull();
+ });
+
+ it("handles absent values", () => {
+ expect(tokenFromBcc(null)).toBeNull();
+ expect(tokenFromBcc(undefined)).toBeNull();
+ expect(tokenFromBcc("")).toBeNull();
+ });
+});
+
+describe("collectorFolderPath — Maildir++ layout", () => {
+ it("puts the token folder dot-prefixed under Maildir/", () => {
+ // mail_location = maildir:%Lh/Maildir/, home from the userdb query, and Maildir++
+ // stores a top-level folder as a dot-prefixed directory beside cur/new/tmp.
+ expect(collectorFolderPath("/var/vmail/vmail1/acme.com/o/op/openship-hook-20260101000000/", "tok")).toBe(
+ "/var/vmail/vmail1/acme.com/o/op/openship-hook-20260101000000/Maildir/.tok",
+ );
+ });
+
+ it("tolerates a home with or without its trailing slash", () => {
+ const withSlash = collectorFolderPath("/var/vmail/vmail1/a/", "t");
+ const without = collectorFolderPath("/var/vmail/vmail1/a", "t");
+ expect(withSlash).toBe(without);
+ expect(without).toBe("/var/vmail/vmail1/a/Maildir/.t");
+ });
+});
diff --git a/apps/api/test/modules/mail/inbound-disarm.test.ts b/apps/api/test/modules/mail/inbound-disarm.test.ts
new file mode 100644
index 000000000..e1a91b3db
--- /dev/null
+++ b/apps/api/test/modules/mail/inbound-disarm.test.ts
@@ -0,0 +1,195 @@
+/**
+ * Every mutation that narrows what is watched must RELEASE the BCC it orphans.
+ *
+ * GH-559: an armed domain makes Postfix BCC a full second copy of every message into a
+ * collector mailbox provisioned at QUOTA 0, and the only thing that prunes those copies is
+ * the read job visiting that domain. Disabling a rule (or moving its scope) stopped the
+ * pruning while the copies kept arriving, on /var/vmail — a bind mount with no quota of its
+ * own. The disk fills and the mail server goes down.
+ *
+ * Only `deleteRuleHandler` released anything; `updateRuleHandler` armed on a scope change
+ * and disarmed nothing, and `enabled: false` disarmed nothing at all. These tests pin the
+ * release on each path, and pin the thing that makes it SAFE: a domain a second enabled
+ * rule still covers must stay armed, because capture is domain-keyed and shared.
+ */
+import { describe, expect, it, beforeEach, vi } from "vitest";
+
+const engine = vi.hoisted(() => ({
+ armed: [] as string[],
+ disarmed: [] as string[],
+ domains: ["acme.com", "beta.com"],
+}));
+
+vi.mock("../../../src/modules/mail/inbound/capture", () => ({
+ armDomain: vi.fn(async (_s: string, d: string) => {
+ engine.armed.push(d);
+ return "tok";
+ }),
+ disarmDomain: vi.fn(async (_s: string, d: string) => {
+ engine.disarmed.push(d);
+ }),
+ listEngineDomains: vi.fn(async () => engine.domains),
+ ruleDomain: ({ scope, target }: { scope: string; target: string | null }) =>
+ scope === "domain" ? target : scope === "mailbox" ? (target?.split("@")[1] ?? null) : null,
+ ForeignBccError: class extends Error {},
+}));
+
+vi.mock("../../../src/modules/mail/inbound/read", () => ({
+ runInboundForServer: vi.fn(async () => ({ read: 0, emitted: 0, dropped: 0, errors: [] })),
+}));
+
+/** Rule rows, keyed by id. `enabled` drives the "still wanted" computation. */
+type Row = {
+ id: string;
+ serverId: string;
+ organizationId: string;
+ scope: string;
+ target: string | null;
+ enabled: boolean;
+};
+const db = vi.hoisted(() => ({ rows: new Map() }));
+
+vi.mock("@repo/db", () => ({
+ repos: {
+ mailInbound: {
+ findById: vi.fn(async (_org: string, id: string) => db.rows.get(id)),
+ update: vi.fn(async (_org: string, id: string, patch: Record) => {
+ const cur = db.rows.get(id) as Row | undefined;
+ if (!cur) return undefined;
+ const next = { ...cur, ...patch };
+ db.rows.set(id, next);
+ return next;
+ }),
+ remove: vi.fn(async (_org: string, id: string) => {
+ db.rows.delete(id);
+ }),
+ listEnabledByServer: vi.fn(async (serverId: string) =>
+ [...db.rows.values()].filter((r) => (r as Row).serverId === serverId && (r as Row).enabled),
+ ),
+ },
+ },
+}));
+
+vi.mock("../../../src/lib/request-context", () => ({
+ getRequestContext: () => ({ organizationId: "org1" }),
+}));
+vi.mock("../../../src/lib/permission", () => ({
+ permission: { assert: vi.fn(async () => undefined) },
+}));
+vi.mock("../../../src/lib/controller-helpers", () => ({
+ param: (_c: unknown, name: string) => (name === "serverId" ? "srv1" : "r1"),
+ isServerInOrg: vi.fn(async () => true),
+ assertNotCloud: () => null,
+}));
+
+import {
+ updateRuleHandler,
+ deleteRuleHandler,
+} from "../../../src/modules/mail/inbound/inbound.controller";
+
+/** Minimal hono Context: a JSON body in, a captured JSON response out. */
+function ctx(body: unknown) {
+ const sent: { body?: unknown; status?: number } = {};
+ return {
+ c: {
+ req: { json: async () => body },
+ json: (b: unknown, status?: number) => {
+ sent.body = b;
+ sent.status = status ?? 200;
+ return sent;
+ },
+ } as never,
+ sent,
+ };
+}
+
+function seed(rows: Row[]) {
+ db.rows.clear();
+ for (const r of rows) db.rows.set(r.id, r);
+}
+
+const RULE: Row = {
+ id: "r1",
+ serverId: "srv1",
+ organizationId: "org1",
+ scope: "domain",
+ target: "acme.com",
+ enabled: true,
+};
+
+beforeEach(() => {
+ engine.armed = [];
+ engine.disarmed = [];
+ engine.domains = ["acme.com", "beta.com"];
+});
+
+describe("inbound rule mutations release the BCC they orphan (GH-559)", () => {
+ it("disabling a rule disarms its domain", async () => {
+ seed([{ ...RULE }]);
+ const { c } = ctx({ enabled: false });
+ await updateRuleHandler(c);
+
+ expect(engine.disarmed).toEqual(["acme.com"]);
+ // A disabled rule must not be left armed either.
+ expect(engine.armed).toEqual([]);
+ });
+
+ it("re-enabling a rule arms it again", async () => {
+ // The old code only armed inside the scope branch, so a resumed rule watched nothing.
+ seed([{ ...RULE, enabled: false }]);
+ const { c } = ctx({ enabled: true });
+ await updateRuleHandler(c);
+
+ expect(engine.armed).toEqual(["acme.com"]);
+ expect(engine.disarmed).toEqual([]);
+ });
+
+ it("retargeting a domain rule releases the domain it left", async () => {
+ seed([{ ...RULE }]);
+ const { c } = ctx({ scope: "domain", target: "beta.com" });
+ await updateRuleHandler(c);
+
+ expect(engine.armed).toEqual(["beta.com"]);
+ expect(engine.disarmed).toEqual(["acme.com"]);
+ });
+
+ it("keeps a domain armed when a second enabled rule still covers it", async () => {
+ // The invariant that makes the release safe: capture is domain-keyed and SHARED.
+ seed([
+ { ...RULE },
+ { id: "r2", serverId: "srv1", organizationId: "org1", scope: "domain", target: "acme.com", enabled: true },
+ ]);
+ const { c } = ctx({ enabled: false });
+ await updateRuleHandler(c);
+
+ expect(engine.disarmed).toEqual([]);
+ });
+
+ it("does not release a domain another rule covers via scope:all", async () => {
+ seed([
+ { ...RULE },
+ { id: "r2", serverId: "srv1", organizationId: "org1", scope: "all", target: null, enabled: true },
+ ]);
+ const { c } = ctx({ enabled: false });
+ await updateRuleHandler(c);
+
+ // scope:all resolves to every engine domain, acme.com included.
+ expect(engine.disarmed).toEqual([]);
+ });
+
+ it("deleting a rule still releases its domain", async () => {
+ seed([{ ...RULE }]);
+ const { c } = ctx({});
+ await deleteRuleHandler(c);
+
+ expect(engine.disarmed).toEqual(["acme.com"]);
+ });
+
+ it("a mailbox-scoped rule releases the domain behind the address", async () => {
+ seed([{ ...RULE, scope: "mailbox", target: "support@acme.com" }]);
+ const { c } = ctx({ enabled: false });
+ await updateRuleHandler(c);
+
+ expect(engine.disarmed).toEqual(["acme.com"]);
+ });
+});
diff --git a/apps/api/test/modules/mail/inbound-filter.test.ts b/apps/api/test/modules/mail/inbound-filter.test.ts
new file mode 100644
index 000000000..1666de3d9
--- /dev/null
+++ b/apps/api/test/modules/mail/inbound-filter.test.ts
@@ -0,0 +1,209 @@
+import { describe, expect, it } from "vitest";
+import {
+ bareAddress,
+ loopGuard,
+ matchesPattern,
+ matchesRule,
+ parseHeaderBlock,
+ spamGate,
+} from "../../../src/modules/mail/inbound/filter";
+import type { MailInboundRule } from "@repo/db";
+
+/**
+ * The decisions that make inbound-mail notifications safe rather than an incident.
+ *
+ * Three failure modes are covered deliberately, because each is silent in production:
+ * - a loop (a notification about mail is itself mail),
+ * - a rule that quietly widens to every message on the server,
+ * - an alert per spam, because nothing upstream filters spam for us.
+ */
+
+const HEADERS = [
+ "Return-Path: ",
+ "From: Alice Example ",
+ "To: support@acme.com, ops@acme.com",
+ "Subject: Invoice 42 is overdue",
+ "Message-Id: ",
+ "",
+ "body must never be parsed",
+].join("\n");
+
+function rule(over: Partial = {}): MailInboundRule {
+ return {
+ scope: "domain",
+ target: "acme.com",
+ fromPattern: null,
+ subjectPattern: null,
+ enabled: true,
+ pausedReason: null,
+ ...over,
+ } as MailInboundRule;
+}
+
+describe("parseHeaderBlock", () => {
+ it("parses the fields a decision is allowed to use, and stops at the body", () => {
+ const h = parseHeaderBlock(HEADERS);
+ expect(h.fromAddress).toBe("alice@example.com");
+ expect(h.recipients).toEqual(["support@acme.com", "ops@acme.com"]);
+ expect(h.subject).toBe("Invoice 42 is overdue");
+ expect(h.messageId).toBe("");
+ // A blank line ends the block; body content must not become a "header".
+ expect(JSON.stringify(h)).not.toContain("body must never");
+ });
+
+ it("unfolds continuation lines instead of truncating them", () => {
+ // Long To/Subject values ARE folded in real mail. A naive line split loses half the
+ // recipient list, which would make a mailbox-scope rule miss its own target.
+ const h = parseHeaderBlock(
+ ["Subject: a very long subject", "\tthat continues here", "To: one@acme.com,", " two@acme.com", ""].join("\n"),
+ );
+ expect(h.subject).toBe("a very long subject that continues here");
+ expect(h.recipients).toEqual(["one@acme.com", "two@acme.com"]);
+ });
+
+ it("is case-insensitive on names and keeps the FIRST occurrence", () => {
+ const h = parseHeaderBlock(["FROM: first@a.com", "From: second@b.com", ""].join("\n"));
+ expect(h.fromAddress).toBe("first@a.com");
+ });
+
+ it("handles CRLF, so a real message and a fixture agree", () => {
+ const h = parseHeaderBlock("Subject: hi\r\nTo: a@b.com\r\n\r\nbody");
+ expect(h.subject).toBe("hi");
+ expect(h.recipients).toEqual(["a@b.com"]);
+ });
+
+ it("reads X-Spam-Flag and a float score, ignoring a malformed score", () => {
+ expect(parseHeaderBlock("X-Spam-Flag: YES\n").spamFlagYes).toBe(true);
+ expect(parseHeaderBlock("X-Spam-Flag: no\n").spamFlagYes).toBe(false);
+ expect(parseHeaderBlock("X-Spam-Score: 7.4\n").spamScore).toBe(7.4);
+ expect(parseHeaderBlock("X-Spam-Score: not-a-number\n").spamScore).toBeUndefined();
+ });
+});
+
+describe("bareAddress", () => {
+ it("preserves the empty angle pair, because <> IS the bounce signal", () => {
+ expect(bareAddress("<>")).toBe("");
+ expect(bareAddress("Alice ")).toBe("a@b.com");
+ expect(bareAddress("A@B.COM")).toBe("a@b.com");
+ expect(bareAddress("not-an-address")).toBeUndefined();
+ expect(bareAddress(undefined)).toBeUndefined();
+ });
+});
+
+describe("loopGuard — the four guards", () => {
+ it("drops a bounce (null envelope sender)", () => {
+ const h = parseHeaderBlock("Return-Path: <>\nFrom: mailer@x.com\n");
+ expect(loopGuard(h)).toEqual({ drop: true, reason: "bounce" });
+ });
+
+ it("drops anything Auto-Submitted other than 'no'", () => {
+ expect(loopGuard(parseHeaderBlock("Auto-Submitted: auto-generated\n")).drop).toBe(true);
+ expect(loopGuard(parseHeaderBlock("Auto-Submitted: auto-replied\n")).drop).toBe(true);
+ expect(loopGuard(parseHeaderBlock("Auto-Submitted: no\n")).drop).toBe(false);
+ });
+
+ it("drops bulk/junk precedence and list traffic", () => {
+ expect(loopGuard(parseHeaderBlock("Precedence: bulk\n")).reason).toBe("bulk-precedence");
+ expect(loopGuard(parseHeaderBlock("Precedence: junk\n")).reason).toBe("bulk-precedence");
+ expect(loopGuard(parseHeaderBlock("List-Id: \n")).reason).toBe("mailing-list");
+ });
+
+ it("drops our OWN outbound sender — the direct self-feeding loop", () => {
+ // A notification delivered to an address inside a watched domain would otherwise be
+ // captured and emit again, forever.
+ const h = parseHeaderBlock("From: Openship \n");
+ expect(loopGuard(h, { openshipSenders: ["NoReply@acme.com"] })).toEqual({
+ drop: true,
+ reason: "openship-sender",
+ });
+ });
+
+ it("lets ordinary human mail through", () => {
+ expect(loopGuard(parseHeaderBlock(HEADERS))).toEqual({ drop: false });
+ });
+});
+
+describe("spamGate", () => {
+ it("drops flagged spam when no threshold is set (the conservative default)", () => {
+ expect(spamGate(parseHeaderBlock("X-Spam-Flag: YES\n"), null).reason).toBe("spam-flagged");
+ expect(spamGate(parseHeaderBlock("Subject: hi\n"), null).drop).toBe(false);
+ });
+
+ it("honours an explicit threshold on the score", () => {
+ const spammy = parseHeaderBlock("X-Spam-Flag: YES\nX-Spam-Score: 9.1\n");
+ expect(spamGate(spammy, 10).drop).toBe(false); // operator asked for a loose gate
+ expect(spamGate(spammy, 5).drop).toBe(true);
+ });
+
+ it("catches bad-header mail, which is delivered with NO X-Spam-Flag at all", () => {
+ // bad_header_lover='Y' in the shipped policy, and bad-header mail is not flagged —
+ // so the score has to be checked independently of the flag.
+ const badHeader = parseHeaderBlock("X-Spam-Score: 8.0\n");
+ expect(badHeader.spamFlagYes).toBe(false);
+ expect(spamGate(badHeader, 6.9).drop).toBe(true);
+ });
+});
+
+describe("matchesRule — fail closed", () => {
+ const h = parseHeaderBlock(HEADERS);
+
+ it("matches a domain rule against the CAPTURED domain, not a header", () => {
+ expect(matchesRule(rule({ scope: "domain", target: "acme.com" }), h, "acme.com")).toBe(true);
+ expect(matchesRule(rule({ scope: "domain", target: "acme.com" }), h, "other.com")).toBe(false);
+ });
+
+ it("matches a mailbox rule on To/Cc", () => {
+ expect(matchesRule(rule({ scope: "mailbox", target: "support@acme.com" }), h, "acme.com")).toBe(true);
+ expect(matchesRule(rule({ scope: "mailbox", target: "nobody@acme.com" }), h, "acme.com")).toBe(false);
+ });
+
+ it("matches EVERYTHING captured for scope=all", () => {
+ expect(matchesRule(rule({ scope: "all", target: null }), h, "anything.com")).toBe(true);
+ });
+
+ // The reason there is no CHECK constraint and this logic exists instead.
+ it("matches NOTHING when a mailbox/domain rule has no target", () => {
+ expect(matchesRule(rule({ scope: "mailbox", target: null }), h, "acme.com")).toBe(false);
+ expect(matchesRule(rule({ scope: "domain", target: null }), h, "acme.com")).toBe(false);
+ expect(matchesRule(rule({ scope: "domain", target: " " }), h, "acme.com")).toBe(false);
+ });
+
+ it("matches nothing for a scope this build does not understand", () => {
+ expect(matchesRule(rule({ scope: "everything", target: null }), h, "acme.com")).toBe(false);
+ });
+
+ it("respects enabled and pausedReason", () => {
+ expect(matchesRule(rule({ enabled: false }), h, "acme.com")).toBe(false);
+ expect(matchesRule(rule({ pausedReason: "rate limit" }), h, "acme.com")).toBe(false);
+ });
+
+ it("applies from and subject filters on top of scope", () => {
+ expect(matchesRule(rule({ fromPattern: "alice@" }), h, "acme.com")).toBe(true);
+ expect(matchesRule(rule({ fromPattern: "bob@" }), h, "acme.com")).toBe(false);
+ expect(matchesRule(rule({ subjectPattern: "overdue" }), h, "acme.com")).toBe(true);
+ expect(matchesRule(rule({ subjectPattern: "refund" }), h, "acme.com")).toBe(false);
+ });
+});
+
+describe("matchesPattern", () => {
+ it("is case-insensitive substring with * as the only wildcard", () => {
+ expect(matchesPattern("INVOICE", "Invoice 42")).toBe(true);
+ expect(matchesPattern("inv*42", "Invoice 42")).toBe(true);
+ expect(matchesPattern("*@acme.com", "bob@acme.com")).toBe(true);
+ });
+
+ it("treats regex metacharacters literally, so an operator cannot inject one", () => {
+ // A pasted `.*` must not match everything, and a pasted `(a+)+$` must not stall the
+ // mail path with catastrophic backtracking.
+ expect(matchesPattern(".*", "anything")).toBe(false);
+ expect(matchesPattern("a.c", "abc")).toBe(false);
+ expect(matchesPattern("a.c", "a.c")).toBe(true);
+ expect(matchesPattern("price?", "price?")).toBe(true);
+ expect(() => matchesPattern("(a+)+$", "aaaaaaaaaaaaaaaaaaaaaaaaaaa!")).not.toThrow();
+ });
+
+ it("an empty pattern is 'no filter', and a missing value never matches", () => {
+ expect(matchesPattern(" ", "anything")).toBe(true);
+ expect(matchesPattern("x", undefined)).toBe(false);
+ });
+});
diff --git a/apps/api/test/modules/mail/mail-backup-flavor.test.ts b/apps/api/test/modules/mail/mail-backup-flavor.test.ts
new file mode 100644
index 000000000..8b4cb12e7
--- /dev/null
+++ b/apps/api/test/modules/mail/mail-backup-flavor.test.ts
@@ -0,0 +1,78 @@
+/**
+ * The backup shell has to be written for the topology it will run on (GH-563).
+ *
+ * The produce/restore commands are BAKED here and executed later by the generic
+ * custom_command producer over a bare SSH executor, which knows nothing about mail. So a
+ * containerized engine cannot be discovered at run time - the plan has to already say
+ * `docker exec`. It did not: it issued `sudo -u postgres pg_dump` and
+ * `chown -R vmail:vmail` on the host, where on a container box there is no `postgres`
+ * user, no `pg_dump`, and no `vmail`. The dump aborted the whole backup under `set -e`,
+ * and on restore the chown was masked by `|| true`, leaving maildirs root-owned and
+ * unreadable by Dovecot while the run reported success.
+ */
+import { describe, expect, it } from "vitest";
+import { buildMailBackupPayload } from "../../../src/modules/mail/admin/backup-plan";
+
+const FLAGS = { messageData: true, keys: true };
+
+describe("mail backup plan targets the right topology", () => {
+ it("[container] runs Postgres in the sidecar and chown in the engine", () => {
+ const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, "container");
+ const { produceCommand: produce, restoreCommand: restore } = payloadConfig;
+
+ // Nothing may assume a host-side postgres or vmail user.
+ expect(produce).not.toContain("sudo -u postgres");
+ expect(restore).not.toContain("sudo -u postgres");
+ expect(produce).toContain("docker exec openship-mail-db pg_dump -U postgres");
+
+ // The dump file is staged in the producer's $tmp ON THE HOST, which the sidecar
+ // cannot see - so the replay must arrive over stdin, and docker exec needs -i.
+ expect(restore).toContain("docker exec -i openship-mail-db psql -U postgres");
+ expect(restore).not.toMatch(/psql[^\n]*-f "\$tmp/);
+
+ // Ownership must be applied where the vmail user exists: the ENGINE, not the sidecar,
+ // and not the host.
+ expect(restore).toContain("docker exec openship-mail chown -R vmail:vmail /var/vmail/vmail1");
+ // ...and a failure there must not be swallowed - root-owned maildirs are unreadable.
+ expect(restore).not.toMatch(/chown -R vmail:vmail [^\n]*\|\| true/);
+
+ // Daemons re-read via supervisord in the container; systemctl does not exist there.
+ expect(restore).toContain("supervisorctl restart postfix dovecot amavis");
+ expect(restore).not.toContain("systemctl reload");
+ });
+
+ it("[host] keeps the legacy sudo/systemctl form", () => {
+ const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, "host");
+ const { produceCommand: produce, restoreCommand: restore } = payloadConfig;
+
+ expect(produce).toContain("sudo -u postgres pg_dump -d vmail");
+ expect(restore).toContain("sudo -u postgres psql");
+ expect(restore).toContain("chown -R vmail:vmail /var/vmail/vmail1");
+ expect(restore).toContain("systemctl reload postfix dovecot");
+ expect(produce).not.toContain("docker exec");
+ expect(restore).not.toContain("docker exec");
+ });
+
+ it("proves sudo works before relying on it to tell absent from unreadable", () => {
+ // The `sudo -n test`/`sudo -n cp` reads below only distinguish those two cases if sudo
+ // is known to work. That used to be guaranteed by `sudo -u postgres pg_dump` running
+ // first under `set -e`; routing the dump through the sidecar removed the guarantee on
+ // container boxes, so the probe has to be explicit - otherwise a failing `test` reads
+ // as absence and the archive is stamped `keys: true` with no keys in it.
+ for (const flavor of ["container", "host"] as const) {
+ const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, flavor);
+ const produce = payloadConfig.produceCommand;
+ expect(produce).toContain("sudo -n true");
+ expect(produce.indexOf("sudo -n true")).toBeLessThan(produce.indexOf("sudo -n test -d /var/lib/dkim"));
+ }
+ });
+
+ it("keeps the maildir tar on the host, where the bind mount is", () => {
+ // /var/vmail is a bind mount, so tar reads it in place on either topology - moving it
+ // into the container would need the archive to come back out again.
+ for (const flavor of ["container", "host"] as const) {
+ const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, flavor);
+ expect(payloadConfig.produceCommand).toContain('sudo -n tar -c -C "$tmp" . -C /var/vmail vmail1');
+ }
+ });
+});
diff --git a/apps/api/test/modules/mail/mail-component-actions.test.ts b/apps/api/test/modules/mail/mail-component-actions.test.ts
new file mode 100644
index 000000000..4575f2b9a
--- /dev/null
+++ b/apps/api/test/modules/mail/mail-component-actions.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Two lies this surface used to tell (issue #565):
+ *
+ * 1. The logs drawer's header. The container engine's read is supervisord's
+ * per-program log, but the payload carried no record of that, so the drawer
+ * printed a `journalctl -u …` string naming a log the engine has not got.
+ * 2. "ClamAV restarted". supervisorctl prints `ERROR (abnormal termination)` and
+ * still exits 0 — it propagates an exit code for its OWN failures, not the
+ * program's — so grading on the exit code alone reported a daemon that never came
+ * up as a success.
+ *
+ * `components.service.ts` reaches the box only through `runMailCommand`, so the real
+ * builders and parsers stay live and only that one function is stubbed.
+ */
+
+const runMailCommand = vi.fn();
+vi.mock("../../../src/modules/mail/mail-engine", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runMailCommand: (...args: unknown[]) => runMailCommand(...args),
+}));
+
+import {
+ getComponentLogs,
+ runComponentAction,
+ MailComponentActionError,
+} from "../../../src/modules/mail/admin/components.service";
+
+/** Answer each build() with a canned output, marker appended as the real shell would. */
+function replies(...outputs: string[]) {
+ let i = 0;
+ runMailCommand.mockImplementation(
+ async (_target: unknown, build: (f: string) => string) => {
+ const command = build("container");
+ const output = outputs[i++] ?? "";
+ return {
+ flavor: "container",
+ output: command.includes("__EXIT=") ? `${output}\n__EXIT=0__` : output,
+ command,
+ };
+ },
+ );
+}
+
+/**
+ * `runComponentAction` waits out a settle delay before re-probing. Driving that with
+ * fake timers keeps the suite fast, but the action has to be STARTED before the clock
+ * is advanced — hence the split await rather than a plain one.
+ */
+async function act(...args: Parameters) {
+ const pending = runComponentAction(...args);
+ // A refused action rejects BEFORE it ever reaches the settle timer, so the rejection
+ // would be unattached for the length of the advance below. Park it.
+ pending.catch(() => {});
+ await vi.advanceTimersByTimeAsync(2_000);
+ return pending;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("getComponentLogs", () => {
+ it("reports the read it actually performed, not journalctl", async () => {
+ replies("line one\nline two");
+
+ const logs = await getComponentLogs("srv_1", "clamav", 300);
+
+ expect(logs.source).toBe(
+ "docker exec openship-mail tail -n 300 /var/log/supervisor/clamav-daemon.log",
+ );
+ expect(logs.source).not.toContain("journalctl");
+ expect(logs.lines).toEqual(["line one", "line two"]);
+ });
+
+ it("names the sidecar's container for the DB row", async () => {
+ replies("");
+
+ expect((await getComponentLogs("srv_1", "postgresql")).source).toBe(
+ "docker logs --tail 200 openship-mail-db",
+ );
+ });
+});
+
+describe("runComponentAction", () => {
+ it("fails a restart supervisorctl refused while exiting 0", async () => {
+ replies("clamav-daemon: ERROR (abnormal termination)");
+
+ await expect(act("srv_1", "clamav", "restart")).rejects.toBeInstanceOf(
+ MailComponentActionError,
+ );
+ });
+
+ it("reports the settled state rather than the acknowledgement", async () => {
+ replies(
+ "clamav-daemon: started",
+ "clamav-daemon FATAL Exited too quickly (process log may have details)",
+ );
+
+ const res = await act("srv_1", "clamav", "restart");
+
+ expect(res.settled?.status).toBe("failed");
+ expect(res.settled?.subState).toBe("fatal");
+ });
+
+ // `ERROR (not running)` after a stop means we are already where we asked to be, so
+ // it rides back in `output` for the panel to show instead of failing the call.
+ it("surfaces a benign refusal in the output instead of swallowing it", async () => {
+ replies("clamav-daemon: ERROR (not running)", "clamav-daemon STOPPED Not started");
+
+ const res = await act("srv_1", "clamav", "stop");
+
+ expect(res.output).toContain("ERROR (not running)");
+ expect(res.settled?.status).toBe("inactive");
+ });
+
+ // A transitional state is not a disagreement: `systemctl --no-block restart dovecot`
+ // returns instantly and the unit sits in `activating` for up to 90s. Reporting that
+ // as "has not come up" would cry wolf on every healthy slow restart, so the settle
+ // probe withholds a verdict instead.
+ it("withholds a verdict while the daemon is still starting", async () => {
+ replies("dovecot: started", "dovecot STARTING pid 41, uptime 0:00:01");
+
+ const res = await act("srv_1", "dovecot", "restart");
+
+ expect(res.settled).toBeUndefined();
+ });
+
+ it("treats a daemon this box does not ship as a no-op, not a refusal", async () => {
+ replies("iredapd: ERROR (no such process)", "iredapd: ERROR (no such process)");
+
+ const res = await act("srv_1", "iredapd", "restart");
+
+ expect(res.settled?.status).toBe("missing");
+ });
+});
diff --git a/apps/api/test/modules/mail/mail-db-not-initialized.test.ts b/apps/api/test/modules/mail/mail-db-not-initialized.test.ts
new file mode 100644
index 000000000..879e8ba46
--- /dev/null
+++ b/apps/api/test/modules/mail/mail-db-not-initialized.test.ts
@@ -0,0 +1,123 @@
+import "./_setup-env";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * An unseeded `vmail` must fail TYPED, with the fix in the message.
+ *
+ * The other half of GH-562: `db-bootstrap.sh` could exit 0 without having loaded the
+ * schema, leaving a box that serves SSH, runs the engine container, and answers every
+ * admin read with `relation "domain" does not exist`. That reached the panel as a bare
+ * 500 — "API 500" with no cause and no next step — because `runMailCommand` only
+ * re-types wrong-topology signatures and a missing relation matches none of them.
+ *
+ * Asserting on the STATUS and the MESSAGE, not on rows: the remediation text is the
+ * deliverable, since the dashboard renders `body.error` verbatim.
+ */
+
+vi.mock("@repo/adapters", () => ({
+ HOST_STATE_DIR: "/root/.openship",
+ detectMailEngine: vi.fn(),
+ MAIL_CONTAINER: "openship-mail",
+ MAIL_DB_CONTAINER: "openship-mail-db",
+ MAIL_DB_NAME: "vmail",
+ MAIL_HOST_PATHS: {
+ saslPasswd: "/opt/openship/mail/postfix/sasl_passwd",
+ senderRelayhost: "/opt/openship/mail/postfix/sender_relayhost",
+ amavisUserConf: "/opt/openship/mail/amavis/50-user",
+ },
+}));
+
+import { detectMailEngine } from "@repo/adapters";
+import {
+ forgetMailEngine,
+ runMailSql,
+ MailDbNotInitializedError,
+} from "../../../src/modules/mail/mail-engine";
+
+const CONTAINER = {
+ flavor: "container" as const,
+ running: true,
+ exists: true,
+ image: "ghcr.io/oblien/openship-mail:0.6.5",
+};
+const HOST = { flavor: "host" as const, running: true, exists: true, image: null };
+
+/** An executor whose psql always rejects with `stderr`, as the SSH layer does. */
+function failing(stderr: string) {
+ const exec = {
+ exec: vi.fn(async () => {
+ throw new Error(stderr);
+ }),
+ } as never;
+ // The topology memo is keyed by executor; a fresh object per case still needs the
+ // probe seeded, since forgetMailEngine only drops a cached answer.
+ forgetMailEngine(exec);
+ return exec;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+});
+
+describe("runMailSql — unseeded schema", () => {
+ it("types a missing table as 409 with the bootstrap command", async () => {
+ const exec = failing('ERROR: relation "domain" does not exist\nLINE 1: SELECT ...');
+
+ const err = await runMailSql(exec, "SELECT count(*) FROM domain").catch((e) => e);
+
+ expect(err).toBeInstanceOf(MailDbNotInitializedError);
+ expect(err.statusCode).toBe(409);
+ expect(err.code).toBe("MAIL_DB_NOT_INITIALIZED");
+ expect(err.message).toContain("db-bootstrap.sh");
+ // The daemons are already FATAL against the empty database by this point, and
+ // supervisord never retries that — the schema alone does not revive them.
+ expect(err.message).toContain("docker restart openship-mail");
+ // Must not read as a verdict while setup may still be mid-bootstrap.
+ expect(err.message).toContain("still running");
+ });
+
+ it("types a missing database the same way", async () => {
+ const exec = failing('psql: FATAL: database "vmail" does not exist');
+
+ const err = await runMailSql(exec, "SELECT 1").catch((e) => e);
+
+ expect(err).toBeInstanceOf(MailDbNotInitializedError);
+ expect(err.code).toBe("MAIL_DB_NOT_INITIALIZED");
+ });
+
+ it("points a legacy host box at mail setup, not at a docker command", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(HOST);
+ const exec = failing('ERROR: relation "mailbox" does not exist');
+
+ const err = await runMailSql(exec, "SELECT 1").catch((e) => e);
+
+ expect(err).toBeInstanceOf(MailDbNotInitializedError);
+ expect(err.message).toContain("Re-run mail setup");
+ expect(err.message).not.toContain("docker");
+ });
+
+ /**
+ * `column … does not exist` means OUR SQL disagrees with a schema that IS there.
+ * That is our bug to read verbatim, not an operator's to bootstrap away — and
+ * dressing it up as "no schema" would send them to run a command that fixes nothing.
+ */
+ it("leaves a schema mismatch as the raw error", async () => {
+ const exec = failing('ERROR: column "nonesuch" does not exist');
+
+ const err = await runMailSql(exec, "SELECT nonesuch FROM domain").catch((e) => e);
+
+ expect(err).not.toBeInstanceOf(MailDbNotInitializedError);
+ expect(err.message).toContain('column "nonesuch" does not exist');
+ });
+
+ it("leaves an unrelated psql failure alone", async () => {
+ const exec = failing("psql: error: connection to server failed: Connection refused");
+
+ const err = await runMailSql(exec, "SELECT 1").catch((e) => e);
+
+ expect(err).not.toBeInstanceOf(MailDbNotInitializedError);
+ expect(err.message).toContain("Connection refused");
+ });
+});
diff --git a/apps/api/test/modules/mail/mail-firewall-step.test.ts b/apps/api/test/modules/mail/mail-firewall-step.test.ts
index dd8deaf31..7b46e7680 100644
--- a/apps/api/test/modules/mail/mail-firewall-step.test.ts
+++ b/apps/api/test/modules/mail/mail-firewall-step.test.ts
@@ -13,12 +13,30 @@ import type { EnvironmentProfile } from "@repo/adapters";
* Only `resolveEnvironment` is mocked: `envOps`, `opScript` and the firewall tables are
* the real ones, so these assertions are on the command this host would really get.
*/
-const h = vi.hoisted(() => ({ host: {} as Partial }));
+const h = vi.hoisted(() => ({
+ host: {} as Partial,
+ /**
+ * The privilege gate, stubbed to the identity.
+ *
+ * `ufw`/`firewall-cmd` are root-only, so the step runs them through `rootOrDegrade`.
+ * That helper probes the host with its own `opsh_*` script, which would land in the
+ * asserted command list and make every expectation here about privilege detection
+ * instead of about firewall syntax — which is what this file exists to pin. The gate's
+ * own behaviour (elevate on sudo, report and degrade otherwise) is covered by the
+ * adapters' privilege tests; what THIS file asserts about it is only that the step goes
+ * through it at all, below.
+ */
+ gate: vi.fn(async (executor: unknown) => executor),
+}));
vi.mock("@repo/adapters", async (importOriginal) => {
const real = await importOriginal();
const fixtures = await import("../../../../../packages/adapters/src/system/environment.fixtures");
- return { ...real, resolveEnvironment: async () => fixtures.profileFixture(h.host) };
+ return {
+ ...real,
+ resolveEnvironment: async () => fixtures.profileFixture(h.host),
+ rootOrDegrade: h.gate,
+ };
});
import { MAIL_PORTS } from "@repo/adapters";
@@ -44,6 +62,7 @@ function run(exec: unknown) {
beforeEach(() => {
h.host = {};
+ h.gate.mockClear();
});
describe("stepOpenMailFirewall", () => {
@@ -78,6 +97,32 @@ describe("stepOpenMailFirewall", () => {
expect(all).not.toMatch(/\bufw\b/);
});
+ test("opens the ports through the privilege gate, not on the raw executor", async () => {
+ // `ufw`/`firewall-cmd` are root-only. This ran on the raw executor, so on a box we log
+ // into as a non-root sudo user every rule failed and the step reported the ports as
+ // REJECTED BY THE FIREWALL rather than as never attempted — a wrong diagnosis about
+ // the operator's host. The profile above only chooses the syntax; it never decided who
+ // runs it.
+ h.host = { firewall: "ufw" };
+ const { executor } = fakeExecutor();
+
+ await run(executor);
+
+ expect(h.gate).toHaveBeenCalledTimes(1);
+ expect(h.gate.mock.calls[0]?.[0]).toBe(executor);
+ });
+
+ test("does not reach for the gate when there is no firewall to open", async () => {
+ // No rules means no root-only command, and probing privileges to run nothing is a
+ // round-trip charged to every mail setup on a box with no firewall.
+ h.host = { firewall: "none" };
+ const { executor } = fakeExecutor();
+
+ await run(executor);
+
+ expect(h.gate).not.toHaveBeenCalled();
+ });
+
test("no active firewall → touches nothing and says so", async () => {
h.host = { firewall: "none" };
const { executor, commands } = fakeExecutor();
diff --git a/apps/api/test/modules/mail/mail-health-probe.test.ts b/apps/api/test/modules/mail/mail-health-probe.test.ts
index 0efb6b75b..fb0e11582 100644
--- a/apps/api/test/modules/mail/mail-health-probe.test.ts
+++ b/apps/api/test/modules/mail/mail-health-probe.test.ts
@@ -64,6 +64,20 @@ describe("parseMailUnitProbe — container flavor", () => {
);
expect(state.status).toBe("failed");
+ // FATAL and BACKOFF are both `failed`; `subState` is the ONLY thing that says
+ // whether supervisord is still trying, and the row's hint reads it.
+ expect(state.subState).toBe("fatal");
+ });
+
+ it("keeps BACKOFF distinguishable from FATAL through the sub-state", () => {
+ const state = parse(
+ "clamav-daemon BACKOFF Exited too quickly",
+ "clamav",
+ "clamav-daemon",
+ );
+
+ expect(state.status).toBe("failed");
+ expect(state.subState).toBe("backoff");
});
describe("postgresql sidecar", () => {
diff --git a/apps/api/test/modules/mail/mail-installer-invocation.test.ts b/apps/api/test/modules/mail/mail-installer-invocation.test.ts
index a1e6b6da1..fb7380321 100644
--- a/apps/api/test/modules/mail/mail-installer-invocation.test.ts
+++ b/apps/api/test/modules/mail/mail-installer-invocation.test.ts
@@ -24,10 +24,14 @@ vi.mock("@repo/adapters", async (importOriginal) => ({
// Health-gate reports everything up so the step reaches its success return.
vi.mock("../../../src/modules/mail/mail-health.service", () => ({
checkMailHealth: vi.fn(async () => [
- { key: "postfix", label: "Postfix", description: "", unit: "postfix", status: "active" },
- { key: "dovecot", label: "Dovecot", description: "", unit: "dovecot", status: "active" },
- { key: "postgresql", label: "PostgreSQL", description: "", unit: "postgresql", status: "active" },
+ { key: "postfix", label: "Postfix", description: "", unit: "postfix", severity: "required", status: "active" },
+ { key: "dovecot", label: "Dovecot", description: "", unit: "dovecot", severity: "required", status: "active" },
+ { key: "postgresql", label: "PostgreSQL", description: "", unit: "postgresql", severity: "required", status: "active" },
]),
+ // The gate reads the CATALOG, not `c.severity` off the row, so this mock must export
+ // it — a missing export throws loudly instead of silently gating on nothing.
+ requiresMailComponent: (key: string) =>
+ ["postfix", "dovecot", "postgresql"].includes(key),
MAIL_COMPONENTS: [],
}));
diff --git a/apps/api/test/modules/mail/mailbox-create-flavor.test.ts b/apps/api/test/modules/mail/mailbox-create-flavor.test.ts
new file mode 100644
index 000000000..0c7f452ae
--- /dev/null
+++ b/apps/api/test/modules/mail/mailbox-create-flavor.test.ts
@@ -0,0 +1,195 @@
+import "./_setup-env";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The mailbox-creation path must reach the mail engine, not the host (GH-562).
+ *
+ * `doveadm` and the `vmail` user live wherever Dovecot does. On a container-flavor
+ * box that is INSIDE `openship-mail` and emphatically not on the host — yet
+ * `hashPassword` ran a bare `doveadm pw` and `createMaildirOnDisk` ran a bare
+ * `chown -R vmail:vmail`, both straight against the host executor. The result was a
+ * 500 on every mailbox create on a containerized install: no `doveadm` on the host
+ * meant empty output, and the SSHA512 gate rejected it.
+ *
+ * Every existing test in this directory hands these helpers a `vi.fn()` executor and
+ * asserts on the ROWS, so all of them stayed green while the command that produced
+ * those rows could not run anywhere. The assertions here are deliberately on the
+ * COMMAND STRING, because the prefix is the entire bug.
+ *
+ * These run on every PR — no daemon needed. The companion real-Docker case is
+ * test/e2e/mail-db-bootstrap.e2e.test.ts, which covers the other half of GH-562.
+ */
+
+vi.mock("@repo/adapters", () => ({
+ HOST_STATE_DIR: "/root/.openship",
+ detectMailEngine: vi.fn(),
+ MAIL_CONTAINER: "openship-mail",
+ MAIL_DB_CONTAINER: "openship-mail-db",
+ MAIL_DB_NAME: "vmail",
+ MAIL_HOST_PATHS: {
+ saslPasswd: "/opt/openship/mail/postfix/sasl_passwd",
+ senderRelayhost: "/opt/openship/mail/postfix/sender_relayhost",
+ amavisUserConf: "/opt/openship/mail/amavis/50-user",
+ },
+}));
+
+import { detectMailEngine } from "@repo/adapters";
+import { hashPassword } from "../../../src/modules/mail/admin/password";
+import {
+ createMaildirOnDisk,
+ generateMaildir,
+ removeMaildirOnDisk,
+} from "../../../src/modules/mail/admin/maildir";
+import { forgetMailEngine } from "../../../src/modules/mail/mail-engine";
+
+const HASH = "{SSHA512}c2FsdGVkaGFzaHZhbHVl";
+
+const CONTAINER = {
+ flavor: "container" as const,
+ running: true,
+ exists: true,
+ image: "ghcr.io/oblien/openship-mail:0.6.5",
+};
+const HOST = { flavor: "host" as const, running: true, exists: true, image: null };
+
+/** An executor that records every command and answers with `reply`. */
+function recorder(reply: string) {
+ const calls: string[] = [];
+ const exec = {
+ exec: vi.fn(async (cmd: string) => {
+ calls.push(cmd);
+ return reply;
+ }),
+ };
+ forgetMailEngine(exec as never);
+ return { exec, calls, last: () => calls[calls.length - 1] ?? "" };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("hashPassword transport", () => {
+ it("runs doveadm INSIDE the engine on a container-flavor box", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder(HASH);
+
+ await expect(hashPassword(r.exec as never, "sekrit-pw")).resolves.toBe(HASH);
+
+ // The prefix IS the fix. Without it the host answers, and the host has no doveadm.
+ expect(r.last()).toContain("docker exec openship-mail ");
+ expect(r.last()).toContain("doveadm pw -s SSHA512");
+ });
+
+ it("runs doveadm bare on a legacy host-flavor box", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(HOST);
+ const r = recorder(HASH);
+
+ await expect(hashPassword(r.exec as never, "sekrit-pw")).resolves.toBe(HASH);
+
+ expect(r.last()).not.toContain("docker exec");
+ expect(r.last()).toContain("doveadm pw -s SSHA512");
+ });
+
+ it("keeps the plaintext out of the thrown message when doveadm answers nothing", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("");
+
+ // The empty-output case is exactly what a missing doveadm produced, and the
+ // operator saw only "500 Internal Server Error" for it.
+ await expect(hashPassword(r.exec as never, "sekrit-pw")).rejects.toThrow(/empty output/);
+ await expect(hashPassword(r.exec as never, "sekrit-pw")).rejects.not.toThrow(/sekrit-pw/);
+ });
+
+ it("names the engine flavor in the failure, so the reader knows which box answered", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("doveadm: command not found");
+
+ await expect(hashPassword(r.exec as never, "pw")).rejects.toThrow(/container engine/);
+ });
+});
+
+describe("createMaildirOnDisk transport + layout", () => {
+ it("creates the tree Dovecot actually opens: /Maildir/{cur,new,tmp}", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("");
+ const layout = generateMaildir("acme.com", "alice", new Date("2026-01-02T03:04:05Z"));
+
+ await createMaildirOnDisk(r.exec as never, layout);
+ const cmd = r.last();
+
+ // mail_location = maildir:%Lh/Maildir/ (engine/samples/dovecot/dovecot.conf:64),
+ // with home = //. A tree at /cur is one level shallow
+ // and Dovecot never reads it.
+ const home = `/var/vmail/vmail1/${layout.maildir}`;
+ expect(cmd).toContain(`${home}Maildir/cur`);
+ expect(cmd).toContain(`${home}Maildir/new`);
+ expect(cmd).toContain(`${home}Maildir/tmp`);
+ expect(cmd).not.toContain(`'${home}cur'`);
+ });
+
+ it("wraps the compound command in one sh -c so && cannot leak to the host shell", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("");
+
+ await createMaildirOnDisk(r.exec as never, generateMaildir("acme.com", "bob"));
+ const cmd = r.last();
+
+ // `docker exec a && b` would run `a` in the engine and `b` on the HOST —
+ // which is how a chown meant for the engine's vmail user hits a host without one.
+ expect(cmd).toMatch(/^docker exec openship-mail sh -c /);
+ const afterShC = cmd.slice(cmd.indexOf("sh -c "));
+ expect(afterShC.startsWith("sh -c '")).toBe(true);
+ // Every && must sit INSIDE the quoted script, never between top-level words.
+ expect(cmd.replace(/'.*'/s, "''")).not.toContain("&&");
+ });
+
+ it("chowns to vmail INSIDE the engine, where that user exists", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("");
+
+ await createMaildirOnDisk(r.exec as never, generateMaildir("acme.com", "carol"));
+
+ expect(r.last()).toContain("chown -R vmail:vmail");
+ expect(r.last()).toMatch(/^docker exec openship-mail /);
+ });
+
+ it("stays bare on a legacy host-flavor box", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(HOST);
+ const r = recorder("");
+
+ await createMaildirOnDisk(r.exec as never, generateMaildir("acme.com", "dave"));
+
+ expect(r.last()).not.toContain("docker exec");
+ expect(r.last()).toMatch(/^sh -c /);
+ });
+});
+
+describe("removeMaildirOnDisk", () => {
+ it("removes the home through the engine, covering the Maildir subtree", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("");
+ const layout = generateMaildir("acme.com", "erin");
+
+ await removeMaildirOnDisk(r.exec as never, layout);
+
+ expect(r.last()).toBe(
+ `docker exec openship-mail rm -rf '/var/vmail/vmail1/${layout.maildir}'`,
+ );
+ });
+
+ it("still refuses a path outside /var/vmail before any command is built", async () => {
+ vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER);
+ const r = recorder("");
+
+ await expect(
+ removeMaildirOnDisk(r.exec as never, {
+ storagebasedirectory: "/etc",
+ storagenode: "passwd",
+ maildir: "",
+ }),
+ ).rejects.toThrow(/Refusing to remove maildir outside/);
+ expect(r.calls).toHaveLength(0);
+ });
+});
diff --git a/apps/api/test/modules/mail/webmail-restart-loop-watch.test.ts b/apps/api/test/modules/mail/webmail-restart-loop-watch.test.ts
new file mode 100644
index 000000000..d2059c80f
--- /dev/null
+++ b/apps/api/test/modules/mail/webmail-restart-loop-watch.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import type { OpenshipReadiness } from "@repo/core";
+import { resolveReadinessGate } from "../../../src/modules/deployments/readiness-gate";
+
+/**
+ * The gate webmail arms, read through the resolver that decides what actually runs.
+ *
+ * Issue #566: a webmail container that exited on a missing `SESSION_ENCRYPTION_KEY` and
+ * restarted ten times finished its deploy as "the app is deployed and running". The
+ * restart-loop watch that would have caught it already existed (#335) — it is OFF by
+ * default, and webmail never asked for it.
+ *
+ * Pinned here rather than in the install test because the VALUE is the decision: it must
+ * enable stabilization (a restart-loop watch), must NOT enable the TCP probe (a different
+ * question, and up to 45s on the critical path), and must veto rather than warn — a warn
+ * leaves the deploy green, which is the bug.
+ */
+
+// Kept in sync with WEBMAIL_READINESS in webmail-install.service.ts, which is private to
+// that module; duplicating the literal is what makes a change to it fail here.
+const WEBMAIL_READINESS: OpenshipReadiness = { stabilization: true, onFailure: "fail" };
+
+describe("webmail's readiness gate", () => {
+ it("watches for a restart loop and vetoes the deploy", () => {
+ const gate = resolveReadinessGate(WEBMAIL_READINESS);
+
+ expect(gate.stabilization.enabled).toBe(true);
+ expect(gate.stabilization.windowMs).toBeGreaterThan(0);
+ expect(gate.onFailure).toBe("fail");
+ // `active` is what makes the pipeline run a gate at all rather than skip the step.
+ expect(gate.active).toBe(true);
+ });
+
+ it("does not turn on the TCP probe", () => {
+ expect(resolveReadinessGate(WEBMAIL_READINESS).probe.enabled).toBe(false);
+ });
+
+ it("is the difference from the default, which watches nothing", () => {
+ const off = resolveReadinessGate(undefined);
+
+ expect(off.active).toBe(false);
+ expect(off.stabilization.enabled).toBe(false);
+ // The default failure policy is a warning; ours must be an explicit veto.
+ expect(off.onFailure).toBe("warn");
+ });
+});
diff --git a/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts b/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts
index 256df16cb..29e266e3b 100644
--- a/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts
+++ b/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts
@@ -25,6 +25,7 @@ const cliPayload = {
environment: { NODE_ENV: "production", EMPTY: "" },
volumes: ["./static:/srv/static:ro"],
command: "node server.js",
+ commandArgv: ["node", "server.js"],
restart: "on-failure:3",
},
{ name: "db", image: "postgres:16", volumes: ["pgdata:/var/lib/postgresql/data"] },
@@ -75,6 +76,35 @@ describe("POST /projects/:id/services/sync body schema", () => {
expect(Value.Check(SyncServicesBody, dashboardPayload)).toBe(true);
});
+ it("advertises `commandArgv`, the only faithful way to send a list command (#332)", () => {
+ // Without it the string was the sole option, and the string form of a list
+ // command is a lossy join: `["sh","-c","a && b"]` → "sh -c a && b" → 5 words.
+ // An MCP agent reads this schema to know what it may send.
+ const entry = (
+ (SyncServicesBody as unknown as {
+ properties: { services: { items: { properties: Record } } };
+ }).properties.services.items.properties
+ );
+ expect(entry.commandArgv).toBeDefined();
+
+ expect(
+ Value.Check(SyncServicesBody, {
+ services: [{ name: "web", commandArgv: ["sh", "-c", "a && b"] }],
+ }),
+ ).toBe(true);
+ // `[]` is meaningful — it clears the image CMD.
+ expect(Value.Check(SyncServicesBody, { services: [{ name: "web", commandArgv: [] }] })).toBe(
+ true,
+ );
+ // argv entries are strings, like every other compose scalar here.
+ expect(Value.Check(SyncServicesBody, { services: [{ name: "web", commandArgv: [8080] }] })).toBe(
+ false,
+ );
+ expect(
+ Value.Check(SyncServicesBody, { services: [{ name: "web", commandArgv: "node server.js" }] }),
+ ).toBe(false);
+ });
+
it("accepts compose restart policies outside the four-value enum", () => {
expect(
Value.Check(SyncServicesBody, { services: [{ name: "web", restart: "on-failure:3" }] }),
diff --git a/apps/api/test/modules/mcp/mcp-tool-call-audit.test.ts b/apps/api/test/modules/mcp/mcp-tool-call-audit.test.ts
new file mode 100644
index 000000000..d3c762780
--- /dev/null
+++ b/apps/api/test/modules/mcp/mcp-tool-call-audit.test.ts
@@ -0,0 +1,139 @@
+/**
+ * Which tool calls get their own audit row.
+ *
+ * `source: "mcp"` told the log that an assistant acted, but only for the calls
+ * something else already recorded — the secureRouter auto-emitter, which fires on
+ * write/admin routes that returned 2xx/3xx. That left two holes, and they were the
+ * ones an operator asks about after the fact:
+ *
+ * - every READ an agent made (no row at all — read routes don't auto-emit)
+ * - every call it was REFUSED (the emitter skips non-2xx, so a denied write was
+ * indistinguishable from a call never made)
+ *
+ * The fix is one row per otherwise-unrecorded call, which puts the rule between two
+ * failure modes worth pinning down: record everything and every mutation an agent
+ * makes is logged twice, record nothing extra and reads go dark again.
+ */
+
+import { describe, expect, it, vi, beforeEach } from "vitest";
+
+const auditCreate = vi.hoisted(() => vi.fn(async () => ({})));
+
+vi.mock("@repo/db", () => ({ repos: { auditEvent: { create: auditCreate } } }));
+
+import { needsOwnAuditRow, recordToolCall } from "../../../src/modules/mcp/mcp-audit";
+import type { ToolCallActor, ToolCallRecord } from "../../../src/modules/mcp/mcp-audit";
+
+const ACTOR: ToolCallActor = {
+ organizationId: "org1",
+ userId: "u1",
+ clientId: "oauth:cli_abc",
+ tokenId: "pat_binding1",
+ ipAddress: "203.0.113.7",
+ userAgent: "claude-desktop/1.2",
+};
+
+function call(over: Partial = {}): ToolCallRecord {
+ return {
+ tool: "get_projects",
+ method: "GET",
+ path: "/api/projects",
+ action: "list",
+ status: 200,
+ ok: true,
+ ...over,
+ };
+}
+
+/** The row handed to the repo, after the fire-and-forget write settles. */
+async function rowFor(record: ToolCallRecord, actor: ToolCallActor = ACTOR) {
+ recordToolCall(actor, record);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ return auditCreate.mock.calls.at(-1)?.[0] as unknown as Record | undefined;
+}
+
+beforeEach(() => {
+ auditCreate.mockClear();
+});
+
+describe("the rule: only calls nothing else records", () => {
+ it("records reads and lists — the blind spot this exists for", () => {
+ expect(needsOwnAuditRow({ ok: true, action: "read" })).toBe(true);
+ expect(needsOwnAuditRow({ ok: true, action: "list" })).toBe(true);
+ });
+
+ it("stays out of the way of a successful write, which records itself", () => {
+ // route-permission.ts's emitter already wrote a row naming the resource and
+ // carrying the diff — a strictly better row than a tool-call row.
+ expect(needsOwnAuditRow({ ok: true, action: "write" })).toBe(false);
+ expect(needsOwnAuditRow({ ok: true, action: "admin" })).toBe(false);
+ });
+
+ it("records a write that FAILED — the emitter fires only on 2xx/3xx", () => {
+ // An agent pushing at the edge of its scope, which is the case where the
+ // absence of a row was worst.
+ expect(needsOwnAuditRow({ ok: false, action: "write" })).toBe(true);
+ expect(needsOwnAuditRow({ ok: false, action: "admin" })).toBe(true);
+ expect(needsOwnAuditRow({ ok: false, action: "read" })).toBe(true);
+ });
+
+ it("records an unrecognized action rather than dropping it", () => {
+ // A new permission verb must not silently create a fresh blind spot.
+ expect(needsOwnAuditRow({ ok: true, action: "create" })).toBe(true);
+ expect(needsOwnAuditRow({ ok: true, action: "" })).toBe(true);
+ });
+});
+
+describe("the row", () => {
+ it("is attributed to the agent, not just to 'an assistant'", async () => {
+ const row = await rowFor(call());
+ expect(row).toMatchObject({
+ organizationId: "org1",
+ actorUserId: "u1",
+ eventType: "mcp.tool_called",
+ resourceType: "mcp_client",
+ resourceId: "pat_binding1",
+ source: "mcp",
+ sourceClientId: "oauth:cli_abc",
+ });
+ });
+
+ it("carries the real client IP and user agent, not the loopback dispatch", async () => {
+ // An in-process sub-request has no peer and no UA; both are read off the outer
+ // request, which is the assistant's own.
+ const row = await rowFor(call());
+ expect(row).toMatchObject({ ipAddress: "203.0.113.7", userAgent: "claude-desktop/1.2" });
+ });
+
+ it("names the tool and the route it hit, with the status", async () => {
+ const row = await rowFor(call({ tool: "delete_project", method: "DELETE", path: "/api/projects/:id", action: "admin", status: 403, ok: false }));
+ expect(row?.after).toEqual({
+ tool: "delete_project",
+ route: "DELETE /api/projects/:id",
+ status: 403,
+ ok: false,
+ });
+ });
+
+ it("stores the route TEMPLATE, so no resource id rides along in it", async () => {
+ const row = await rowFor(call({ path: "/api/projects/:id/services/:serviceId" }));
+ expect(String((row?.after as { route: string }).route)).toContain(":serviceId");
+ });
+
+ it("never carries the arguments", async () => {
+ // Tool args routinely hold env values and secrets. The same reasoning keeps
+ // grant tuples out of mcp.scope_changed.
+ const row = await rowFor(call());
+ expect(Object.keys(row?.after as object).sort()).toEqual(["ok", "route", "status", "tool"]);
+ });
+
+ it("is skipped when no org resolved — there is nothing to attribute it to", async () => {
+ await rowFor(call(), { ...ACTOR, organizationId: null });
+ expect(auditCreate).not.toHaveBeenCalled();
+ });
+
+ it("writes nothing for a successful write", async () => {
+ await rowFor(call({ method: "POST", action: "write", status: 201 }));
+ expect(auditCreate).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/api/test/modules/notifications/categories-cloud-gate.test.ts b/apps/api/test/modules/notifications/categories-cloud-gate.test.ts
index e477d7b2d..a968622f7 100644
--- a/apps/api/test/modules/notifications/categories-cloud-gate.test.ts
+++ b/apps/api/test/modules/notifications/categories-cloud-gate.test.ts
@@ -88,4 +88,37 @@ describe("GET /categories billing gate", () => {
const { categories, groups } = await fetchCategories(true);
expect(new Set(categories.map((c) => c.group))).toEqual(new Set(groups.map((g) => g.id)));
});
+
+ // Mail is the mirror image of billing: the engine is self-hosted-only and the whole
+ // mail module is absent from the cloud runtime, so the gate has to run the other way.
+ // Nothing else pins the DIRECTION, and a later "simplification" that filtered
+ // CATEGORIES itself — or dropped one of the two branches — would ship green.
+ it("keeps the mail group on self-hosted", async () => {
+ const { categories, groups } = await fetchCategories(false);
+ expect(groups.map((g) => g.id)).toContain("mail");
+ expect(categories.map((c) => c.id)).toContain("mail.inbound_received");
+ });
+
+ it("drops the mail group from both arrays on cloud", async () => {
+ const { categories, groups } = await fetchCategories(true);
+
+ expect(groups.map((g) => g.id)).not.toContain("mail");
+ expect(categories.map((c) => c.id)).not.toContain("mail.inbound_received");
+ const groupIds = new Set(groups.map((g) => g.id));
+ for (const cat of categories) expect(groupIds).toContain(cat.group);
+ });
+
+ it("still renders an inbound-mail alert on a cloud box", async () => {
+ // Same registry-stays-complete guarantee as billing, in the other direction: a row
+ // stored before a migration to cloud must keep its label, not degrade to the raw id.
+ await fetchCategories(true);
+ expect(findCategory("mail.inbound_received")?.label).toBe("Inbound email received");
+ });
+
+ // Per-message events must never default on: the dispatcher's fallback fans a
+ // default-enabled category to every member's verified email channel, and a
+ // notification mail landing back on the watched engine captures itself.
+ it("never defaults inbound mail to enabled", async () => {
+ expect(findCategory("mail.inbound_received")?.defaultEnabled).toBe(false);
+ });
});
diff --git a/apps/api/test/modules/services/service-command-argv.test.ts b/apps/api/test/modules/services/service-command-argv.test.ts
new file mode 100644
index 000000000..e96086a31
--- /dev/null
+++ b/apps/api/test/modules/services/service-command-argv.test.ts
@@ -0,0 +1,157 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const projectRepo = vi.hoisted(() => ({ findById: vi.fn() }));
+const serviceRepo = vi.hoisted(() => ({
+ findById: vi.fn(),
+ update: vi.fn(),
+ listByProject: vi.fn(),
+ syncFromCompose: vi.fn(),
+}));
+
+vi.mock("@repo/db", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ repos: { ...actual.repos, project: projectRepo, service: serviceRepo },
+ };
+});
+
+import { syncComposeServices, updateService } from "../../../src/modules/services/service.service";
+
+/**
+ * #332 left the EDITORS behind: the compose parser produced `commandArgv`, but
+ * every writer that takes a text `command` ignored it. Two live consequences,
+ * both covered here:
+ * • PATCH — argv wins at deploy time, so a stale argv kept running the OLD
+ * command. The service form's command field did nothing on any row imported
+ * from a compose file.
+ * • sync/deploy — those wire shapes took only the string, and the stored string
+ * is a lossy display join for a list command, so a read-then-post-back re-split
+ * `["sh","-c","a && b"]` into five words. That decision now lives one layer
+ * down, in composeWritePatch (see packages/db compose-spec-command.test.ts).
+ */
+const ctx = { organizationId: "org_1" } as never;
+const project = { id: "proj_1", organizationId: "org_1", internalAlias: null };
+
+const row = (over: Record = {}) => ({
+ id: "svc_1",
+ projectId: project.id,
+ name: "web",
+ kind: "compose",
+ image: "ghcr.io/acme/app:1",
+ command: "server start",
+ commandArgv: ["server", "start"],
+ environment: {},
+ ports: [],
+ restart: "unless-stopped",
+ enabled: true,
+ exposed: false,
+ ...over,
+});
+
+/** The patch handed to repos.service.update. */
+const written = () => serviceRepo.update.mock.calls.at(-1)?.[1] as Record;
+/** The entries handed to repos.service.syncFromCompose. */
+const synced = () =>
+ serviceRepo.syncFromCompose.mock.calls.at(-1)?.[1] as Array>;
+
+beforeEach(() => {
+ projectRepo.findById.mockReset().mockResolvedValue(project);
+ serviceRepo.findById.mockReset().mockResolvedValue(row());
+ serviceRepo.update.mockReset().mockResolvedValue(undefined);
+ serviceRepo.listByProject.mockReset().mockResolvedValue([]);
+ serviceRepo.syncFromCompose.mockReset().mockResolvedValue([]);
+});
+
+describe("updateService — command edits keep argv in step", () => {
+ it("re-derives argv on a command edit, so the new command actually runs", async () => {
+ await updateService(ctx, project.id, "svc_1", { command: "server start --verbose" } as never);
+
+ expect(written().command).toBe("server start --verbose");
+ expect(written().commandArgv).toEqual(["server", "start", "--verbose"]);
+ });
+
+ it("gives a row with no argv real argv instead of resurrecting the `sh -c` wrap", async () => {
+ serviceRepo.findById.mockResolvedValue(row({ command: null, commandArgv: null }));
+
+ await updateService(ctx, project.id, "svc_1", { command: "server start" } as never);
+
+ expect(written().commandArgv).toEqual(["server", "start"]);
+ });
+
+ it("does not touch argv when the patch never mentions the command", async () => {
+ await updateService(ctx, project.id, "svc_1", { restart: "always" } as never);
+
+ expect(written()).not.toHaveProperty("commandArgv");
+ });
+
+ it("keeps a list command intact when the form echoes its lossy display join back", async () => {
+ // `command: ["sh","-c","a && b"]` is STORED as "sh -c a && b" for display. The
+ // service form posts every field it owns, so any unrelated save re-sends that
+ // string — re-splitting it would hand the container five arguments.
+ serviceRepo.findById.mockResolvedValue(
+ row({ command: "sh -c a && b", commandArgv: ["sh", "-c", "a && b"] }),
+ );
+
+ await updateService(ctx, project.id, "svc_1", {
+ command: "sh -c a && b",
+ restart: "always",
+ } as never);
+
+ expect(written().commandArgv).toEqual(["sh", "-c", "a && b"]);
+ });
+
+ it("clears argv when the command field is cleared", async () => {
+ await updateService(ctx, project.id, "svc_1", { command: "" } as never);
+
+ expect(written().command).toBeNull();
+ expect(written().commandArgv).toBeNull();
+ });
+
+ it("lets an explicit argv win over the string", async () => {
+ await updateService(ctx, project.id, "svc_1", {
+ command: "sh -c 'a && b'",
+ commandArgv: ["sh", "-c", "a && b"],
+ } as never);
+
+ expect(written().commandArgv).toEqual(["sh", "-c", "a && b"]);
+ });
+});
+
+describe("syncComposeServices — hands the command to the repo untouched", () => {
+ // The argv DECISION lives in composeWritePatch (packages/db), so that every
+ // writer into syncFromCompose gets it — including the deploy request's service
+ // list, which never passes through this function. What this layer owes is not
+ // mangling the command on the way there, while still restoring masked env.
+ it("forwards an explicit argv verbatim (what the CLI sends)", async () => {
+ serviceRepo.listByProject.mockResolvedValue([row()]);
+
+ await syncComposeServices(ctx, project.id, [
+ {
+ name: "web",
+ image: "ghcr.io/acme/app:1",
+ command: "sh -c a && b",
+ commandArgv: ["sh", "-c", "a && b"],
+ },
+ ]);
+
+ expect(synced()[0]?.commandArgv).toEqual(["sh", "-c", "a && b"]);
+ expect(synced()[0]?.command).toBe("sh -c a && b");
+ });
+
+ it("restores masked env without inventing an argv the caller didn't send", async () => {
+ serviceRepo.listByProject.mockResolvedValue([row({ environment: { SECRET: "real-value" } })]);
+
+ await syncComposeServices(ctx, project.id, [
+ {
+ name: "web",
+ image: "ghcr.io/acme/app:1",
+ command: "server start --verbose",
+ environment: { SECRET: "••••••••" },
+ },
+ ]);
+
+ expect(synced()[0]?.environment).toEqual({ SECRET: "real-value" });
+ expect(synced()[0]).not.toHaveProperty("commandArgv");
+ });
+});
diff --git a/apps/api/test/modules/system/containers-applying-endpoint.test.ts b/apps/api/test/modules/system/containers-applying-endpoint.test.ts
new file mode 100644
index 000000000..e92bea22b
--- /dev/null
+++ b/apps/api/test/modules/system/containers-applying-endpoint.test.ts
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * `GET /system/containers/applying` — the fleet's live apply progress.
+ *
+ * It answers from two stores because neither is the whole truth, and this pins the
+ * seam between them:
+ * - the cached rows know every component that was ACCEPTED, including the ones a
+ * bulk run has queued but not started (they have no session yet, and used to be
+ * byte-identical to "never asked for");
+ * - the in-memory sessions know how far the running ones have got and how the
+ * finished ones ended — the only place an outcome exists, since a component that
+ * lands clears its drift and its in-progress mark in the same row write.
+ */
+
+vi.mock("@repo/db", () => ({
+ repos: {
+ server: { listByOrganization: vi.fn() },
+ serverContainerStatus: { listByOrg: vi.fn() },
+ },
+}));
+vi.mock("../../../src/lib/controller-helpers", () => ({
+ assertNotCloud: vi.fn(() => undefined),
+}));
+vi.mock("../../../src/lib/request-context", () => ({
+ getRequestContext: () => ({ userId: "u1", organizationId: "org_1" }),
+}));
+
+import { repos } from "@repo/db";
+import { listApplyingContainers } from "../../../src/modules/system/server-containers.controller";
+import {
+ createContainerApplySession,
+ finishContainerApplySession,
+} from "../../../src/lib/server-container-session";
+
+interface Progress {
+ active: {
+ serverId: string;
+ serverName: string;
+ component: string;
+ state: "queued" | "running";
+ intent: "update" | "repair" | null;
+ sessionId?: string;
+ steps?: { id: string; status: string }[];
+ }[];
+ recent: { serverId: string; component: string; ok: boolean; error?: string }[];
+}
+
+const mocked = {
+ server: vi.mocked(repos.server),
+ status: vi.mocked(repos.serverContainerStatus),
+};
+
+const row = (over: Record = {}) => ({
+ serverId: "srv_1",
+ component: "edge",
+ behind: true,
+ latestInProgress: true,
+ detail: null,
+ ...over,
+});
+
+async function call(): Promise {
+ return (await listApplyingContainers({ json: (body: unknown) => body } as never)) as never;
+}
+
+/** Unique per case: the session store is module-global. */
+let n = 0;
+const nextServer = () => `srv_live_${++n}`;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocked.server.listByOrganization.mockResolvedValue([
+ { id: "srv_1", name: "web-1", sshHost: "10.0.0.1" },
+ ] as never);
+ mocked.status.listByOrg.mockResolvedValue([] as never);
+});
+
+describe("listApplyingContainers", () => {
+ it("is empty when nothing is in flight", async () => {
+ expect(await call()).toEqual({ active: [], recent: [] });
+ });
+
+ it("reports an accepted-but-unstarted target as queued, with no session", async () => {
+ mocked.status.listByOrg.mockResolvedValue([row()] as never);
+
+ const { active } = await call();
+ expect(active).toMatchObject([
+ { serverId: "srv_1", serverName: "web-1", component: "edge", state: "queued", intent: "update" },
+ ]);
+ expect(active[0]!.sessionId).toBeUndefined();
+ });
+
+ it("promotes a target to running once its session exists, and carries its steps", async () => {
+ mocked.status.listByOrg.mockResolvedValue([row()] as never);
+ const session = createContainerApplySession("srv_1", "edge");
+ try {
+ const { active } = await call();
+ expect(active).toMatchObject([{ state: "running", sessionId: session.id }]);
+ expect(active[0]!.steps?.map((s) => s.id)).toEqual(["pull", "recreate", "verify"]);
+ } finally {
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+ }
+ });
+
+ it("names the intent from the row — a stopped component is being restarted", async () => {
+ mocked.status.listByOrg.mockResolvedValue([
+ row({ behind: false, detail: { down: true } }),
+ ] as never);
+
+ expect((await call()).active).toMatchObject([{ intent: "repair" }]);
+ });
+
+ it("still reports a running swap whose cached row went missing", async () => {
+ const id = nextServer();
+ mocked.server.listByOrganization.mockResolvedValue([
+ { id, name: null, sshHost: "10.0.0.9" },
+ ] as never);
+ const session = createContainerApplySession(id, "mail");
+ try {
+ const { active } = await call();
+ expect(active).toMatchObject([
+ // No row means no recorded intent — the caller falls back to update wording.
+ { serverId: id, serverName: "10.0.0.9", component: "mail", state: "running", intent: null },
+ ]);
+ } finally {
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+ }
+ });
+
+ it("counts a component once when both stores describe it", async () => {
+ mocked.status.listByOrg.mockResolvedValue([row()] as never);
+ const session = createContainerApplySession("srv_1", "edge");
+ try {
+ expect((await call()).active).toHaveLength(1);
+ } finally {
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+ }
+ });
+
+ it("reports how a just-finished apply ended, success and failure alike", async () => {
+ const ok = nextServer();
+ const bad = nextServer();
+ mocked.server.listByOrganization.mockResolvedValue([
+ { id: ok, name: "ok-box", sshHost: "10.0.0.2" },
+ { id: bad, name: "bad-box", sshHost: "10.0.0.3" },
+ ] as never);
+ const good = createContainerApplySession(ok, "edge");
+ const failed = createContainerApplySession(bad, "edge");
+ finishContainerApplySession(good.id, "completed", { updated: true, down: false });
+ finishContainerApplySession(failed.id, "failed", undefined, "could not pull the image");
+
+ const { active, recent } = await call();
+ expect(active).toEqual([]);
+ expect(recent).toMatchObject([
+ { serverId: ok, ok: true },
+ { serverId: bad, ok: false, error: "could not pull the image" },
+ ]);
+ });
+
+ it("never reports a server outside the caller's org", async () => {
+ const foreign = nextServer();
+ // The org owns a different box entirely, so neither store may surface this run.
+ mocked.server.listByOrganization.mockResolvedValue([
+ { id: nextServer(), name: "ours", sshHost: "10.0.0.4" },
+ ] as never);
+ const session = createContainerApplySession(foreign, "edge");
+ try {
+ expect(await call()).toEqual({ active: [], recent: [] });
+ } finally {
+ finishContainerApplySession(session.id, "completed", { updated: true, down: false });
+ }
+ });
+});
diff --git a/apps/api/test/modules/system/local-row-readonly-fields.test.ts b/apps/api/test/modules/system/local-row-readonly-fields.test.ts
new file mode 100644
index 000000000..193054d00
--- /dev/null
+++ b/apps/api/test/modules/system/local-row-readonly-fields.test.ts
@@ -0,0 +1,58 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+/**
+ * #527: the isLocal row's connection fields must stay refused, INCLUDING the next one
+ * somebody adds.
+ *
+ * `updateServer` refuses a connection edit on the row that represents this box, because
+ * nothing dials with those fields — the container→host channel authenticates with
+ * OPENSHIP_HOST_SSH_*, and `ensureLocalServer` reverts `ssh_user` on the next read anyway.
+ * Storing them meant the UI displayed a credential that was neither used nor kept, which
+ * is where #527's reporter spent most of a dozen messages.
+ *
+ * The failure mode this guards is drift, not logic: the guard is a list, and a list goes
+ * stale the moment someone adds `sshFoo` to the patch builder and not to it — restoring
+ * exactly the silent-accept behaviour for one field, on the one row where it means
+ * nothing. Asserted over the SOURCE rather than by importing the controller: the check is
+ * about which lines exist, and importing would drag repos/permission/audit into a test
+ * that needs none of them.
+ */
+describe("updateServer — every ssh* field it accepts is refused on the local row", () => {
+ const SRC = readFileSync(
+ join(__dirname, "../../../src/modules/system/servers.controller.ts"),
+ "utf8",
+ );
+
+ /** The declared list. */
+ const listed = (() => {
+ const block = SRC.match(/LOCAL_ROW_READONLY_FIELDS = \[([\s\S]*?)\] as const/);
+ expect(block, "LOCAL_ROW_READONLY_FIELDS was renamed or removed").toBeTruthy();
+ return new Set([...block![1].matchAll(/"(\w+)"/g)].map((m) => m[1]));
+ })();
+
+ /** Every ssh* field the patch builder actually writes. */
+ const handled = new Set(
+ [...SRC.matchAll(/if \(body\.(ssh\w+) !== undefined\)/g)].map((m) => m[1]),
+ );
+
+ it("finds the fields at all, so a rewrite can't make this vacuously pass", () => {
+ expect(handled.size).toBeGreaterThan(5);
+ expect(listed.size).toBeGreaterThan(5);
+ });
+
+ it("refuses every ssh* field the handler can write", () => {
+ expect([...handled].filter((f) => !listed.has(f))).toEqual([]);
+ });
+
+ it("lists nothing the handler doesn't write, so the refusal names real fields", () => {
+ expect([...listed].filter((f) => !handled.has(f))).toEqual([]);
+ });
+
+ it("leaves `name` writable — renaming this row is meaningful", () => {
+ expect(listed.has("name")).toBe(false);
+ expect(SRC).toContain("if (body.name !== undefined) patch.name");
+ });
+});
diff --git a/apps/api/test/modules/system/server-containers.test.ts b/apps/api/test/modules/system/server-containers.test.ts
index 039c1b173..3fc1961ad 100644
--- a/apps/api/test/modules/system/server-containers.test.ts
+++ b/apps/api/test/modules/system/server-containers.test.ts
@@ -39,6 +39,9 @@ vi.mock("@repo/db", () => ({
vi.mock("@repo/adapters", () => ({
dockerAvailable: vi.fn().mockResolvedValue(true),
+ // Only the bulk apply reaches this, and only to pre-check 80/443 before promising
+ // an edge REPAIR. Clean by default so classification isn't the thing under test.
+ probeEdge: vi.fn().mockResolvedValue({ canProceedClean: true }),
detectEdgeContainer: vi
.fn()
.mockResolvedValue({ name: null, running: false, image: null, exists: false }),
@@ -76,6 +79,7 @@ vi.mock("../../../src/lib/ssh-manager", () => ({
import {
imageTag,
+ applyAllContainers,
classifyContainerIssues,
detectServerContainers,
applyServerContainer,
@@ -332,6 +336,75 @@ describe("detectServerContainers", () => {
expect(mocked.status.upsert).not.toHaveBeenCalled();
expect(mocked.status.remove).not.toHaveBeenCalledWith("srv_1", "edge");
});
+
+ it("never writes the in-progress flag — a scan must not clear a running apply", async () => {
+ mocked.edge.mockResolvedValue(edgeContainer("ghcr.io/oblien/openship-edge:0.4.0") as never);
+
+ await detectServerContainers(server);
+
+ // The repo preserves the flag only when the payload omits it. A probe knows what
+ // the box runs, not whether a swap is mid-flight; writing its default is what let
+ // the 6-hourly scan (and the dashboard's own mount auto-scan) erase the state.
+ const payload = mocked.status.upsert.mock.calls
+ .map(([p]) => p as { component: string })
+ .find((p) => p.component === "edge");
+ expect(payload).toBeDefined();
+ expect(Object.keys(payload!)).not.toContain("latestInProgress");
+ });
+});
+
+describe("applyAllContainers", () => {
+ const box = (id: string) => ({ id, name: id.toUpperCase(), sshHost: `10.0.0.${id.slice(-1)}` });
+ const behindEdge = (serverId: string) => ({
+ serverId,
+ component: "edge" as const,
+ behind: true,
+ latestInProgress: false,
+ detail: null,
+ });
+
+ it("flags every accepted target before returning — queued ones included", async () => {
+ // Five targets against a concurrency of 3: two of them cannot have been started
+ // by the time the response is built, and used to be indistinguishable from
+ // "never asked for" on every surface that reads the cache.
+ const servers = ["srv_1", "srv_2", "srv_3", "srv_4", "srv_5"];
+ mocked.server.listByOrganization.mockResolvedValue(servers.map(box) as never);
+ mocked.status.listByOrg.mockResolvedValue(servers.map(behindEdge) as never);
+
+ const result = await applyAllContainers("org_1", ["update"]);
+
+ expect(result.started).toHaveLength(5);
+ expect(result.skipped).toEqual([]);
+ for (const id of servers) {
+ expect(mocked.status.setInProgress).toHaveBeenCalledWith(id, "edge", true);
+ }
+ });
+
+ it("refuses a target whose apply is already in flight", async () => {
+ mocked.server.listByOrganization.mockResolvedValue([box("srv_1")] as never);
+ mocked.status.listByOrg.mockResolvedValue([
+ { ...behindEdge("srv_1"), latestInProgress: true },
+ ] as never);
+
+ const result = await applyAllContainers("org_1", ["update"]);
+
+ expect(result.started).toEqual([]);
+ expect(result.skipped).toMatchObject([{ serverId: "srv_1", reason: "already_running" }]);
+ expect(mocked.reconcileEdge).not.toHaveBeenCalled();
+ });
+
+ it("acts only on the intents it was given", async () => {
+ mocked.server.listByOrganization.mockResolvedValue([box("srv_1"), box("srv_2")] as never);
+ mocked.status.listByOrg.mockResolvedValue([
+ behindEdge("srv_1"),
+ { serverId: "srv_2", component: "edge" as const, behind: false, latestInProgress: false, detail: { down: true } },
+ ] as never);
+
+ const result = await applyAllContainers("org_1", ["repair"]);
+
+ expect(result.started).toMatchObject([{ serverId: "srv_2", intent: "repair" }]);
+ expect(mocked.status.setInProgress).not.toHaveBeenCalledWith("srv_1", "edge", true);
+ });
});
describe("applyServerContainer", () => {
diff --git a/apps/api/test/modules/tokens/mcp-client-scope.test.ts b/apps/api/test/modules/tokens/mcp-client-scope.test.ts
index a3f32cdf9..51bcbbe1d 100644
--- a/apps/api/test/modules/tokens/mcp-client-scope.test.ts
+++ b/apps/api/test/modules/tokens/mcp-client-scope.test.ts
@@ -45,6 +45,7 @@ const mocks = vi.hoisted(() => ({
scoped: boolean;
createdAt: Date;
lastUsedAt: Date | null;
+ useCount: number;
},
/** Grants attached to that binding. */
grants: [] as Grant[],
@@ -58,6 +59,7 @@ const mocks = vi.hoisted(() => ({
organizationId: args.organizationId,
})),
auditCreate: vi.fn(async () => {}),
+ disconnectMcpClient: vi.fn(async () => {}),
}));
vi.mock("@repo/db", () => ({
@@ -85,7 +87,10 @@ vi.mock("@repo/db", () => ({
upsertOAuthBindingWithGrants: mocks.upsertOAuthBindingWithGrants,
},
organization: { findManyById: vi.fn(async () => [{ id: ORG, name: "Acme" }]) },
- oauth: { listApplicationsByClientIds: vi.fn(async () => [{ clientId: "cli1", name: "Claude Code" }]) },
+ oauth: {
+ listApplicationsByClientIds: vi.fn(async () => [{ clientId: "cli1", name: "Claude Code" }]),
+ disconnectMcpClient: mocks.disconnectMcpClient,
+ },
auditEvent: { create: mocks.auditCreate },
project: { findById: vi.fn(async (id: string) => ({ id, organizationId: ORG })), findEnvVarById: vi.fn(async () => null) },
server: { get: vi.fn(async (id: string) => ({ id, organizationId: ORG })) },
@@ -100,7 +105,11 @@ vi.mock("@repo/db", () => ({
}));
vi.mock("../../../src/config/env", () => ({ env: { CLOUD_MODE: false } }));
-import { authorizeMcpClient, getMcpClient } from "../../../src/modules/tokens/token.controller";
+import {
+ authorizeMcpClient,
+ disconnectMcpClient,
+ getMcpClient,
+} from "../../../src/modules/tokens/token.controller";
interface Reply {
body: { data?: Record; error?: string; code?: string };
@@ -162,6 +171,12 @@ async function read(clientId: string): Promise {
return reply();
}
+async function disconnect(clientId: string): Promise {
+ const { c, reply } = request({}, { clientId });
+ await disconnectMcpClient(c);
+ return reply();
+}
+
function connect(over: Partial> = {}) {
mocks.state.binding = {
id: "binding1",
@@ -172,6 +187,7 @@ function connect(over: Partial> = {}) {
scoped: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
lastUsedAt: null,
+ useCount: 0,
...over,
};
}
@@ -186,6 +202,7 @@ beforeEach(() => {
mocks.state.userGrants = [];
mocks.upsertOAuthBindingWithGrants.mockClear();
mocks.auditCreate.mockClear();
+ mocks.disconnectMcpClient.mockClear();
});
describe("GET one client", () => {
@@ -394,3 +411,74 @@ describe("the audit row carries counts, never the grant tuples", () => {
expect(call.before ?? null).toBeNull();
});
});
+
+/**
+ * Authorizing and re-scoping an agent were recorded; REVOKING it was not. That
+ * left the one MCP lifecycle event with no trace, and a log that reads as though a
+ * client which is long gone still holds the scope it was last seen with.
+ */
+describe("disconnecting is recorded too", () => {
+ it("records mcp.disconnected with the scope the client HELD", async () => {
+ connect({ scoped: true, readOnly: true, useCount: 42 });
+ mocks.state.grants = [PROJECT_GRANT, { ...PROJECT_GRANT, resourceId: "P2" }];
+
+ const r = await disconnect("cli1");
+ expect(r.status).toBe(200);
+ expect(mocks.disconnectMcpClient).toHaveBeenCalledWith("u1", "cli1");
+
+ const call = mocks.auditCreate.mock.calls.at(-1)?.[0] as unknown as Record;
+ expect(call).toMatchObject({
+ eventType: "mcp.disconnected",
+ resourceType: "mcp_client",
+ resourceId: "binding1",
+ organizationId: ORG,
+ });
+ // Read BEFORE the teardown — afterwards the binding and its grants are gone,
+ // and the row could only have said "something named cli1 was disconnected".
+ expect(call.before).toMatchObject({
+ clientId: "cli1",
+ scoped: true,
+ readOnly: true,
+ grantCount: 2,
+ useCount: 42,
+ });
+ expect(call.after ?? null).toBeNull();
+ });
+
+ it("keeps the grant tuples out of the row, like every other mcp.* event", async () => {
+ connect();
+ mocks.state.grants = [PROJECT_GRANT];
+ await disconnect("cli1");
+ const call = mocks.auditCreate.mock.calls.at(-1)?.[0] as unknown as Record;
+ expect(JSON.stringify(call)).not.toContain("P1");
+ });
+
+ it("still records when the binding is already gone", async () => {
+ // A double-click, or a client disconnected in another tab: the teardown is
+ // idempotent, and the attempt is still part of the history.
+ mocks.state.binding = null;
+ const r = await disconnect("cli1");
+ expect(r.status).toBe(200);
+ const call = mocks.auditCreate.mock.calls.at(-1)?.[0] as unknown as Record;
+ expect(call).toMatchObject({ eventType: "mcp.disconnected", resourceId: "cli1" });
+ expect(call.before).toEqual({ clientId: "cli1" });
+ });
+
+ it("400s on a blank clientId, and tears down nothing", async () => {
+ // A present-but-empty param: an absent one throws in `param()` and is mapped
+ // by the error handler, so this guard is what catches "/mcp-clients/%20".
+ const r = await disconnect(" ");
+ expect(r.status).toBe(400);
+ expect(r.body.code).toBe("CLIENT_ID_REQUIRED");
+ expect(mocks.disconnectMcpClient).not.toHaveBeenCalled();
+ expect(mocks.auditCreate).not.toHaveBeenCalled();
+ });
+});
+
+describe("the client list carries its usage", () => {
+ it("reports the call count and the key for this client's audit rows", async () => {
+ connect({ useCount: 1204 });
+ const r = await read("cli1");
+ expect(r.body.data).toMatchObject({ useCount: 1204, auditClientId: "oauth:cli1" });
+ });
+});
diff --git a/apps/api/vitest.e2e.config.ts b/apps/api/vitest.e2e.config.ts
index f83651a3e..2b6eb5ee8 100644
--- a/apps/api/vitest.e2e.config.ts
+++ b/apps/api/vitest.e2e.config.ts
@@ -22,8 +22,17 @@ import { sharedTestOptions, testAlias } from "./vitest.config";
* cross-platform and there is exactly one entry point to run these locally.
*/
const HEAVY = "test/e2e/rollback-build-restore.e2e.test.ts";
+/**
+ * `update` is its own scope because it needs things a checkout does not have: the
+ * PREVIOUS release's published images, and an api image for the new side. CI runs it
+ * between `build-images` and `merge-images` in docker-images.yml, where the new image
+ * exists as a pushed digest — so it is excluded from every other scope, INCLUDING the
+ * local default, rather than silently pulling a release on someone's laptop.
+ */
+const UPDATE = "test/e2e/update-from-previous-release.e2e.test.ts";
const scope = process.env.E2E_SCOPE;
-const include = scope === "heavy" ? [HEAVY] : ["test/e2e/**/*.e2e.test.ts"];
+const include =
+ scope === "heavy" ? [HEAVY] : scope === "update" ? [UPDATE] : ["test/e2e/**/*.e2e.test.ts"];
/**
* The sandbox `backup-volume-roundtrip` puts its destination inside, read back
@@ -54,7 +63,12 @@ export default defineConfig({
BACKUP_ALLOW_LOCAL_DESTINATION: "true",
BACKUP_LOCAL_ROOT: BACKUP_ROOT,
},
- exclude: [...configDefaults.exclude, ...(scope === "fast" ? [HEAVY] : [])],
+ exclude: [
+ ...configDefaults.exclude,
+ ...(scope === "fast" ? [HEAVY] : []),
+ // Opt-in only: `E2E_SCOPE=update` is the sole way to run it (see UPDATE above).
+ ...(scope === "update" ? [] : [UPDATE]),
+ ],
// Pulling and building images and streaming volumes all happen in
// beforeAll. There is no sane default here, which is why every E2E hook
// currently passes its own timeout inline.
diff --git a/apps/cli/src/commands/wizard.ts b/apps/cli/src/commands/wizard.ts
index 9497b0215..ff0110571 100644
--- a/apps/cli/src/commands/wizard.ts
+++ b/apps/cli/src/commands/wizard.ts
@@ -919,10 +919,18 @@ export async function runWizard(): Promise {
migratedCertPems,
migratedStaticRootOverrides,
);
- if (!imported.ok) {
+ // A PARTIAL import is not a total failure: `importMigratedSites` returns
+ // ok:false when even one site missed, so keying the warning off `ok` and
+ // printing `migratedSites.length` claimed every site was dark one line after
+ // the import itself said "Migrated 3/4". Report only the real shortfall, and
+ // leave the retry advice to the import — it's the only layer that knows
+ // whether the cause was transient (edge still starting) or a config it will
+ // reject identically on every re-run.
+ const missed = migratedSites.length - imported.registered.length;
+ if (missed > 0) {
log.warn(
- `Your ${migratedSites.length} existing site${migratedSites.length === 1 ? "" : "s"} ` +
- "aren't served yet — re-run `openship up` to retry the import.",
+ `${missed} of your ${migratedSites.length} existing site${migratedSites.length === 1 ? "" : "s"} ` +
+ `${missed === 1 ? "isn't" : "aren't"} served yet — see the import output above.`,
);
}
}
diff --git a/apps/cli/src/lib/compose.ts b/apps/cli/src/lib/compose.ts
index 1d4b3af7f..a9915e0af 100644
--- a/apps/cli/src/lib/compose.ts
+++ b/apps/cli/src/lib/compose.ts
@@ -39,7 +39,9 @@ import { sanitizeEdgeVhosts } from "@repo/adapters/proxy";
import {
DEFAULT_IMAGE_REGISTRY,
explainHostChannelCause,
+ hostChannelAccount,
HOST_CHANNEL_BLOCKED,
+ HOST_CHANNEL_DEFAULT_ACCOUNT,
HOST_CHANNEL_RECHECK,
HOST_CHANNEL_UNAFFECTED,
wrapText,
@@ -793,10 +795,18 @@ export type HostChannelTarget = {
user: string;
keyPath: string;
authKeysPath: string;
- viaSudo: boolean;
/** No way to reach root (non-root invoker, no passwordless sudo): the channel logs
* in as the invoker and CANNOT do root host ops — the caller must warn. */
rootUnavailable: boolean;
+ /**
+ * How this channel reaches root once connected — what the api will do at run time, not
+ * what provisioning did.
+ *
+ * `login` is a root session; `sudo` is a non-root session that elevates per operation
+ * through `privilegedExecutor`. Surfaced so the dry-run preview can say which, instead
+ * of repeating a root warning that no longer applies to the sudo case.
+ */
+ elevation: "login" | "sudo" | "none";
};
const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
@@ -805,15 +815,36 @@ const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
* PURE. Decide which account the container→host SSH channel logs in as, given the
* invoking user and whether passwordless sudo is available.
*
- * The platform runs every host op AS ROOT — `/root/.openship` state, iRedMail install,
- * the edge binding :80/:443 — and the auto-created server record defaults `ssh_user=root`.
- * So the channel must be root, or the two disagree and host ops fail on root-owned paths
- * (issue #489: `mkdir: cannot create directory '/root': Permission denied`).
- *
- * - invoked AS root → already root, write /root directly.
- * - non-root + passwordless sudo → authorize root's authorized_keys via sudo, log in as root.
- * - non-root, no sudo → fall back to the invoking user (a channel that can't do
- * root host ops) and flag it so the caller warns loudly.
+ * Host operations need root. They do NOT need a root LOGIN — that is the distinction this
+ * function used to miss. `privilegedExecutor` already elevates a non-root sudo session per
+ * operation (packages/adapters/src/system/privilege.ts), and the deploy path, the
+ * installer and the on-box state store all reach root that way. So a sudo-capable operator
+ * gets a channel that logs in AS THEM and elevates, rather than one that mints a standing
+ * root SSH credential to buy access the platform already had.
+ *
+ * Minting it was the old middle arm, and it bought a materially bigger blast radius than
+ * the docker socket this channel is justified against — the argument this file already
+ * makes about the key's own scope. It also broke boxes it was supposed to help: a host with
+ * `PermitRootLogin no` (a normal hardening choice, and #527's likely shape) got a root
+ * channel sshd refuses, after the working alternative had been revoked.
+ *
+ * - invoked AS root → already root, write /root directly. Nothing to mint.
+ * - non-root + passwordless sudo → log in as the invoker and ELEVATE per operation.
+ * - non-root, no sudo → the invoker, with no route to root at all; flagged so
+ * the caller warns loudly.
+ *
+ * What still needs a root login, and is therefore degraded on the sudo arm: the host
+ * TERMINAL is a PTY channel that no elevation decorator wraps, so it opens an unprivileged
+ * shell (the operator types `sudo -i`). SFTP cannot be elevated either, which is why
+ * `scratchDir()` exists beside `stateDir()` in adapters.
+ *
+ * An operator who WANTS a root channel runs the installer as root — `sudo openship up`
+ * makes the invoker uid 0 and takes the first arm. That is deliberately the only way to
+ * end up with a standing root credential: this function will not mint one for an account
+ * it is not logging in as. It is also the escape hatch for a box provisioned as root by an
+ * older CLI, because a re-run as a non-root sudo user MIGRATES that install to an invoker
+ * channel (and revokes the old root line). Nothing has to move for that: the on-box state
+ * under /root/.openship is reached through `privilegedExecutor`, which elevates.
*
* Exported for tests: this decision is the whole fix, so it's verified directly.
*/
@@ -824,17 +855,23 @@ export function chooseHostChannelUser(input: {
hasPasswordlessSudo: boolean;
}): Omit {
if (input.invokerUid === 0) {
- return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: false, rootUnavailable: false };
- }
- if (input.hasPasswordlessSudo) {
- return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: true, rootUnavailable: false };
+ return {
+ user: "root",
+ authKeysPath: ROOT_AUTHORIZED_KEYS,
+ rootUnavailable: false,
+ elevation: "login",
+ };
}
- return {
+ const invoker = {
user: input.invokerName,
authKeysPath: join(input.invokerHome, ".ssh", "authorized_keys"),
- viaSudo: false,
- rootUnavailable: true,
};
+ if (input.hasPasswordlessSudo) {
+ // Reaches root through sudo at run time, so this is NOT `rootUnavailable` — the
+ // caller must not warn that mail and edge will fail, because they won't.
+ return { ...invoker, rootUnavailable: false, elevation: "sudo" };
+ }
+ return { ...invoker, rootUnavailable: true, elevation: "none" };
}
/**
@@ -893,30 +930,12 @@ function authorizeKeyAt(authKeysPath: string, pub: string): void {
}
/**
- * The same read-modify-write, but on /root/.ssh/authorized_keys through `sudo -n`, for a
- * non-root invoker with passwordless sudo. Returns false if any step fails so the caller
- * can fall back rather than leave a half-authorized root channel.
+ * Openship no longer AUTHORIZES a root key on a non-root box — `chooseHostChannelUser`
+ * logs in as the invoker and elevates instead, so there is nothing to mint. The revoke
+ * below is deliberately kept: an install provisioned by an older CLI has a root line we
+ * are no longer using, and leaving a standing root credential behind would be worse than
+ * having created it.
*/
-function authorizeRootKeyViaSudo(pub: string): boolean {
- const mk = spawnSync("sudo", ["-n", "sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"], {
- stdio: "ignore",
- });
- if (mk.status !== 0) return false;
- const read = spawnSync(
- "sudo",
- ["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
- { encoding: "utf8" },
- );
- if (read.status !== 0) return false;
- const next = rewriteHostAuthorizedKeys(read.stdout ?? "", pub);
- // `tee` (not a shell `>`) so the redirect happens under root, not this shell.
- const write = spawnSync("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
- input: next,
- stdio: ["pipe", "ignore", "ignore"],
- });
- if (write.status !== 0) return false;
- return spawnSync("sudo", ["-n", "chmod", "600", ROOT_AUTHORIZED_KEYS], { stdio: "ignore" }).status === 0;
-}
/**
* Best-effort: strip our line from an `authorized_keys` this process can write.
@@ -937,8 +956,9 @@ function revokeKeyAt(authKeysPath: string): boolean {
}
}
-/** The same, on /root/.ssh/authorized_keys through `sudo -n` — the mirror of
- * {@link authorizeRootKeyViaSudo}, for a non-root invoker with passwordless sudo. */
+/** The same, on /root/.ssh/authorized_keys through `sudo -n`. Sweeps a root line left by
+ * an older CLI (or by a box that used to be provisioned as root) once this install has
+ * moved to an invoker channel. */
function revokeRootKeyViaSudo(): boolean {
const read = spawnSync(
"sudo",
@@ -980,7 +1000,7 @@ function retireHostSshChannel(prev: Record): void {
// Which file holds the line is what the channel logged in AS — root unless it fell
// back to the invoker (#489). A pre-#489 install wrote the invoker's file either way,
// so that one is always swept too.
- const asRoot = (prev.OPENSHIP_HOST_SSH_USER?.trim() || "root") === "root";
+ const asRoot = hostChannelAccount(prev) === HOST_CHANNEL_DEFAULT_ACCOUNT;
const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0;
const revoked = asRoot
? weAreRoot
@@ -1025,12 +1045,26 @@ type HostChannelIssueCode =
/** Reading or authorizing it threw: a `mkdir`/`authorized_keys` this user can't touch. */
| "error"
/** Provisioned fine, but nothing on this host is listening for SSH (see probeHostSshd). */
- | "no-sshd";
+ | "no-sshd"
+ /**
+ * sshd is listening and REFUSED the key we just authorized (see verifyChannelAuth).
+ *
+ * The state #527 had no name for. `authorized_keys` is a file we write, not a verdict
+ * sshd gives: it can be the wrong account's file, or the right one on a host whose
+ * sshd permits that account no login at all. Both passed every check this install ran,
+ * so the operator was told host control was on and found out weeks later, through an
+ * unrelated operation, in wording that blamed their own credentials.
+ */
+ | "auth-refused";
export interface HostChannelIssue {
code: HostChannelIssueCode;
/** The errno/message/port behind it, verbatim, for the operator. */
detail?: string;
+ /** The host account the channel logs in as. Only `auth-refused` needs it, and needs it
+ * badly: "the key was refused" is unactionable until you know WHICH account to go and
+ * authorize it for (root via sudo, or the invoking user — see chooseHostChannelUser). */
+ account?: string;
}
interface HostChannelProvision {
@@ -1072,14 +1106,21 @@ function provisionHostSshChannel(cfg: {
}): HostChannelProvision {
const target = plannedHostChannel(cfg.hostControl);
if (!target) return { channel: null };
- const { user, keyPath, authKeysPath, viaSudo, rootUnavailable } = target;
+ const { user, keyPath, authKeysPath, elevation, rootUnavailable } = target;
const provisioned = (channel: { user: string; keyPath: string }): HostChannelProvision => {
// The key is authorized and `.env` is about to point the api at the host, but a key
// is not a server: `ssh-keygen` is the openssh CLIENT and `authorized_keys` is just a
// file, so every step above succeeds on a box with no sshd at all.
const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
- return probeHostSshd(port) === "absent"
- ? { channel, issue: { code: "no-sshd", detail: `port ${port}` } }
+ if (probeHostSshd(port) === "absent") {
+ return { channel, issue: { code: "no-sshd", detail: `port ${port}` } };
+ }
+ // And a LISTENER is not an accepted key. This is the check whose absence is #527:
+ // everything above can succeed while sshd still refuses the account, and the only
+ // thing that can settle it is asking sshd.
+ const auth = verifyChannelAuth(channel.user, channel.keyPath, port);
+ return auth.state === "refused"
+ ? { channel, issue: { code: "auth-refused", detail: auth.detail, account: channel.user } }
: { channel };
};
try {
@@ -1111,32 +1152,75 @@ function provisionHostSshChannel(cfg: {
return provisioned({ user, keyPath });
}
- if (viaSudo) {
- if (!authorizeRootKeyViaSudo(pub)) {
- // `sudo -n true` passed but a step still failed — don't leave a half-authorized root
- // channel. Fall back to the invoker's own file and dial in as them, with a warning.
- console.warn(
- " ⚠ Could not authorize the host key for root via sudo — falling back to the\n" +
- " current user. Root host operations (mail/edge) may fail.",
- );
- const invoker = userInfo().username;
- authorizeKeyAt(join(homedir(), ".ssh", "authorized_keys"), pub);
- return provisioned({ user: invoker, keyPath });
- }
- // The channel now logs in as root, so any line the old CLI left under the invoking
- // user is dead — revoke it.
- revokeKeyAt(join(homedir(), ".ssh", "authorized_keys"));
- return provisioned({ user, keyPath });
- }
-
- // Root invoker writing /root directly (authKeysPath === /root/.ssh/authorized_keys).
+ // The account's OWN file, on every arm: a root invoker writes /root directly, and a
+ // non-root one writes its own home. Nothing is ever authorized for an account we are
+ // not logging in as — which is what collapsed the old write-goes-through-sudo
+ // distinction: no arm touches another account's authorized_keys any more.
authorizeKeyAt(authKeysPath, pub);
+ // Switching AWAY from root must not leave the old root key live. Symmetric with the
+ // sweep the root-ward transition used to do alone, and cheap: `revokeRootKeyViaSudo`
+ // is a no-op when there is no line of ours to remove.
+ if (elevation === "sudo") revokeRootKeyViaSudo();
return provisioned({ user, keyPath });
} catch (err) {
return { channel: null, issue: { code: "error", detail: (err as Error)?.message } };
}
}
+type ChannelAuthCheck =
+ | { state: "ok" }
+ | { state: "refused"; detail: string }
+ /** The question could not be asked — no `ssh` client, or it never ran. Never reported
+ * as a refusal: failing an otherwise-good install over a missing binary would be the
+ * opposite mistake. */
+ | { state: "unknown" };
+
+/**
+ * Does sshd actually ACCEPT this key for this account?
+ *
+ * Dialed at 127.0.0.1, not `host.docker.internal` — that name only resolves inside the
+ * container, and it is not the half in question. This asks the one thing only sshd can
+ * answer (is this key authorized for this user, and may this user log in at all); the
+ * bridge half is what `probeHostSshd` and the firewall probe already cover.
+ *
+ * `BatchMode=yes` is load-bearing: without it a host that falls back to password auth
+ * would sit at a prompt inside `openship up` forever. `UserKnownHostsFile=/dev/null`
+ * keeps a verification dial from writing an entry for a host the operator never asked to
+ * trust, and `PreferredAuthentications=publickey` stops a success arriving via some other
+ * method — which would report the channel working when its key is not.
+ */
+function verifyChannelAuth(user: string, keyPath: string, port: number): ChannelAuthCheck {
+ const r = spawnSync(
+ "ssh",
+ [
+ "-i", keyPath,
+ "-p", String(port),
+ "-o", "BatchMode=yes",
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "ConnectTimeout=5",
+ "-o", "PreferredAuthentications=publickey",
+ "-o", "IdentitiesOnly=yes",
+ `${user}@127.0.0.1`,
+ "true",
+ ],
+ { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], timeout: 15_000 },
+ );
+
+ if (r.status === 0) return { state: "ok" };
+ if ((r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" || r.status === null) {
+ return { state: "unknown" };
+ }
+ // `StrictHostKeyChecking=no` announces every first contact on stderr; that line is not
+ // a cause and would bury the one that is.
+ const detail = (r.stderr || "")
+ .split("\n")
+ .map((l) => l.trim())
+ .filter((l) => l && !/^Warning: Permanently added/.test(l))
+ .join(" ");
+ return { state: "refused", detail: detail || `ssh exited ${r.status}` };
+}
+
/** Why `ssh-keygen` didn't produce a key — a missing binary is the common one, and it
* reads as an exit code with no output unless it's named. */
function keygenWhy(r: { status: number | null; stderr?: string; error?: Error }): string {
@@ -1214,7 +1298,7 @@ export function listensOnPort(listing: string, port: number): boolean {
function carriedHostChannel(prev: Record): { user: string; keyPath: string } | null {
const keyPath = prev.OPENSHIP_HOST_KEY_PATH?.trim();
if (!prev.OPENSHIP_HOST_SSH_HOST?.trim() || !keyPath || !existsSync(keyPath)) return null;
- return { user: prev.OPENSHIP_HOST_SSH_USER?.trim() || "root", keyPath };
+ return { user: hostChannelAccount(prev), keyPath };
}
const HOST_ISSUE_INDENT = " ";
@@ -1252,7 +1336,9 @@ export function renderHostChannelIssue(
issue.code === "no-sshd"
? ` ⚠ Host control is provisioned, but nothing on this host is listening for SSH` +
`${detail}.`
- : ` ⚠ Host control could NOT be provisioned on this box.`;
+ : issue.code === "auth-refused"
+ ? ` ⚠ Host control is provisioned, but this host REFUSED the channel's key.`
+ : ` ⚠ Host control could NOT be provisioned on this box.`;
let cause: string;
switch (issue.code) {
@@ -1278,13 +1364,25 @@ export function renderHostChannelIssue(
sshdEnableHint: thisHost().sshdEnableHint(),
}).body;
break;
+ case "auth-refused":
+ // The one cause here an operator can act on immediately, so it names both the
+ // possibilities and the command that shows which — #527's reporter had neither, and
+ // spent a dozen messages moving key files that were never read.
+ cause =
+ `The key was authorized for \`${issue.account ?? "the channel account"}\` and sshd ` +
+ `is listening, but the dial was refused${detail}. Either that account's ` +
+ `\`authorized_keys\` is not the ` +
+ `file we wrote to, or sshd permits it no login — \`sshd -T | grep -i ` +
+ `permitrootlogin\` shows the second. Until it is fixed the api container will be ` +
+ `refused every time it dials ${ctx.target}.`;
+ break;
case "error":
cause = `Setting up the channel's key failed${detail}.`;
break;
}
const continuity =
- issue.code === "no-sshd"
+ issue.code === "no-sshd" || issue.code === "auth-refused"
? ""
: ctx.kept
? hostIssueNote(
@@ -2040,7 +2138,7 @@ export function composeHostChannel(): { host: string; port: number; user: string
return {
host,
port: toPort(env.OPENSHIP_HOST_SSH_PORT) ?? 22,
- user: env.OPENSHIP_HOST_SSH_USER?.trim() || "root",
+ user: hostChannelAccount(env),
};
}
diff --git a/apps/cli/src/lib/repair.ts b/apps/cli/src/lib/repair.ts
index 29d883a6a..f3cad1434 100644
--- a/apps/cli/src/lib/repair.ts
+++ b/apps/cli/src/lib/repair.ts
@@ -26,6 +26,7 @@ import {
import { startService, ensureInternalToken } from "../commands/up";
import {
summarizeHostChannelCause,
+ HOST_CHANNEL_AUTH_REJECTED_SHORT,
HOST_CHANNEL_PROVISION_COMMAND,
type HostChannelCause,
} from "@repo/core";
@@ -279,6 +280,11 @@ export function hostControlRow(
case "key_unreadable":
case "unreachable":
return row("fail", `${api.state.replace(/_/g, " ")} — see the api container's boot log`);
+ // Its own case, NOT grouped above: this is the one fault here an operator can act
+ // on without the withheld address, and #527 is what happens when the only thing
+ // we tell them is to go read a log.
+ case "auth_rejected":
+ return row("fail", HOST_CHANNEL_AUTH_REJECTED_SHORT);
// `disabled` (a hardening choice) and `not_applicable` (a bare install) are the
// same non-event the local path returns null for. Whitelisted rather than derived
// from `ok === false`, which `disabled` also is.
diff --git a/apps/cli/src/lib/up-plan.ts b/apps/cli/src/lib/up-plan.ts
index 44479465d..195ae014b 100644
--- a/apps/cli/src/lib/up-plan.ts
+++ b/apps/cli/src/lib/up-plan.ts
@@ -174,10 +174,19 @@ export async function planUp(opts: UpPlanOpts): Promise {
...(stack.hostChannel
? [
stack.hostChannel.keyPath,
- `${stack.hostChannel.authKeysPath}${stack.hostChannel.viaSudo ? " (via sudo)" : ""} (authorizes that key for ${stack.hostChannel.user}'s host ops; --no-host-control skips it)`,
+ `${stack.hostChannel.authKeysPath} (authorizes that key for ${stack.hostChannel.user}'s host ops; --no-host-control skips it)`,
+ // Say which route to root this channel will take, rather than repeating a root
+ // warning at a box that reaches root perfectly well via sudo. `sudo` is a
+ // supported shape now, not a degradation, and the preview is where an operator
+ // decides whether that is what they wanted.
+ ...(stack.hostChannel.elevation === "sudo"
+ ? [
+ `logs in as ${stack.hostChannel.user} and elevates with sudo for root host ops (no root login is created)`,
+ ]
+ : []),
...(stack.hostChannel.rootUnavailable
? [
- "⚠ host control needs root — this user isn't root and passwordless sudo isn't available, so mail/edge host ops will fail (re-run as root to fix)",
+ "⚠ host control needs root — this user isn't root and passwordless sudo isn't available, so mail/edge host ops will fail (re-run as root, or enable passwordless sudo, to fix)",
]
: []),
]
diff --git a/apps/cli/test/unit/compose-env-preserve.test.ts b/apps/cli/test/unit/compose-env-preserve.test.ts
index f164a532c..8e204ba03 100644
--- a/apps/cli/test/unit/compose-env-preserve.test.ts
+++ b/apps/cli/test/unit/compose-env-preserve.test.ts
@@ -46,6 +46,16 @@ const h = vi.hoisted(() => ({
stdout: "LISTEN 0 128 0.0.0.0:22 0.0.0.0:*",
stderr: "",
}),
+ /**
+ * The verification dial (`ssh … true`) that proves sshd ACCEPTS the key we just
+ * authorized (#527). Default: it does — so the refusal warning only fires for the
+ * tests that ask for it, the same convention `listeners` follows.
+ *
+ * Declared explicitly rather than left to the mock's status-0 fallthrough: a check
+ * whose default answer is an accident is a check that stops being exercised the moment
+ * the fallthrough changes.
+ */
+ channelAuth: () => ({ status: 0, stdout: "", stderr: "" }),
}));
vi.mock("node:child_process", () => ({
@@ -68,6 +78,8 @@ vi.mock("node:child_process", () => ({
// Host-channel provisioning: the keypair, then the sshd prerequisite (#509).
if (cmd === "ssh-keygen") return h.keygen();
if (cmd === "ss" || cmd === "netstat") return h.listeners();
+ // ...then the dial that asks sshd whether the key actually works (#527).
+ if (cmd === "ssh") return h.channelAuth();
// #486 preflight: registry manifest probe. Driven per-ref by h.manifestInspect.
if (cmd === "docker" && args[0] === "manifest" && args[1] === "inspect") {
return h.manifestInspect(String(args[2]));
@@ -1536,6 +1548,52 @@ describe("composeUp — the host channel is provisioned out loud (#509)", () =>
expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal");
expect(warned.join("\n")).not.toContain("listening for SSH");
});
+
+ /**
+ * #527: a LISTENER is not an accepted key.
+ *
+ * The reporter's box passed every check above — key generated, `authorized_keys`
+ * written, sshd listening on 22 — and the install said host control was on. The channel
+ * was refused on every dial, which they discovered weeks later through an unrelated
+ * feature, worded as their own stored credentials being wrong. The only thing that can
+ * settle it is asking sshd, and nothing did.
+ */
+ it("reports a key sshd refuses, and still writes the channel", async () => {
+ seedHostKey();
+ h.channelAuth = () => ({
+ status: 255,
+ stdout: "",
+ stderr: "root@127.0.0.1: Permission denied (publickey).",
+ });
+
+ const res = await composeUp({});
+
+ // Still not fatal, and the key stays authorized: it is right the moment the host
+ // permits that account, exactly as the no-sshd case stays right once sshd starts.
+ expect(res.ok).toBe(true);
+ expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal");
+ const msg = warned.join("\n");
+ expect(msg).toContain("REFUSED the channel's key");
+ // The account, because "the key was refused" is unactionable without knowing which
+ // authorized_keys to go and look at.
+ expect(msg).toContain("root");
+ // Both causes, and the command that distinguishes them — the half the reporter was
+ // never told, and could not have guessed from inside a container.
+ expect(msg).toContain("permitrootlogin");
+ expect(msg).toContain("Ordinary deploys to this box still work");
+ });
+
+ it("stays quiet when the verification dial can't run at all", async () => {
+ // No `ssh` client: the question was never asked, and answering "refused" would fail
+ // an install that is fine — the mirror of the ss-not-installed case above.
+ seedHostKey();
+ h.channelAuth = () => ({ status: null, stdout: "", stderr: "", error: Object.assign(new Error("spawn ssh ENOENT"), { code: "ENOENT" }) });
+
+ await composeUp({});
+
+ expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal");
+ expect(warned.join("\n")).not.toContain("REFUSED");
+ });
});
describe("composePgDataRisk — the disk-facing gate entry (#487)", () => {
diff --git a/apps/cli/test/unit/host-ssh-authorized-keys.test.ts b/apps/cli/test/unit/host-ssh-authorized-keys.test.ts
index f490ad764..b929c9403 100644
--- a/apps/cli/test/unit/host-ssh-authorized-keys.test.ts
+++ b/apps/cli/test/unit/host-ssh-authorized-keys.test.ts
@@ -147,23 +147,26 @@ describe("chooseHostChannelUser", () => {
expect(out).toEqual({
user: "root",
authKeysPath: "/root/.ssh/authorized_keys",
- viaSudo: false,
rootUnavailable: false,
+ elevation: "login",
});
});
- it("authorizes root via sudo when a non-root user has passwordless sudo", () => {
+ it("logs in as the invoker and elevates when a non-root user has passwordless sudo", () => {
const out = chooseHostChannelUser({
invokerUid: 1000,
invokerName: "ubuntu",
invokerHome: "/home/ubuntu",
hasPasswordlessSudo: true,
});
+ // The change #527 argued for: root is reached by ELEVATING this session, not by
+ // minting a standing root SSH credential. `rootUnavailable` stays false because root
+ // is still reachable — through sudo — so the caller must not warn that mail/edge fail.
expect(out).toEqual({
- user: "root",
- authKeysPath: "/root/.ssh/authorized_keys",
- viaSudo: true,
+ user: "ubuntu",
+ authKeysPath: "/home/ubuntu/.ssh/authorized_keys",
rootUnavailable: false,
+ elevation: "sudo",
});
});
@@ -177,8 +180,8 @@ describe("chooseHostChannelUser", () => {
expect(out).toEqual({
user: "deploy",
authKeysPath: "/home/deploy/.ssh/authorized_keys",
- viaSudo: false,
rootUnavailable: true,
+ elevation: "none",
});
});
});
diff --git a/apps/dashboard/scripts/check-i18n.mjs b/apps/dashboard/scripts/check-i18n.mjs
index 343d5f634..8eadfab65 100644
--- a/apps/dashboard/scripts/check-i18n.mjs
+++ b/apps/dashboard/scripts/check-i18n.mjs
@@ -30,18 +30,45 @@ function leafKeys(obj, prefix = "") {
return out;
}
+function leafEntries(obj, prefix = "", out = {}) {
+ for (const [k, v] of Object.entries(obj)) {
+ const kp = prefix ? `${prefix}.${k}` : k;
+ if (v && typeof v === "object" && !Array.isArray(v)) leafEntries(v, kp, out);
+ else out[kp] = v;
+ }
+ return out;
+}
+
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
+/**
+ * Is this value worth flagging when a locale copies English verbatim?
+ *
+ * Plenty of strings are LEGITIMATELY identical across languages — "Email", "DNS",
+ * "GitHub", "OK", "URL", a `••••••••` placeholder, `you@example.com`. Flagging those
+ * would bury the real signal, so this only considers values that read like PROSE:
+ * multiple words, and long enough that a shared product noun is unlikely.
+ *
+ * That is a heuristic, deliberately. It exists to catch the case that key-parity
+ * cannot see at all — a key that is PRESENT in every locale while still holding the
+ * English sentence, so the UI silently renders English and every test stays green.
+ */
+function looksTranslatable(value) {
+ return typeof value === "string" && value.trim().length > 3 && /\s/.test(value.trim());
+}
+
/**
* Compare every non-English locale to the English source across all namespaces.
* @returns {{
* missing: {locale:string,namespace:string,key:string}[],
* extra: {locale:string,namespace:string,key:string}[],
+ * untranslated: {locale:string,namespace:string,key:string}[],
* byNamespaceMissing: Record,
* byLocaleMissing: Record,
- * totalMissing: number, totalExtra: number,
+ * byNamespaceUntranslated: Record,
+ * totalMissing: number, totalExtra: number, totalUntranslated: number,
* }}
*/
export function checkI18nParity(localesDir = defaultLocalesDir()) {
@@ -56,18 +83,29 @@ export function checkI18nParity(localesDir = defaultLocalesDir()) {
const missing = [];
const extra = [];
+ const untranslated = [];
const byNamespaceMissing = {};
const byLocaleMissing = {};
+ const byNamespaceUntranslated = {};
for (const ns of namespaces) {
- const base = leafKeys(readJson(path.join(enDir, `${ns}.json`)));
+ const baseEntries = leafEntries(readJson(path.join(enDir, `${ns}.json`)));
+ const base = Object.keys(baseEntries);
const baseSet = new Set(base);
for (const locale of locales) {
const file = path.join(localesDir, locale, `${ns}.json`);
- let localeKeys = new Set();
- if (fs.existsSync(file)) localeKeys = new Set(leafKeys(readJson(file)));
+ let localeEntries = {};
+ if (fs.existsSync(file)) localeEntries = leafEntries(readJson(file));
+ const localeKeys = new Set(Object.keys(localeEntries));
for (const k of base) if (!localeKeys.has(k)) missing.push({ locale, namespace: ns, key: k });
for (const k of localeKeys) if (!baseSet.has(k)) extra.push({ locale, namespace: ns, key: k });
+ // Present, but still the English sentence — invisible to the two checks above.
+ for (const k of localeKeys) {
+ if (!baseSet.has(k)) continue;
+ if (localeEntries[k] === baseEntries[k] && looksTranslatable(baseEntries[k])) {
+ untranslated.push({ locale, namespace: ns, key: k });
+ }
+ }
}
}
@@ -75,14 +113,20 @@ export function checkI18nParity(localesDir = defaultLocalesDir()) {
byNamespaceMissing[m.namespace] = (byNamespaceMissing[m.namespace] ?? 0) + 1;
byLocaleMissing[m.locale] = (byLocaleMissing[m.locale] ?? 0) + 1;
}
+ for (const u of untranslated) {
+ byNamespaceUntranslated[u.namespace] = (byNamespaceUntranslated[u.namespace] ?? 0) + 1;
+ }
return {
missing,
extra,
+ untranslated,
byNamespaceMissing,
byLocaleMissing,
+ byNamespaceUntranslated,
totalMissing: missing.length,
totalExtra: extra.length,
+ totalUntranslated: untranslated.length,
};
}
@@ -95,12 +139,15 @@ if (isMain()) {
const full = process.argv.includes("--full");
const r = checkI18nParity();
- if (r.totalMissing === 0 && r.totalExtra === 0) {
+ if (r.totalMissing === 0 && r.totalExtra === 0 && r.totalUntranslated === 0) {
console.log("✓ i18n parity: every locale matches the English source.");
process.exit(0);
}
- console.log(`i18n drift vs "${SOURCE_LOCALE}" — missing: ${r.totalMissing}, extra: ${r.totalExtra}\n`);
+ console.log(
+ `i18n drift vs "${SOURCE_LOCALE}" — missing: ${r.totalMissing}, extra: ${r.totalExtra}, ` +
+ `untranslated: ${r.totalUntranslated}\n`,
+ );
const nsRows = Object.entries(r.byNamespaceMissing).sort((a, b) => b[1] - a[1]);
if (nsRows.length) {
@@ -114,6 +161,14 @@ if (isMain()) {
for (const [l, n] of locRows) console.log(` ${l.padEnd(6)} ${n}`);
console.log("");
}
+ // Present in every locale, still the English sentence — the case key parity cannot
+ // see, and the one that renders English in the UI while looking complete.
+ const untRows = Object.entries(r.byNamespaceUntranslated).sort((a, b) => b[1] - a[1]);
+ if (untRows.length) {
+ console.log("Untranslated VALUES by namespace (locale string == English):");
+ for (const [ns, n] of untRows) console.log(` ${ns.padEnd(18)} ${n}`);
+ console.log("");
+ }
if (full) {
const group = (items) => {
@@ -125,6 +180,13 @@ if (isMain()) {
}
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
};
+ if (r.untranslated.length) {
+ console.log("UNTRANSLATED (key → locales still showing English):");
+ for (const [id, locs] of group(r.untranslated)) {
+ console.log(` ${id} [${[...locs].sort().join(",")}]`);
+ }
+ console.log("");
+ }
if (r.missing.length) {
console.log("MISSING (key → locales that lack it):");
for (const [id, locs] of group(r.missing)) console.log(` ${id} [${[...locs].sort().join(",")}]`);
diff --git a/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx b/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx
index 4747f9471..5aa5eec75 100644
--- a/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx
@@ -43,6 +43,7 @@ import {
type WebmailTargetOption,
} from "@/lib/api";
import { getApiErrorCode, getApiErrorMessage } from "@/lib/api/client";
+import { mailHostname } from "@repo/core";
/** The API's code for "this webmail predates the catalog app". */
const LEGACY_WEBMAIL_CODE = "LEGACY_WEBMAIL";
@@ -94,7 +95,7 @@ export default function DeployMailPage() {
const existing = st.webmail?.hostname;
if (existing) setDomain(existing);
else if (st.webmail?.routingUnknown) setDomain("");
- else if (st.domain) setDomain(`mail.${st.domain}`);
+ else if (st.domain) setDomain(mailHostname(st.domain));
if (st.webmail?.legacy) setReplacing(true);
}
setTargets(tg.options);
@@ -121,7 +122,7 @@ export default function DeployMailPage() {
return true;
}, [domain, selectedTarget]);
- const mailHostnameFromStatus = status?.domain ? `mail.${status.domain}` : "";
+ const mailHostnameFromStatus = status?.domain ? mailHostname(status.domain) : "";
// When cloud is chosen AND the chosen domain is the mail server's own
// `mail.` subdomain, the deploy uses the proxy variant: the
// workload runs on Opshcloud at *.opsh.io, the mail VPS proxies the
@@ -131,6 +132,14 @@ export default function DeployMailPage() {
selectedTarget?.kind === "opshcloud" &&
!!mailHostnameFromStatus &&
domain.toLowerCase() === mailHostnameFromStatus;
+ // The mail server's own hostname, deployed on the mail server itself: it already
+ // resolves here and already has a certificate, so there is nothing for the operator
+ // to set up. Worth saying, because this used to be refused (#566) and the hint that
+ // asks for DNS reads as work that isn't needed.
+ const isMailHostOnMailServer =
+ selectedTarget?.kind === "mail" &&
+ !!mailHostnameFromStatus &&
+ domain.toLowerCase() === mailHostnameFromStatus;
const startDeploy = async () => {
if (!canSubmit || !selectedTarget) return;
@@ -190,7 +199,7 @@ export default function DeployMailPage() {
// loads targets) - it drives both the proxy-variant detection and the
// submit-button disabled state, so it lives there rather than here.
const domainPlaceholder = status?.domain
- ? `mail.${status.domain}`
+ ? mailHostname(status.domain)
: "mail.example.com";
return (
@@ -270,9 +279,11 @@ export default function DeployMailPage() {
hint={
isCloudProxyVariant
? tm.domainHintProxy
- : selectedTarget?.kind === "opshcloud"
- ? tm.domainHintCloud
- : tm.domainHintDefault
+ : isMailHostOnMailServer
+ ? tm.domainHintMailHost
+ : selectedTarget?.kind === "opshcloud"
+ ? tm.domainHintCloud
+ : tm.domainHintDefault
}
>
(resumeDeploymentId);
const [projectId, setProjectId] = useState(adoptedProjectId);
const [progress, setProgress] = useState(0);
+ // Epoch ms this install started, for the progress panel's elapsed clock. Set
+ // when we enter `installing`, and on a mid-install refresh from the build's own
+ // `buildStartedAt` so the resumed view doesn't restart the clock at zero.
+ const [startedAt, setStartedAt] = useState(null);
const [phaseLabel, setPhaseLabel] = useState("");
const [liveUrl, setLiveUrl] = useState(null);
const [logs, setLogs] = useState("");
@@ -670,6 +675,9 @@ export default function AppInstallPage() {
const s = res?.data ?? res ?? {};
const status: string = s.deploymentStatus ?? s.status ?? "";
if (typeof s.progress === "number") setProgress(s.progress);
+ // Resuming: keep the clock on the real start when the build reports one.
+ const startedIso = Date.parse(String(s.buildStartedAt ?? ""));
+ setStartedAt((prev) => prev ?? (Number.isFinite(startedIso) ? startedIso : Date.now()));
if (
["ready", "failed", "cancelled", "partial_failure", "action_required", "rejected", "no_changes"].includes(
status,
@@ -908,6 +916,7 @@ export default function AppInstallPage() {
deployTarget: "server",
serverId: server.id,
serverHost: server.sshHost,
+ serverName: server.name ?? undefined,
})
}
/>
@@ -1004,6 +1013,7 @@ export default function AppInstallPage() {
}
}
setPhaseLabel(w.phaseQueued);
+ setStartedAt(Date.now());
setPhase("installing");
} catch (err) {
// Strip the server's "Pre-deploy checks failed:" prefix for a cleaner
@@ -1078,6 +1088,7 @@ export default function AppInstallPage() {
setErrorMsg("");
setDeploymentId(null);
setCancelled(false);
+ setStartedAt(null);
try {
const url = new URL(window.location.href);
url.searchParams.delete("deployment");
@@ -1087,6 +1098,96 @@ export default function AppInstallPage() {
}
setPhase("form");
};
+
+ // What this install was configured WITH — the aside's read-out, and the thing
+ // an operator can't get from the stepper or the logs. Every row is read from
+ // the pickers' own state, so it shows the configuration that was sent, never
+ // one re-derived from the template. A value this view can't know (the
+ // destination after a mid-install refresh — only the routing pickers
+ // rehydrate) is left out rather than guessed.
+ const summary: DeploySummaryRow[] = [];
+ const destinationValue =
+ destination?.deployTarget === "cloud"
+ ? t.deploy.targetStep.options.cloud
+ : (destination?.serverName || destination?.serverHost || "");
+ if (destinationValue) {
+ summary.push({
+ id: "destination",
+ label: w.summaryDestination,
+ value: destinationValue,
+ mono: destination?.deployTarget === "server" && !destination.serverName,
+ });
+ }
+ for (const e of appEndpoints) {
+ const st = expo[endpointKey(e)];
+ if (!st) continue;
+ const id = `ep-${endpointKey(e)}`;
+ const hostPort = hostPortForEndpoint(template.services, e);
+ if (st.kind === "http" && st.mode === "domain") {
+ // The hostname the install will actually write — same resolver the
+ // routing payload uses, seeded with the same default label.
+ const host = resolvePublicEndpointHostname(
+ {
+ domainType: st.ep.domainType,
+ domain: st.ep.domain.trim() ? normalizeServiceLabel(st.ep.domain) : defaultFreeLabel(e),
+ customDomain: normalizeCustomHostname(st.ep.customDomain),
+ },
+ baseDomain,
+ );
+ if (host) summary.push({ id, label: e.label, value: host, mono: true });
+ } else if (st.kind === "tcp" && st.mode === "internal") {
+ summary.push({ id, label: e.label, value: w.tcpInternalLabel });
+ } else {
+ // Port-only web, or a published database port: the reachable HOST port.
+ summary.push({
+ id,
+ label: e.label,
+ value: destination?.serverHost ? `${destination.serverHost}:${hostPort}` : `:${hostPort}`,
+ mono: true,
+ });
+ }
+ }
+ const serviceCount = template.services?.length ?? 0;
+ if (serviceCount > 0) {
+ summary.push({ id: "services", label: w.summaryServices, value: String(serviceCount) });
+ }
+ for (const req of requires) {
+ const sourceName = candidates.find((p) => p.id === connChoices[req.id])?.name;
+ if (sourceName) {
+ summary.push({
+ id: `req-${req.id}`,
+ label: resolveLocalized(req.label, locale),
+ value: sourceName,
+ });
+ }
+ }
+ // The business fields the operator filled in. Secrets are never shown, and a
+ // boolean is skipped rather than rendered as a bare "true"; capped so a
+ // settings-heavy app doesn't push the actions off a short viewport.
+ const fieldValueOf = (service: string, key: string) => values[fk(service, key)];
+ for (const f of installFields) {
+ if (summary.length >= 10) break;
+ if (f.secret || f.type === "boolean" || !isFieldVisible(f, fieldValueOf)) continue;
+ const raw = values[fk(f.service, f.key)];
+ if (typeof raw !== "string" || raw.trim() === "") continue;
+ summary.push({
+ id: `set-${f.service}-${f.key}`,
+ label: f.label,
+ value: f.options?.find((o) => o.value === raw)?.label ?? raw.trim(),
+ });
+ }
+ if (declaresResources) {
+ const needs = [
+ template.minResources?.memoryMb
+ ? interpolate(w.needsMemory, { value: formatMemoryMb(template.minResources.memoryMb) })
+ : null,
+ template.minResources?.cpuCores ? formatCpuCores(template.minResources.cpuCores) : null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ if (needs) summary.push({ id: "resources", label: w.needsTitle, value: needs });
+ }
+
return (
mailServers.find((s) => s.completed && s.domain) ?? null,
[mailServers],
);
- const ourMailHost = ourMailServer?.domain ? `mail.${ourMailServer.domain}` : null;
+ const ourMailHost = ourMailServer?.domain ? mailHostname(ourMailServer.domain) : null;
useEffect(() => {
void (async () => {
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx
index 31e094031..28b895099 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx
@@ -1,14 +1,14 @@
"use client";
/**
- * Reusable journalctl tail drawer. Slides in from the right, locks body
- * scroll, ESC + backdrop close. Shared by:
+ * Daemon log drawer for the Health tab. Slides in from the right, locks body
+ * scroll, ESC + backdrop close.
*
- * - Health tab - "Logs" link on a failed component.
- * - Advanced tab - every row in the Components panel.
- *
- * Keeping a single implementation means both surfaces stay in sync when
- * we add features (filter, follow, copy-all).
+ * The header prints the read the SERVER performed (`logs.source`) rather than a
+ * command this file composes. It used to hardcode `journalctl -u `, which on
+ * the container engine named a log that does not exist — the real read is
+ * `docker exec openship-mail tail -n N /var/log/supervisor/.log`. Re-deriving
+ * it here would only move the guess client-side; only the box knows its topology.
*/
import { useCallback, useEffect, useRef, useState } from "react";
@@ -19,7 +19,6 @@ import { useI18n, interpolate } from "@/components/i18n-provider";
interface LogsDrawerProps {
serverId: string;
componentKey: string;
- unit: string;
label: string;
onClose: () => void;
}
@@ -27,7 +26,6 @@ interface LogsDrawerProps {
export function LogsDrawer({
serverId,
componentKey,
- unit,
label,
onClose,
}: LogsDrawerProps) {
@@ -91,8 +89,8 @@ export function LogsDrawer({
{interpolate(t.emailsAdmin.shared.logsTitle, { label })}
-
- journalctl -u {unit} -n 300
+
+ {logs?.source}
{t.emailsAdmin.shared.loadingLogs}
) : logs && logs.lines.length === 0 ? (
- {t.emailsAdmin.shared.noJournal}
+ {t.emailsAdmin.shared.noLogLines}
) : (
logs?.lines.map((line, i) => (
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/admin-panel.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/admin-panel.tsx
index f40ebc779..8a484b308 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/admin-panel.tsx
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/admin-panel.tsx
@@ -26,6 +26,7 @@ import {
Globe,
UserRound,
Forward,
+ Inbox,
FileText,
HeartPulse,
Send,
@@ -42,6 +43,7 @@ import { OverviewTab } from "./overview-tab";
import { DomainsTab } from "./domains-tab";
import { MailboxesTab } from "./mailboxes-tab";
import { AliasesTab } from "./aliases-tab";
+import { InboundTab } from "./inbound-tab";
import { DnsTab } from "./dns-tab";
import { HealthTab } from "./health-tab";
import { TestTab } from "./test-tab";
@@ -67,6 +69,7 @@ type TabKey =
| "domains"
| "mailboxes"
| "aliases"
+ | "inbound"
| "dns"
| "health"
| "test"
@@ -91,6 +94,7 @@ const TABS: TabDef[] = [
{ key: "domains", icon: Globe },
{ key: "mailboxes", icon: UserRound },
{ key: "aliases", icon: Forward },
+ { key: "inbound", icon: Inbox },
{ key: "sending", icon: Waypoints },
{ key: "dns", icon: FileText },
{ key: "health", icon: HeartPulse },
@@ -203,6 +207,9 @@ export function MailAdminPanel({ status, serverId, onRefresh, onForgotten }: Mai
onSelectDomain={(d) => setQuery({ domain: d })}
/>
)}
+ {tab === "inbound" && (
+
+ )}
{tab === "dns" && (
(null);
const [logsOpen, setLogsOpen] = useState(false);
@@ -303,11 +305,31 @@ function DaemonRow({
if (acting) return;
setActing(action);
try {
- await mailAdminApi.components.action(serverId, component.key, action);
- const doneTpl =
- action === "start" ? h.toast.started : action === "stop" ? h.toast.stopped : h.toast.restarted;
- showToast(interpolate(doneTpl, { label: component.label }), "success");
+ const res = await mailAdminApi.components.action(serverId, component.key, action);
+ // Refresh BEFORE toasting so the pill and the toast can't contradict each other.
await onActed();
+ const wanted = action === "stop" ? "inactive" : "active";
+ if (!res.settled || res.settled.status === wanted) {
+ const doneTpl =
+ action === "start" ? h.toast.started : action === "stop" ? h.toast.stopped : h.toast.restarted;
+ showToast(interpolate(doneTpl, { label: component.label }), "success");
+ } else {
+ // The supervisor took the job and the daemon still isn't there. "ClamAV
+ // restarted" for a daemon already back in BACKOFF is the lie this removes.
+ // `res.output` is the supervisor's own words (e.g. "ERROR (not running)"),
+ // server-generated, so it is appended verbatim and untranslated. A settled
+ // state that is still transitional arrives as undefined, so a healthy slow
+ // restart keeps the optimistic wording above.
+ const sentence = interpolate(h.toast.notConfirmed, {
+ label: component.label,
+ state: daemonStatusLabel(res.settled.status, h, res.settled.subState),
+ });
+ showToast(
+ res.output ? `${sentence} ${res.output}` : sentence,
+ "info",
+ interpolate(h.toast.notConfirmedTitle, { label: component.label }),
+ );
+ }
} catch (err) {
const failMsg =
action === "start" ? h.toast.startFailed : action === "stop" ? h.toast.stopFailed : h.toast.restartFailed;
@@ -387,6 +409,14 @@ function DaemonRow({
{component.unit}
+ {/* Informational rows carry the same chip: from the operator's side both
+ mean "mail still works without this". The difference is only whether the
+ banner grades it (GH-240). */}
+ {component.severity !== "required" && (
+
+ {h.optional}
+
+ )}
{component.description}
@@ -403,6 +433,9 @@ function DaemonRow({
{component.detail}
)}
+ {subStateHint && (
+ {subStateHint}
+ )}
{statusLabel}
@@ -436,7 +469,6 @@ function DaemonRow({
setLogsOpen(false)}
/>
@@ -917,7 +949,11 @@ function dnsStatusPresentation(status: DnsCheckStatus): DnsStatusPresentation {
// ─── Status label maps (localized) ───────────────────────────────────────────
-function daemonStatusLabel(status: MailComponentStatus, h: HealthDict): string {
+function daemonStatusLabel(
+ status: MailComponentStatus,
+ h: HealthDict,
+ subState?: string,
+): string {
switch (status) {
case "active":
return h.daemonStatus.running;
@@ -927,8 +963,11 @@ function daemonStatusLabel(status: MailComponentStatus, h: HealthDict): string {
return h.daemonStatus.stopping;
case "inactive":
return h.daemonStatus.stopped;
+ // supervisord collapses FATAL (given up) and BACKOFF (still retrying) into one
+ // `failed`; only the sub-state separates them, and only one of them needs the
+ // operator to press Restart.
case "failed":
- return h.daemonStatus.failed;
+ return subState === "fatal" ? h.daemonStatus.crashed : h.daemonStatus.failed;
case "missing":
return h.daemonStatus.missing;
default:
@@ -936,6 +975,19 @@ function daemonStatusLabel(status: MailComponentStatus, h: HealthDict): string {
}
}
+/**
+ * The one thing `failed` doesn't say: whether anything is still trying.
+ *
+ * Matches the LOWER-CASED supervisord word (mail-engine.ts lower-cases the container
+ * arm); systemd has no FATAL/BACKOFF, so the host flavor never hits either branch.
+ */
+function daemonSubStateHint(component: MailComponentHealth, h: HealthDict): string | null {
+ if (component.status !== "failed") return null;
+ if (component.subState === "fatal") return h.daemonHint.fatal;
+ if (component.subState === "backoff") return h.daemonHint.backoff;
+ return null;
+}
+
function dnsStatusLabel(status: DnsCheckStatus, h: HealthDict): string {
switch (status) {
case "pass":
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/inbound-tab.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/inbound-tab.tsx
new file mode 100644
index 000000000..6af9a90cd
--- /dev/null
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/inbound-tab.tsx
@@ -0,0 +1,419 @@
+"use client";
+
+/**
+ * Inbound rules tab — "mail arrives at this address → tell me in this channel".
+ *
+ * Two behaviours of the machinery underneath are surfaced here on purpose, because both
+ * would otherwise be silent:
+ *
+ * - the channel picker lists only `enabled && verified` channels, and excludes `in_app`.
+ * The dispatcher drops anything unverified, and the in_app worker is a deliberate no-op
+ * with no dashboard surface reading the deliveries feed — so either would let an
+ * operator save a rule that can never deliver anything they can see.
+ * - a mailbox rule carries a caveat rather than hiding one. Postfix runs with
+ * `enable_original_recipient = no`, so a captured copy has no `X-Original-To` and can
+ * only be attributed via To/Cc: mail that arrived by Bcc or through an alias is
+ * invisible to a mailbox rule. Domain scope has no such gap.
+ *
+ * Same layout and primitives as the Mailboxes/Aliases tabs (DataTable + StatusPill +
+ * FormModalContent), so this reads as one panel rather than a bolt-on.
+ */
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Inbox, Pencil, Plus, Trash2, FlaskConical } from "lucide-react";
+import {
+ getApiErrorMessage,
+ mailAdminApi,
+ notificationsApi,
+ type InboundRule,
+ type InboundScope,
+ type NotificationChannel,
+} from "@/lib/api";
+import { useModal } from "@/context/ModalContext";
+import { useI18n, interpolate } from "@/components/i18n-provider";
+import { DataTable, RowIconButton, type DataTableColumn } from "./_shared/data-table";
+import { StatusPill } from "./_shared/status-pill";
+import { Field, FormModalContent, inputClassName } from "./_shared/form-modal-content";
+import { useMailRailOwnsTabs } from "../../_lib/mail-section";
+
+interface InboundTabProps {
+ serverId: string;
+ primaryDomain: string;
+}
+
+const SCOPES: InboundScope[] = ["mailbox", "domain", "all"];
+
+export function InboundTab({ serverId, primaryDomain }: InboundTabProps) {
+ const { showModal, hideModal } = useModal();
+ const { t } = useI18n();
+ const a = t.emailsAdmin.inbound;
+ // Heading lives in the page header in mail view — see ../../_lib/mail-section.
+ const hoisted = useMailRailOwnsTabs(serverId);
+
+ const [rules, setRules] = useState([]);
+ const [channels, setChannels] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const [testing, setTesting] = useState(false);
+
+ const reload = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [r, c] = await Promise.all([
+ mailAdminApi.inbound.list(serverId),
+ notificationsApi
+ .listChannels()
+ .then((x) => x.channels)
+ .catch(() => [] as NotificationChannel[]),
+ ]);
+ setRules(r.rules);
+ setChannels(c);
+ } catch (err) {
+ setError(getApiErrorMessage(err, a.loadFailed));
+ } finally {
+ setLoading(false);
+ }
+ }, [serverId, a.loadFailed]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ /** Only channels the dispatcher will actually ship to — see the module header. */
+ const usable = useMemo(
+ () => channels.filter((c) => c.enabled && c.verified && c.kind !== "in_app"),
+ [channels],
+ );
+
+ const openEditor = (rule?: InboundRule) => {
+ const id = showModal({
+ maxWidth: "600px",
+ showCloseButton: false,
+ customContent: (
+ hideModal(id)}
+ onSaved={() => {
+ hideModal(id);
+ void reload();
+ }}
+ />
+ ),
+ });
+ };
+
+ const openDelete = (rule: InboundRule) => {
+ const id = showModal({
+ maxWidth: "520px",
+ showCloseButton: false,
+ customContent: (
+ hideModal(id)}
+ onSubmit={async () => {
+ await mailAdminApi.inbound.remove(serverId, rule.id);
+ hideModal(id);
+ void reload();
+ }}
+ >
+ {a.deleteHint}
+
+ ),
+ });
+ };
+
+ const runTest = async () => {
+ setTesting(true);
+ setError(null);
+ setNotice(null);
+ try {
+ const r = await mailAdminApi.inbound.test(serverId);
+ setNotice(
+ interpolate(a.testResult, {
+ read: String(r.read),
+ matched: String(r.matched),
+ dropped: String(r.dropped),
+ }),
+ );
+ } catch (err) {
+ setError(getApiErrorMessage(err, a.testFailed));
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ const columns: DataTableColumn[] = [
+ {
+ key: "name",
+ header: a.colRule,
+ width: "minmax(220px, 1.4fr)",
+ cell: (r) => (
+
+ ),
+ },
+ {
+ key: "watch",
+ header: a.colWatch,
+ width: "minmax(200px, 1.2fr)",
+ cell: (r) =>
+ r.scope === "all" ? (
+ {a.scopeAllSummary}
+ ) : (
+ {r.target ?? "—"}
+ ),
+ },
+ {
+ key: "channels",
+ header: a.colChannels,
+ width: "140px",
+ hideBelow: "md",
+ cell: (r) => (
+
+ {interpolate(a.channelCount, { count: String(r.channelIds.length) })}
+
+ ),
+ },
+ {
+ key: "status",
+ header: a.colStatus,
+ width: "150px",
+ cell: (r) =>
+ r.pausedReason ? (
+
+ {a.paused}
+
+ ) : r.enabled ? (
+
+ {a.active}
+
+ ) : (
+
+ {a.disabled}
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {!hoisted && (
+
+
{a.heading}
+
{a.description}
+
+ )}
+
+
void runTest()}
+ disabled={testing || rules.length === 0}
+ className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-foreground text-sm font-medium rounded-xl hover:bg-muted/50 transition-colors disabled:opacity-50"
+ >
+
+ {testing ? a.testing : a.test}
+
+
openEditor()}
+ disabled={usable.length === 0}
+ className="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-sm font-medium rounded-xl hover:bg-primary/90 transition-all hover:shadow-lg hover:shadow-primary/25 disabled:opacity-50 disabled:hover:shadow-none"
+ >
+
+ {a.newRule}
+
+
+
+
+ {usable.length === 0 && !loading && (
+
{a.noChannels}
+ )}
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+
r.id}
+ loading={loading}
+ empty={{ icon: Inbox, title: a.emptyTitle, description: a.emptyBody }}
+ rowActions={(r) => (
+ <>
+ openEditor(r)} />
+ openDelete(r)}
+ />
+ >
+ )}
+ />
+
+ );
+}
+
+function RuleForm({
+ serverId,
+ rule,
+ channels,
+ primaryDomain,
+ onCancel,
+ onSaved,
+}: {
+ serverId: string;
+ rule?: InboundRule;
+ channels: NotificationChannel[];
+ primaryDomain: string;
+ onCancel: () => void;
+ onSaved: () => void;
+}) {
+ const { t } = useI18n();
+ const a = t.emailsAdmin.inbound;
+
+ const [name, setName] = useState(rule?.name ?? "");
+ const [scope, setScope] = useState(rule?.scope ?? "mailbox");
+ const [target, setTarget] = useState(rule?.target ?? "");
+ const [fromPattern, setFromPattern] = useState(rule?.fromPattern ?? "");
+ const [subjectPattern, setSubjectPattern] = useState(rule?.subjectPattern ?? "");
+ const [selected, setSelected] = useState(rule?.channelIds ?? []);
+ const [enabled, setEnabled] = useState(rule?.enabled ?? true);
+
+ const needsTarget = scope !== "all";
+ const invalid =
+ name.trim().length === 0 || selected.length === 0 || (needsTarget && target.trim().length === 0);
+
+ const scopeLabel = (s: InboundScope) =>
+ s === "mailbox" ? a.scopeMailbox : s === "domain" ? a.scopeDomain : a.scopeAll;
+
+ return (
+ {
+ const payload = {
+ name: name.trim(),
+ scope,
+ target: needsTarget ? target.trim() : null,
+ fromPattern: fromPattern.trim() || null,
+ subjectPattern: subjectPattern.trim() || null,
+ channelIds: selected,
+ enabled,
+ };
+ // FormModalContent surfaces a thrown error, and getApiErrorMessage is what
+ // unwraps the server's real message — an ApiError's own `.message` is the
+ // useless "API 409: Conflict", which is exactly the foreign-BCC refusal the
+ // operator has to read to know what to do.
+ try {
+ if (rule) await mailAdminApi.inbound.update(serverId, rule.id, payload);
+ else await mailAdminApi.inbound.create(serverId, payload);
+ } catch (err) {
+ throw new Error(getApiErrorMessage(err, a.saveFailed));
+ }
+ onSaved();
+ }}
+ >
+
+ setName(e.target.value)}
+ placeholder={a.namePlaceholder}
+ />
+
+
+
+ setScope(e.target.value as InboundScope)}
+ >
+ {SCOPES.map((s) => (
+
+ {scopeLabel(s)}
+
+ ))}
+
+
+
+ {needsTarget && (
+
+ setTarget(e.target.value)}
+ placeholder={scope === "mailbox" ? `support@${primaryDomain}` : primaryDomain}
+ />
+
+ )}
+
+
+ setFromPattern(e.target.value)}
+ placeholder={a.patternPlaceholder}
+ />
+
+
+
+ setSubjectPattern(e.target.value)}
+ placeholder={a.patternPlaceholder}
+ />
+
+
+
+
+ {channels.map((c) => (
+
+
+ setSelected((prev) =>
+ e.target.checked ? [...prev, c.id] : prev.filter((x) => x !== c.id),
+ )
+ }
+ />
+
+ {c.label} ({c.kind})
+
+
+ ))}
+
+
+
+
+ setEnabled(e.target.checked)}
+ />
+ {a.fieldEnabled}
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx
index 630b206e3..116241aaa 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx
@@ -60,6 +60,7 @@ import { getMarketingOrigin } from "@/lib/api/urls";
import { webmailCta } from "../../_lib/webmail-cta";
import { Skeleton } from "./_shared/skeleton";
import { useI18n, interpolate } from "@/components/i18n-provider";
+import { mailHostname } from "@repo/core";
interface OverviewTabProps {
status: MailSetupStatus;
@@ -69,7 +70,7 @@ interface OverviewTabProps {
export function OverviewTab({ status, serverId }: OverviewTabProps) {
const domain = status.domain ?? "";
- const mailHost = domain ? `mail.${domain}` : "";
+ const mailHost = domain ? mailHostname(domain) : "";
return (
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx
index 0111d116a..47e22d96a 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx
+++ b/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx
@@ -28,7 +28,7 @@ import {
type MailSSEEvent,
type PortConflict,
} from "@/lib/api";
-import { relayProvider } from "@repo/core";
+import { relayProvider, mailHostname } from "@repo/core";
import { mailProvider } from "@/lib/mail-providers";
import type { ServerOption } from "@/components/shared/ServerSelector";
import { PageContainer } from "@/components/ui/PageContainer";
@@ -281,7 +281,7 @@ function MailConsoleInner() {
setPtrPending({
ipv4,
ipv6,
- target: `mail.${s.domain}`,
+ target: mailHostname(s.domain),
resumeStep: s.resumeStep ?? 12,
});
}
@@ -759,7 +759,7 @@ function MailConsoleInner() {
setPtrPending({
ipv4,
ipv6,
- target: `mail.${domain}`,
+ target: mailHostname(domain),
resumeStep: next,
});
} else {
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts
index f0dbe6eda..d854077cf 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts
+++ b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts
@@ -28,6 +28,7 @@ function daemon(overrides: Partial
= {}): MailComponentHeal
label: "Postfix",
description: "SMTP server",
unit: "postfix",
+ severity: "required",
status: "active",
...overrides,
};
@@ -172,4 +173,136 @@ describe("summarizeHealth", () => {
expect(s?.banner).toContain("danger");
expect(s?.sub).toBe(h.summary.partDelivery);
});
+
+ /**
+ * The #565 defect: a dead ClamAV painted the same red "Issues need attention"
+ * banner as a dead Postfix, so the one signal that means "stop what you are doing"
+ * fired for a box that was still delivering mail.
+ */
+ it("is amber, not red, when only an advisory daemon is down", () => {
+ const s = summarizeHealth(
+ [
+ daemon({ key: "clamav", label: "ClamAV", severity: "advisory", status: "failed", subState: "fatal" }),
+ ...NINE_UP,
+ ],
+ [dns("pass")],
+ delivery(),
+ h,
+ );
+
+ expect(s?.banner).toContain("warning");
+ expect(s?.label).toBe(h.summary.degradedLabel);
+ expect(s?.sub).toContain("ClamAV");
+ /**
+ * GH-565: this used to assert the copy said mail would "queue", which was the
+ * opposite of what the engine does. `apps/email/engine/samples/amavisd/amavisd.conf`
+ * sets neither `$virus_scanners_failure_is_fatal` nor `$final_unchecked_destiny`, and
+ * `$undecipherable_subject_tag` is `undef` - so a message no scanner could examine is
+ * DELIVERED, unlabelled. The old assertion made a passing test guard a false promise,
+ * which is worse than no test: an operator reading "may queue" waits for mail to flow
+ * again instead of learning that unscanned mail is already reaching inboxes.
+ *
+ * If you want the queue behaviour, it is one line in amavisd.conf - see the opt-in
+ * documented there - and this assertion should flip back at the same time.
+ */
+ expect(s?.sub).toMatch(/WITHOUT virus scanning/i);
+ expect(s?.sub).not.toContain("queue");
+ });
+
+ it("says signatures, not scanning, when only freshclam is down", () => {
+ const s = summarizeHealth(
+ [
+ daemon({ key: "freshclam", label: "ClamAV updates", severity: "advisory", status: "inactive" }),
+ ...NINE_UP,
+ ],
+ [dns("pass")],
+ delivery(),
+ h,
+ );
+
+ expect(s?.banner).toContain("warning");
+ expect(s?.sub).toContain("signatures");
+ expect(s?.sub).not.toContain("queue");
+ });
+
+ it("still names an advisory daemon when something required is also down", () => {
+ const s = summarizeHealth(
+ [
+ daemon({ key: "dovecot", label: "Dovecot", status: "failed" }),
+ daemon({ key: "clamav", label: "ClamAV", severity: "advisory", status: "failed" }),
+ ...NINE_UP,
+ ],
+ [dns("pass")],
+ delivery(),
+ h,
+ );
+
+ expect(s?.banner).toContain("danger");
+ expect(s?.sub).toContain("Dovecot");
+ expect(s?.sub).toContain("ClamAV");
+ });
+
+ /**
+ * GH-240 FP1: `spamd` is reported for completeness, but amavis scores spam through its
+ * own in-process Mail::SpamAssassin integration and nothing on this stack speaks to the
+ * daemon - so its state says nothing about whether spam filtering works. It used to be
+ * graded `advisory`, which put a permanent amber banner on every host that simply does
+ * not run it. An amber that is always wrong teaches operators to ignore the banner.
+ */
+ it("stays green when only an informational daemon is down", () => {
+ const s = summarizeHealth(
+ [
+ daemon({ key: "spamassassin", label: "SpamAssassin", severity: "informational", status: "failed" }),
+ ...NINE_UP,
+ ],
+ [dns("pass")],
+ delivery(),
+ h,
+ );
+
+ expect(s?.label).toBe(h.summary.allGoodLabel);
+ expect(s?.sub ?? "").not.toContain("SpamAssassin");
+ });
+
+ it("stays green when an informational daemon is missing entirely", () => {
+ // The common shape on a legacy host: the unit was never installed.
+ const s = summarizeHealth(
+ [
+ daemon({ key: "spamassassin", label: "SpamAssassin", severity: "informational", status: "missing" }),
+ ...NINE_UP,
+ ],
+ [dns("pass")],
+ delivery(),
+ h,
+ );
+
+ expect(s?.label).toBe(h.summary.allGoodLabel);
+ });
+
+ it("is not green while an advisory daemon is down", () => {
+ const s = summarizeHealth(
+ [daemon({ key: "fail2ban", label: "fail2ban", severity: "advisory", status: "inactive" }), ...NINE_UP],
+ [dns("pass")],
+ delivery(),
+ h,
+ );
+
+ expect(s?.label).not.toBe(h.summary.allGoodLabel);
+ expect(s?.sub).toBe("fail2ban not running");
+ });
+
+ /**
+ * A row an older API didn't stamp with `severity` must fail SAFE to required —
+ * landing in neither bucket would leave the amber branch with a heading and no
+ * sentence, which this file's whole priority contract forbids.
+ */
+ it("treats a row with no severity marker as required", () => {
+ const unstamped = { ...daemon({ label: "Postfix", status: "failed" }) } as MailComponentHealth;
+ delete (unstamped as { severity?: unknown }).severity;
+
+ const s = summarizeHealth([unstamped, ...NINE_UP], [dns("pass")], delivery(), h);
+
+ expect(s?.banner).toContain("danger");
+ expect(s?.sub).toContain("Postfix");
+ });
});
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts
index ff5542e79..60db2a621 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts
+++ b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts
@@ -6,10 +6,11 @@
* single sentence an operator can act on. That means a priority order, and the
* order is the whole point:
*
- * red = something is broken right now (a daemon down, DNS failing, or mail
- * not leaving the box)
- * amber = working, but with something worth looking at (records that only warn,
- * a daemon this host doesn't ship, mail waiting in the queue)
+ * red = something is broken right now (a REQUIRED daemon down, DNS failing, or
+ * mail not leaving the box)
+ * amber = working, but with something worth looking at (an advisory daemon down,
+ * records that only warn, a daemon this host doesn't ship, mail waiting
+ * in the queue)
* green = all three readings clean
*
* A reading we couldn't take never colours the banner. `unknown` means "we didn't
@@ -44,16 +45,30 @@ export function summarizeHealth(
): BannerSummary | null {
if (!components && !checks && !delivery) return null;
- // Separate "missing" from "down" - a unit that isn't installed on this
- // host is a different operator problem than one that exists and is
- // failing. The banner names which is which so the user doesn't have
- // to scan the whole list to figure out what's broken.
- const downComponents =
- components?.filter(
- (c) => c.status === "failed" || c.status === "inactive",
- ) ?? [];
- const missingComponents =
- components?.filter((c) => c.status === "missing") ?? [];
+ // Down splits by SEVERITY before anything else. A dead ClamAV and a dead Postfix
+ // are both `failed`, but only one of them means this box has stopped being a mail
+ // server — and one bucket painted the banner red for either, so an advisory daemon
+ // read as an outage. The marker is the server's (the same one the install gate
+ // uses); grading it off a key list here would be a second definition.
+ //
+ // `!== "advisory"` rather than `=== "required"`: severity is required in TypeScript
+ // but arrives as unvalidated JSON, and a row an older API didn't stamp must fail
+ // SAFE to required — landing in neither bucket would give the banner a heading with
+ // no sentence under it.
+ //
+ // "Missing" stays its own bucket: a unit this host never shipped is a different
+ // operator problem from one that exists and is failing.
+ //
+ // `informational` is dropped BEFORE that split, and it has to be: the fail-safe above
+ // would otherwise sort it into `requiredDown` and paint the banner RED for a daemon
+ // nothing consults (GH-240 — spamd, while amavis scores spam in-process). Explicitly
+ // opting a row out is a different act from an older API forgetting to stamp one, which
+ // is why this is a separate marker and not a third value of the same test.
+ const graded = components?.filter((c) => c.severity !== "informational") ?? [];
+ const down = graded.filter((c) => c.status === "failed" || c.status === "inactive");
+ const requiredDown = down.filter((c) => c.severity !== "advisory");
+ const advisoryDown = down.filter((c) => c.severity === "advisory");
+ const missingComponents = graded.filter((c) => c.status === "missing");
const dnsFails = checks?.filter((c) => c.status === "fail").length ?? 0;
const dnsWarns = checks?.filter((c) => c.status === "warn").length ?? 0;
@@ -75,7 +90,7 @@ export function summarizeHealth(
: null;
const allClean =
- downComponents.length === 0 &&
+ down.length === 0 &&
missingComponents.length === 0 &&
dnsFails === 0 &&
dnsWarns === 0 &&
@@ -94,59 +109,60 @@ export function summarizeHealth(
};
}
- if (
- downComponents.length === 0 &&
- missingComponents.length === 0 &&
- dnsFails === 0 &&
- !deliveryFails
- ) {
+ // Amber: nothing REQUIRED is down and nothing hard-fails. Advisory daemons that are
+ // down, daemons this host never shipped, warn-only DNS records and a queue with
+ // something in it all live here — one branch, so a box with two amber causes gets one
+ // sentence instead of whichever branch happened to come first. (That merge is a
+ // deliberate behaviour change for one combination: missing daemons alongside DNS
+ // warnings used to drop the DNS sentence.)
+ if (requiredDown.length === 0 && dnsFails === 0 && !deliveryFails) {
const almost: string[] = [];
+ almost.push(...advisoryNotes(advisoryDown, h));
+ // The heading names the most consequential amber cause: a daemon that is down and
+ // meant to be running outranks one this host never shipped.
+ const namesMissing = missingComponents.map((c) => c.label).join(", ");
+ const labelNamesMissing = advisoryDown.length === 0 && missingComponents.length > 0;
+ if (missingComponents.length > 0) {
+ // When the heading already names them, the sub explains; when it doesn't, the sub
+ // has to name them, or "Mail still works without it" refers to nothing.
+ almost.push(
+ labelNamesMissing
+ ? missingComponents.length === 1
+ ? h.summary.notInstalledSubOne
+ : h.summary.notInstalledSubOther
+ : interpolate(h.summary.partNotInstalled, { names: namesMissing }),
+ );
+ }
if (dnsWarns > 0) {
almost.push(
interpolate(dnsWarns === 1 ? h.summary.almostSubOne : h.summary.almostSubOther, { count: String(dnsWarns) }),
);
}
if (queueNote) almost.push(queueNote);
+ const label =
+ advisoryDown.length > 0
+ ? h.summary.degradedLabel
+ : labelNamesMissing
+ ? interpolate(h.summary.notInstalledLabel, { names: namesMissing })
+ : h.summary.almostLabel;
return {
Icon: AlertTriangle,
banner: "bg-warning-bg border-warning-border",
iconBg: "bg-warning-bg",
iconColor: "text-warning",
textColor: "text-warning",
- label: h.summary.almostLabel,
+ label,
sub: almost.join(" · "),
};
}
- // If only "missing" daemons (nothing actually down, no DNS fails), it's
- // a soft warning - the box doesn't ship that daemon. Don't paint the
- // whole banner red for that.
- if (
- downComponents.length === 0 &&
- missingComponents.length > 0 &&
- dnsFails === 0 &&
- !deliveryFails
- ) {
- const names = missingComponents.map((c) => c.label).join(", ");
- const sub =
- missingComponents.length === 1
- ? h.summary.notInstalledSubOne
- : h.summary.notInstalledSubOther;
- return {
- Icon: AlertTriangle,
- banner: "bg-warning-bg border-warning-border",
- iconBg: "bg-warning-bg",
- iconColor: "text-warning",
- textColor: "text-warning",
- label: interpolate(h.summary.notInstalledLabel, { names }),
- sub: queueNote ? `${sub} · ${queueNote}` : sub,
- };
- }
-
const parts: string[] = [];
- if (downComponents.length > 0) {
- parts.push(interpolate(h.summary.partDown, { names: downComponents.map((c) => c.label).join(", ") }));
+ if (requiredDown.length > 0) {
+ parts.push(interpolate(h.summary.partDown, { names: requiredDown.map((c) => c.label).join(", ") }));
}
+ // An advisory daemon down beside a real outage still gets named — it just doesn't
+ // get to be the reason the banner is red.
+ parts.push(...advisoryNotes(advisoryDown, h));
if (missingComponents.length > 0) {
parts.push(
interpolate(h.summary.partNotInstalled, { names: missingComponents.map((c) => c.label).join(", ") }),
@@ -169,3 +185,37 @@ export function summarizeHealth(
sub: parts.join(" · "),
};
}
+
+/**
+ * Advisory is not "harmless", and the kinds of advisory-down have different
+ * consequences — so the sentence is chosen by WHICH daemon it is.
+ *
+ * - amavis / clamav: the engine's runtime amavis config (apps/email/engine/samples/
+ * amavisd/amavisd.conf, landed as /etc/amavis/conf.d/50-user) declares ONE scanner
+ * (clamav-socket) and `@av_scanners_backup = ()`, so with clamd gone there is nothing
+ * to scan with. The message is then DELIVERED UNSCANNED (GH-565): that config sets
+ * neither `$virus_scanners_failure_is_fatal` nor `$final_unchecked_destiny`, and
+ * `$undecipherable_subject_tag` is `undef`, so nothing defers it and nothing labels
+ * it. This copy used to promise the opposite ("may queue") — which left an operator
+ * waiting for mail to resume while unscanned mail was already landing in inboxes.
+ * Amavis still stamps `X-Virus-Scanned`; that header means amavis SAW the message,
+ * not that a scanner passed it. Failing closed is an opt-in documented in amavisd.conf.
+ * - freshclam: clamd keeps answering, so mail keeps flowing — against a signature set
+ * that has stopped moving forward. Scanned, but against ageing signatures.
+ * - spamd / iRedAPD / fail2ban: delivery is unaffected.
+ */
+const SCANNING_KEYS = new Set(["clamav", "amavis"]);
+const SIGNATURE_KEYS = new Set(["freshclam"]);
+
+function advisoryNotes(rows: MailComponentHealth[], h: HealthDict): string[] {
+ if (rows.length === 0) return [];
+ const names = (subset: MailComponentHealth[]) => subset.map((c) => c.label).join(", ");
+ const scanning = rows.filter((c) => SCANNING_KEYS.has(c.key));
+ const signatures = rows.filter((c) => SIGNATURE_KEYS.has(c.key));
+ const other = rows.filter((c) => !SCANNING_KEYS.has(c.key) && !SIGNATURE_KEYS.has(c.key));
+ const out: string[] = [];
+ if (scanning.length > 0) out.push(interpolate(h.summary.partScanning, { names: names(scanning) }));
+ if (signatures.length > 0) out.push(interpolate(h.summary.partSignatures, { names: names(signatures) }));
+ if (other.length > 0) out.push(interpolate(h.summary.partAdvisory, { names: names(other) }));
+ return out;
+}
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts b/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts
index 5d4858c66..ab0ea3a9f 100644
--- a/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts
+++ b/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts
@@ -62,6 +62,8 @@ export function getMailSectionHeading(tab: string, t: Dictionary): MailSectionHe
return { title: a.mailboxes.heading, description: a.mailboxes.description };
case "aliases":
return { title: a.aliases.heading, description: a.aliases.description };
+ case "inbound":
+ return { title: a.inbound.heading, description: a.inbound.description };
case "dns":
return { title: a.dns.heading, description: a.dns.description };
case "health":
diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx
index aff5b044e..2c7b573c1 100644
--- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx
+++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx
@@ -196,9 +196,8 @@ export function DraftProjectView({ onDeleteProject }: DraftProjectViewProps) {
{heading}
-
{projectStatusLabel(status, t)}
diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx
index bb8107d38..55a16b18f 100644
--- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx
+++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx
@@ -168,9 +168,8 @@ export const ProjectSidebar = () => {
-
{projectStatusLabel(status, t)}
diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx
index f42552b95..1dda3d171 100644
--- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx
+++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx
@@ -777,10 +777,11 @@ export function ServiceDetailPanel({
isEditingMode={true}
setIsEditingMode={() => { /* always editing in the Env tab */ }}
showSettingsActions={false}
- // #336: env values arrive masked; reveal the real ones on demand
- // (the endpoint is write-gated, so read-only members can't).
- onRevealAll={async () =>
- (await servicesApi.revealEnv(projectId, service.id)).environment
+ // #336: env values arrive masked; reveal only the keys the operator
+ // actually opens (the endpoint is write-gated, so read-only members
+ // can't reveal at all).
+ onReveal={async (keys) =>
+ (await servicesApi.revealEnv(projectId, service.id, keys)).environment
}
borderless
/>
diff --git a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx
index 47da27a30..ef0ba6697 100644
--- a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx
+++ b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx
@@ -126,6 +126,21 @@ export function ConnectionBanner(props: {
const copy = (() => {
switch (kind) {
case "host_channel":
+ // Refused, not unanswered (#527). Checked before the address cases because the
+ // fault is not about the address at all: something DID answer there, so the
+ // generic "nothing answered" copy would be false, and the operator's next move
+ // is a key, not a firewall. Red rather than amber: unlike a channel that was
+ // never provisioned, this one was set up and has stopped working.
+ if (diagnosis?.channel === "auth_rejected") {
+ return {
+ title: t.servers.banner.hostChannelAuthTitle,
+ body: interpolate(t.servers.banner.hostChannelAuthBody, {
+ target: dialed ?? "the host",
+ }),
+ icon: KeyRound,
+ tone: "red",
+ };
+ }
// Nothing dialed → the channel was never provisioned, so no address can be
// named as unresponsive and nothing here is down (#509).
return dialed === null
@@ -183,11 +198,20 @@ export function ConnectionBanner(props: {
*/
const fix = kind !== "host_channel"
? null
- : dialed === null
- ? { label: t.servers.banner.hostChannelProvisionFix, command: HOST_CHANNEL_PROVISION_COMMAND }
- : diagnosis?.rule
- ? { label: t.servers.banner.hostChannelFix, command: diagnosis.rule }
- : null;
+ : diagnosis?.channel === "auth_rejected"
+ // Re-running the installer re-authorizes the key, so the remedy is the same command
+ // as an unprovisioned channel with a different reason for running it. A firewall
+ // rule is never offered here: a packet arrived and was answered, so no packet filter
+ // has been established — the distinction #490 was fixed to preserve.
+ ? {
+ label: t.servers.banner.hostChannelReauthorizeFix,
+ command: HOST_CHANNEL_PROVISION_COMMAND,
+ }
+ : dialed === null
+ ? { label: t.servers.banner.hostChannelProvisionFix, command: HOST_CHANNEL_PROVISION_COMMAND }
+ : diagnosis?.rule
+ ? { label: t.servers.banner.hostChannelFix, command: diagnosis.rule }
+ : null;
const tone = copy.tone === "red"
? "bg-danger-bg border-danger-border text-danger"
diff --git a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx
index f05537e04..e4d8a727f 100644
--- a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx
+++ b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Boxes, RefreshCw } from "lucide-react";
import { systemApi, type ServerContainerStatus } from "@/lib/api/system";
import { ContainerStatusRow, EdgeInstallRow } from "@/components/infra/ContainerStatusRow";
@@ -21,6 +21,10 @@ import { useReattachActiveFix } from "@/hooks/useReattachActiveFix";
* to the drift cache the operator can trigger by hand; the roll-up in Settings →
* Infrastructure does the same across the whole fleet.
*/
+/** Settle cadence + a stall bound (~4 min) while a swap on this box is in flight. */
+const SETTLE_MS = 3000;
+const SETTLE_BUDGET = 80;
+
export function ServerContainerUpdates({ serverId }: { serverId: string }) {
const { t } = useI18n();
const [rows, setRows] = useState([]);
@@ -42,6 +46,26 @@ export function ServerContainerUpdates({ serverId }: { serverId: string }) {
void load();
}, [load]);
+ // Settle-watch: a row renders "Updating…" from the cached in-progress flag, and
+ // nothing else on this page re-reads it — so dismissing the streamed modal used to
+ // leave the row disabled at "Updating…" until a manual Scan, long after the swap
+ // landed. Chains off `rows` (every load sets them) and stops as soon as nothing is
+ // in flight, so there is no interval to clear. Bounded, because a flag can outlive
+ // its run if the API dies mid-swap; the next boot clears those.
+ const inFlight = rows.some((r) => r.latestInProgress);
+ const polls = useRef(0);
+ useEffect(() => {
+ if (!inFlight) polls.current = 0;
+ }, [inFlight]);
+ useEffect(() => {
+ if (!inFlight || polls.current >= SETTLE_BUDGET) return;
+ const timer = setTimeout(() => {
+ polls.current += 1;
+ void load();
+ }, SETTLE_MS);
+ return () => clearTimeout(timer);
+ }, [inFlight, rows, load]);
+
// Refresh recovery for an in-flight image swap (edge/mail update or mail
// repair). Container-apply ONLY — NOT install: the parent server page already
// re-attaches the install/repair session into the setup wizard, so passing
@@ -83,7 +107,9 @@ export function ServerContainerUpdates({ serverId }: { serverId: string }) {
// never-scanned box with an edge already present (nothing else) stays hidden.
if (loading || (rows.length === 0 && hasEdge)) return null;
- const behindCount = rows.filter((r) => r.behind).length;
+ // Work still to do — a component mid-swap is reported by its own row, so counting
+ // it here too would keep announcing an update that is already being applied.
+ const behindCount = rows.filter((r) => r.behind && !r.latestInProgress).length;
const c = t.servers.containers;
return (
diff --git a/apps/dashboard/src/app/(dashboard)/servers/page.tsx b/apps/dashboard/src/app/(dashboard)/servers/page.tsx
index d9f7595eb..989d9d9fb 100644
--- a/apps/dashboard/src/app/(dashboard)/servers/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/servers/page.tsx
@@ -24,7 +24,7 @@ import {
HardDrive,
} from "lucide-react";
import { systemApi } from "@/lib/api";
-import type { ContainerApplyIntent } from "@/lib/api/system";
+import type { ContainerApplyActive, ContainerApplyIntent } from "@/lib/api/system";
import { PageContainer } from "@/components/ui/PageContainer";
import DropdownMenu from "@/components/ui/DropdownMenu";
import { Tabs, type TabDef } from "@/components/ui/Tabs";
@@ -32,6 +32,7 @@ import { usePlatform } from "@/context/PlatformContext";
import { useI18n, interpolate } from "@/components/i18n-provider";
import { useToast } from "@/components/toast";
import { useInfraFleet, type InfraSegment } from "@/hooks/useInfraFleet";
+import { useContainerApplyModal } from "@/hooks/useSystemPrepareModal";
import { InfraFleetCard } from "@/components/infra/InfraFleetCard";
import { InfraFilters } from "@/components/infra/InfraFilters";
import { ComingSoonPanel } from "./_components/coming-soon-panel";
@@ -205,25 +206,57 @@ export default function ServersPage() {
// ── Managed containers (edge / mail) across the fleet ──────────────────────
const infra = useInfraFleet(infraEnabled);
const ic = t.servers.list.infra;
+ const openContainerApply = useContainerApplyModal();
const [segment, setSegment] = useState("all");
const [search, setSearch] = useState("");
+ /**
+ * Watch one in-flight component's log. GET re-attach by session id — never a POST,
+ * so opening the log can't start a second swap for a run already going. This is the
+ * only way into a bulk run's output from the page that launched it; the per-server
+ * page has the same modal on its own rows.
+ */
+ const openApplyLog = useCallback(
+ (target: ContainerApplyActive) => {
+ if (!target.sessionId) return;
+ openContainerApply(target.serverId, target.component, {
+ label:
+ target.component === "mail"
+ ? t.servers.containers.componentMail
+ : t.servers.containers.componentEdge,
+ intent: target.intent ?? "update",
+ attachSessionId: target.sessionId,
+ onDone: () => void infra.reload(),
+ });
+ },
+ // `infra.reload` is stable; the whole `infra` object is not.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [openContainerApply, t.servers.containers, infra.reload],
+ );
+
+ /**
+ * Start a bulk apply. Deliberately quiet on success: the card now shows the run
+ * itself — what's queued, what's pulling, and how it ended — so a toast claiming
+ * "Updating N components" the instant the request returns added a second, greener,
+ * less accurate account of the same thing. A toast is left only for what the card
+ * cannot show: nothing to do, targets it must hand back, and a failed start.
+ */
const runBulk = useCallback(
async (intent: ContainerApplyIntent) => {
try {
const res = await infra.applyAll(intent);
if (!res) return; // infra disabled (cloud) — the buttons aren't rendered there
- const n = res.started.length;
- const skipped = res.skipped.length;
- if (n === 0 && skipped === 0) {
+ if (res.started.length === 0 && res.skipped.length === 0) {
toast("info", ic.nothingToDo);
return;
}
- const head = interpolate(intent === "update" ? ic.started : ic.startedRestart, {
- n: String(n),
- });
- const tail = skipped > 0 ? interpolate(ic.skipped, { n: String(skipped) }) : "";
- toast(n > 0 ? "success" : "info", tail ? `${head} · ${tail}` : head);
+ const skipped = res.skipped.length;
+ if (skipped > 0) {
+ toast(
+ "info",
+ interpolate(skipped === 1 ? ic.skippedOne : ic.skippedMany, { n: String(skipped) }),
+ );
+ }
} catch {
toast("error", ic.applyFailed);
}
@@ -232,17 +265,13 @@ export default function ServersPage() {
);
/**
- * Which bucket a server falls in — attention wins over updates. `null` until the
- * fleet view loads: an unread server matches no segment rather than being called
- * healthy, so the segment counts and the filtered list can never disagree.
+ * Which bucket a server falls in. Read straight off the summary — the rule lives
+ * in `useInfraFleet` so the roll-up counts, these segments and the row chip cannot
+ * drift apart. `null` until the fleet view loads: an unread server matches no
+ * segment rather than being called healthy.
*/
const bucketOf = useCallback(
- (id: string): InfraBucket | null => {
- const s = infra.summaries.get(id);
- if (!s) return null;
- if (s.down.length + s.missing.length > 0 || s.edgeAbsent) return "attention";
- return s.updates > 0 ? "updates" : "healthy";
- },
+ (id: string): InfraBucket | null => infra.summaries.get(id)?.bucket ?? null,
[infra.summaries],
);
@@ -446,6 +475,13 @@ export default function ServersPage() {
{downParts.join(" · ")}
+ ) : comp && comp.applying > 0 ? (
+ // Mid-apply outranks the drift it is fixing: the row would
+ // otherwise keep offering "1 update" for a swap already running.
+
+
+ {ic.chipUpdating}
+
) : comp && comp.updates > 0 ? (
{interpolate(comp.updates === 1 ? ic.chipUpdateOne : ic.chipUpdates, {
@@ -549,8 +585,11 @@ export default function ServersPage() {
counts={infra.counts}
scanning={infra.scanning}
applying={infra.applying}
+ active={infra.active}
+ outcome={infra.outcome}
onScan={() => void infra.scan()}
onApply={(intent) => void runBulk(intent)}
+ onViewLogs={openApplyLog}
/>
)}
diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx
index 2d2188ced..321efba92 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx
@@ -77,6 +77,7 @@ const CATEGORY_ICONS: Record | null | undefined;
if (payload && typeof payload === "object") {
- for (const field of ["email", "hostname", "name", "slug"]) {
+ // `tool` is what a tool-call row is about — "ran the MCP tool get_projects"
+ // beats "ran the MCP tool a connected AI agent".
+ for (const field of ["email", "hostname", "name", "slug", "tool"]) {
const value = payload[field];
if (typeof value === "string" && value.trim()) return value;
}
@@ -301,6 +304,15 @@ function AuditDetailsBody({
label={t.settings.audit.details.cameFrom}
value={sourceLabel(event.source)}
/>
+ {/* The agent, when there was one. Named separately from "came from"
+ because "an AI assistant" and "Cursor" answer different questions —
+ the second is the one that decides which connection to revoke. */}
+ {event.sourceClientId && (
+
+ )}
{(event.resourceType || event.resourceId) && (
@@ -462,7 +474,17 @@ export function AuditTab() {
return hit ? hit.id : "all";
}, [categoryParam]);
- const [source, setSource] = useState("all");
+ // Source and client live in the URL alongside category, not in component state:
+ // "what has this agent been doing" is a link other screens hand out (the MCP
+ // settings row does) and a question people paste to each other, neither of which
+ // works if the answer is only reachable by clicking two pills in order.
+ const sourceParam = searchParams.get("source");
+ const source: SourceKey = useMemo(
+ () => (sourceParam && SOURCE_ICONS[sourceParam as AuditSource] ? (sourceParam as AuditSource) : "all"),
+ [sourceParam],
+ );
+ const client = searchParams.get("client") ?? "";
+
const [actorUserId, setActorUserId] = useState("");
const [period, setPeriod] = useState("all");
const [search, setSearch] = useState("");
@@ -486,11 +508,12 @@ export function AuditTab() {
() => ({
category: category === "all" ? undefined : category,
source: source === "all" ? undefined : source,
+ sourceClientId: client || undefined,
actorUserId: actorUserId || undefined,
from: periodStart(period),
q: debouncedSearch || undefined,
}),
- [category, source, actorUserId, period, debouncedSearch],
+ [category, source, client, actorUserId, period, debouncedSearch],
);
useEffect(() => {
@@ -579,22 +602,50 @@ export function AuditTab() {
[query, showToast, t],
);
+ /**
+ * The audit URL with some filters changed and the rest kept. Merging rather
+ * than rebuilding is what stops a category tab from silently dropping the
+ * source/client filter the reader already chose.
+ */
+ const hrefWith = useCallback(
+ (patch: Record) => {
+ const next = new URLSearchParams(searchParams.toString());
+ next.set("tab", "audit");
+ for (const [key, value] of Object.entries(patch)) {
+ if (value) next.set(key, value);
+ else next.delete(key);
+ }
+ return `/settings?${next.toString()}`;
+ },
+ [searchParams],
+ );
+
// Every filter change invalidates the offset — page 3 of the old result set
// has nothing to do with the new one, so each setter resets it rather than an
// effect on `query` (which would fetch the stale page first, then page 1).
const setCategory = useCallback(
(next: CategoryKey) => {
- const url = next === "all" ? "/settings?tab=audit" : `/settings?tab=audit&category=${next}`;
- router.replace(url, { scroll: false });
+ router.replace(hrefWith({ category: next === "all" ? undefined : next }), { scroll: false });
setPage(1);
},
- [router],
+ [router, hrefWith],
);
- const pickSource = useCallback((next: SourceKey) => {
- setSource(next);
- setPage(1);
- }, []);
+ const pickSource = useCallback(
+ (next: SourceKey) => {
+ router.replace(hrefWith({ source: next === "all" ? undefined : next }), { scroll: false });
+ setPage(1);
+ },
+ [router, hrefWith],
+ );
+
+ const pickClient = useCallback(
+ (next: string) => {
+ router.replace(hrefWith({ client: next || undefined }), { scroll: false });
+ setPage(1);
+ },
+ [router, hrefWith],
+ );
const pickActor = useCallback((next: string) => {
setActorUserId(next);
@@ -618,17 +669,17 @@ export function AuditTab() {
key: "all" as CategoryKey,
label: t.settings.audit.tabs.all,
count: facets?.total,
- href: "/settings?tab=audit",
+ href: hrefWith({ category: undefined }),
},
...AUDIT_CATEGORIES.map((cat) => ({
key: cat.id as CategoryKey,
label: cat.label,
icon: CATEGORY_ICONS[cat.id],
count: counts.get(cat.id),
- href: `/settings?tab=audit&category=${cat.id}`,
+ href: hrefWith({ category: cat.id }),
})),
];
- }, [facets, t]);
+ }, [facets, t, hrefWith]);
const sourceOptions: PillOption[] = useMemo(() => {
const seen = new Set(
@@ -649,6 +700,27 @@ export function AuditTab() {
];
}, [facets, source, t]);
+ /**
+ * The connected agents that appear in the feed. A dropdown rather than pills:
+ * the list is unbounded (any registered client), unlike the six fixed sources.
+ * Only rendered when there is more than one agent to choose between.
+ */
+ const clientOptions = useMemo(() => {
+ const rows = facets?.clients ?? [];
+ // Keep the current selection listed even at zero, same as the source pills —
+ // otherwise a link into a quiet window leaves the filter unclearable.
+ const listed = rows.some((row) => row.id === client);
+ return [
+ { value: "", label: t.settings.audit.filters.anyAgent },
+ ...rows.map((row) => ({
+ value: row.id,
+ label: row.name || row.id,
+ description: interpolate(t.settings.audit.filters.agentCalls, { count: String(row.count) }),
+ })),
+ ...(client && !listed ? [{ value: client, label: client }] : []),
+ ];
+ }, [facets, client, t]);
+
const actorOptions = useMemo(
() => [
{ value: "", label: t.settings.audit.filters.anyone },
@@ -727,15 +799,24 @@ export function AuditTab() {
}, [events, t]);
const filtersActive =
- category !== "all" || source !== "all" || !!actorUserId || period !== "all" || !!debouncedSearch;
+ category !== "all" ||
+ source !== "all" ||
+ !!client ||
+ !!actorUserId ||
+ period !== "all" ||
+ !!debouncedSearch;
const clearFilters = useCallback(() => {
- setSource("all");
setActorUserId("");
setPeriod("all");
setSearch("");
- setCategory("all");
- }, [setCategory]);
+ // One navigation for all three URL-held filters — clearing them separately
+ // would race, each push overwriting the previous one's params.
+ router.replace(hrefWith({ category: undefined, source: undefined, client: undefined }), {
+ scroll: false,
+ });
+ setPage(1);
+ }, [router, hrefWith]);
return (
@@ -789,6 +870,18 @@ export function AuditTab() {
placeholder={t.settings.audit.filters.period}
className="w-40"
/>
+ {/* Hidden when there is nothing to choose between — but ALWAYS shown
+ while a client filter is active, or arriving from the MCP tab's
+ Activity link (one connected agent) would leave no way to clear it. */}
+ {(clientOptions.length > 2 || !!client) && (
+
+ )}
{sourceOptions.length > 1 && (
@@ -851,9 +944,15 @@ export function AuditTab() {
{clockTime(e.createdAt)}
{e.source === "mcp" && (
-
-
- {t.settings.audit.sources.mcp}
+ // The client's own name when we have it: at a glance
+ // "Claude Desktop" tells the reader which of their
+ // connected agents this was, which the generic
+ // "AI assistant" chip never could.
+
+
+
+ {e.sourceClientName || t.settings.audit.sources.mcp}
+
)}
{e.source && e.source !== "mcp" && e.source !== "dashboard" && (
diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx
index ad79dfb4a..a4350a1a4 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx
@@ -8,7 +8,7 @@
import { useEffect, useState, type ComponentType } from "react";
import Link from "next/link";
-import { Boxes, Copy, Check, ShieldCheck, Unplug, Loader2, ChevronDown, ExternalLink, KeyRound, SlidersHorizontal } from "lucide-react";
+import { Boxes, Copy, Check, ScrollText, ShieldCheck, Unplug, Loader2, ChevronDown, ExternalLink, KeyRound, SlidersHorizontal } from "lucide-react";
import { SettingsSection } from "./SettingsSection";
import { McpAccessEditor } from "./McpAccessEditor";
import { getRestApiBaseUrl } from "@/lib/api/urls";
@@ -626,6 +626,15 @@ function ClientsList({
{c.organizationName ? interpolate(t.settings.mcp.orgPrefix, { org: c.organizationName }) : ""}
{interpolate(t.settings.mcp.authorized, { date: formatDate(c.authorizedAt) })}
{c.lastUsedAt ? interpolate(t.settings.mcp.lastUsedSuffix, { date: formatDate(c.lastUsedAt) }) : ""}
+ {/* A timestamp says the agent is alive; the count says how much
+ it has actually done, which is the difference between a client
+ someone tried once and one running unattended all week. */}
+ {c.useCount > 0
+ ? interpolate(
+ c.useCount === 1 ? t.settings.mcp.callsOne : t.settings.mcp.callsMany,
+ { count: c.useCount.toLocaleString() },
+ )
+ : ""}
{confirming ? (
@@ -650,6 +659,16 @@ function ClientsList({
// Edit leads; Disconnect keeps its danger hover. Both are hidden while
// the row is confirming a disconnect — that interaction owns the row.
+ {/* Straight to this agent's own history. The audit log could
+ already filter to "an AI assistant", but arriving from the
+ row that names one and having to re-pick it was the gap. */}
+
+
+ {t.settings.mcp.viewActivity}
+
onEdit(id)}
disabled={!id || loadingScope === id}
diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/PersonalAccessTokens.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/PersonalAccessTokens.tsx
index fd90fdae5..a1178b7ad 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/_components/PersonalAccessTokens.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/_components/PersonalAccessTokens.tsx
@@ -325,6 +325,14 @@ export function PersonalAccessTokens() {
{tok.tokenPrefix}…
{interpolate(t.settings.tokens.metaLine, { lastUsed: fmtDate(tok.lastUsedAt), expires: fmtDate(tok.expiresAt) })}
+ {tok.useCount > 0 && (
+
+ {interpolate(
+ tok.useCount === 1 ? t.settings.tokens.callsOne : t.settings.tokens.callsMany,
+ { count: tok.useCount.toLocaleString() },
+ )}
+
+ )}
{
- if (s) onChange({ deployTarget: "server", serverId: s.id, serverHost: s.host });
+ if (s)
+ onChange({
+ deployTarget: "server",
+ serverId: s.id,
+ serverHost: s.host,
+ serverName: s.name,
+ });
}}
/>
diff --git a/apps/dashboard/src/components/deploy/CleanDeployProgress.test.tsx b/apps/dashboard/src/components/deploy/CleanDeployProgress.test.tsx
index 2fe0a0537..207723fa1 100644
--- a/apps/dashboard/src/components/deploy/CleanDeployProgress.test.tsx
+++ b/apps/dashboard/src/components/deploy/CleanDeployProgress.test.tsx
@@ -5,7 +5,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/components/i18n-provider";
import { PlatformProvider } from "@/context/PlatformContext";
import { ToastProvider } from "@/context/ToastContext";
-import { CleanDeployProgressCard } from "./CleanDeployProgress";
+import {
+ CleanDeployProgressCard,
+ installProgressPercent,
+ installStepIndex,
+} from "./CleanDeployProgress";
+import type { StepStatus } from "@/components/deploy/InstallStepper";
type Props = React.ComponentProps;
@@ -53,11 +58,10 @@ describe("CleanDeployProgressCard — installing", () => {
expect(out).toContain("Starting services");
expect(out).toContain("backend"); // per-service sub-list
expect(out).toContain("Hide logs"); // logs open by default → toggle offers "Hide"
- // Legacy numeric progress bar must be gone in the stepper layout. Matched on
- // `h-1.5 w-full` (the bar's track), not bare `h-1.5` — that alone is a spacing
- // value the status pill's dot also uses, which made this assert on a
- // coincidence and let the legacy-path assertion below pass vacuously.
- expect(html).not.toContain("h-1.5 w-full");
+ // The legacy CENTERED layout must be gone. Matched on its container, not on a
+ // utility class: the stepper layout now has a progress bar of its own in the
+ // aside, so "no bar" no longer identifies the layout at all.
+ expect(html).not.toContain("mx-auto max-w-2xl");
// images done → check icon; services active → spinner
expect(html).toContain("lucide-circle-check");
expect(html).toContain("animate-spin");
@@ -81,11 +85,112 @@ describe("CleanDeployProgressCard — installing", () => {
it("without phases (legacy mail wizard): renders the progress bar, no stepper", () => {
const html = render({ phase: "installing", phases: undefined, logs: "x", progress: 40 });
+ expect(html).toContain("mx-auto max-w-2xl"); // the legacy centered container
expect(html).toContain("h-1.5 w-full"); // the numeric progress bar's track
expect(text(html)).not.toContain("Installation steps");
});
});
+describe("CleanDeployProgressCard — install progress readout", () => {
+ it("replaces the status pill with a bar, a step counter and the service tally", () => {
+ const html = render({
+ phase: "installing",
+ phases: { images: "done", services: "active" },
+ services: [
+ { serviceName: "db", serviceId: "s1", status: "running" },
+ { serviceName: "web", serviceId: "s2", status: "deploying" },
+ { serviceName: "worker", serviceId: "s3", status: "pending" },
+ { serviceName: "cache", serviceId: "s4", status: "pending" },
+ ],
+ phaseLabel: "Starting services",
+ onStop: () => {},
+ });
+ const out = text(html);
+ // 3 phases (this app has no app-setup) → images done (1) + services active at
+ // 1/4 of its services (0.25), over 3 → 42%.
+ expect(out).toContain("42%");
+ expect(out).toContain("Step 2 of 3");
+ expect(out).toContain("1 of 4 services ready");
+ expect(html).toContain("animate-progress-sweep");
+ // The dot the pill used to carry is gone from the aside.
+ expect(html).not.toContain("bg-info-solid");
+ });
+
+ it("renders the chosen configuration as the aside read-out", () => {
+ const out = text(
+ render({
+ phase: "installing",
+ phases: { services: "active" },
+ summary: [
+ { id: "destination", label: "Destination", value: "prod-vps" },
+ { id: "ep", label: "Web UI", value: "posthog.opsh.io", mono: true },
+ ],
+ }),
+ );
+ expect(out).toContain("Configuration");
+ expect(out).toContain("Destination");
+ expect(out).toContain("prod-vps");
+ expect(out).toContain("posthog.opsh.io");
+ });
+
+ it("keeps the summary on the terminal screens, where the pill carries a glyph", () => {
+ const html = render({
+ phase: "done",
+ phases: { ready: "done" },
+ liveUrl: "https://x.example.com",
+ summary: [{ id: "destination", label: "Destination", value: "prod-vps" }],
+ });
+ expect(text(html)).toContain("prod-vps");
+ expect(text(html)).toContain("Live");
+ expect(html).toContain("lucide-check");
+ expect(html).not.toContain("animate-progress-sweep"); // no bar once it's settled
+ });
+
+ it("omits the summary card entirely when there's nothing configured to show", () => {
+ const out = text(render({ phase: "installing", phases: {}, summary: [] }));
+ expect(out).not.toContain("Configuration");
+ });
+});
+
+describe("installProgressPercent", () => {
+ const row = (status: StepStatus, subs: StepStatus[] = []) => ({ status, subs });
+
+ it("weighs phases equally and sub-steps inside the active phase", () => {
+ expect(installProgressPercent([])).toBe(0);
+ // Nothing started yet → the visible floor, not 0.
+ expect(installProgressPercent([row("pending"), row("pending")])).toBe(4);
+ // A sub-list-less active phase is worth half of it.
+ expect(installProgressPercent([row("done"), row("active"), row("pending"), row("pending")])).toBe(38);
+ // Services advance inside their own phase: 5/10 up → 1.5/4.
+ expect(
+ installProgressPercent([
+ row("done"),
+ row("active", [
+ ...Array(5).fill("done"),
+ ...Array(5).fill("pending"),
+ ]),
+ row("pending"),
+ row("pending"),
+ ]),
+ ).toBe(38);
+ // A skipped phase counts as passed (a pull-only app skips image prep).
+ expect(installProgressPercent([row("skipped"), row("done")])).toBe(99);
+ });
+
+ it("never reaches 100 while the install is still running", () => {
+ expect(installProgressPercent([row("done"), row("active", ["done", "done"])])).toBeLessThan(100);
+ });
+});
+
+describe("installStepIndex", () => {
+ it("points at the phase in flight, or the last settled one while queued", () => {
+ expect(installStepIndex([{ status: "done" }, { status: "active" }, { status: "pending" }])).toBe(2);
+ expect(installStepIndex([{ status: "done" }, { status: "done" }, { status: "pending" }])).toBe(2);
+ // Queued: nothing settled, nothing active → still "step 1".
+ expect(installStepIndex([{ status: "pending" }, { status: "pending" }])).toBe(1);
+ });
+});
+
describe("CleanDeployProgressCard — Stop / status pill / header", () => {
it("offers a Stop button while installing when onStop is wired", () => {
const out = text(render({ phase: "installing", phases: { services: "active" }, onStop: () => {} }));
diff --git a/apps/dashboard/src/components/deploy/CleanDeployProgress.tsx b/apps/dashboard/src/components/deploy/CleanDeployProgress.tsx
index 44a9582a1..713b6beff 100644
--- a/apps/dashboard/src/components/deploy/CleanDeployProgress.tsx
+++ b/apps/dashboard/src/components/deploy/CleanDeployProgress.tsx
@@ -13,11 +13,12 @@ import {
ChevronUp,
Square,
Ban,
+ Clock,
} from "lucide-react";
import { INSTALL_PHASES, type InstallPhaseId, type InstallPhaseStatus } from "@repo/core";
import { AppLogo } from "@/components/AppLogo";
import { PageContainer } from "@/components/ui/PageContainer";
-import { useI18n } from "@/components/i18n-provider";
+import { useI18n, interpolate } from "@/components/i18n-provider";
import { InstallStepper, type StepItem, type StepStatus } from "@/components/deploy/InstallStepper";
import { ConnectionCard } from "@/app/(dashboard)/projects/[id]/components/ConnectionCard";
import type { ServiceStatusEvent } from "@/lib/sseMessageProcessors";
@@ -57,6 +58,158 @@ function serviceStatusToStep(status: ServiceStatusEvent["status"]): StepStatus {
return "active";
}
+/**
+ * One row of the install aside's read-out of what this deploy was configured
+ * with — the destination, where each endpoint lands, the settings picked. It's
+ * the one thing an operator can't read off the stepper or the logs, and it's what
+ * the aside shows instead of a status dot that repeated the header.
+ */
+export interface DeploySummaryRow {
+ id: string;
+ label: string;
+ value: string;
+ /** Monospace the value — hostnames, hosts and ports read better fixed-width. */
+ mono?: boolean;
+}
+
+/** A top-level install phase plus its sub-step statuses. The stepper AND the
+ * progress bar are both derived from this, so the two can't disagree. */
+type PhaseRow = { id: InstallPhaseId; label: string; status: StepStatus; subs: StepStatus[] };
+
+/** Statuses that mean a step will not advance again. */
+const SETTLED: ReadonlySet = new Set(["done", "skipped", "failed", "error"]);
+
+/**
+ * Completion percent for the install bar, derived from the phase rows the
+ * checklist renders — never from the backend's build percentage, which counts
+ * build steps a services install never runs and would leave a bar at 90% next to
+ * a checklist sitting on step 2.
+ *
+ * Phases weigh equally, and an ACTIVE phase contributes its own sub-step
+ * fraction: a 10-service app advances ten times inside "Starting services"
+ * instead of standing still at 25%. Held under 100 — this only renders while the
+ * install is still running, so a full bar would be a lie.
+ */
+export function installProgressPercent(
+ rows: readonly { status: StepStatus; subs: readonly StepStatus[] }[],
+): number {
+ if (rows.length === 0) return 0;
+ let done = 0;
+ for (const r of rows) {
+ if (SETTLED.has(r.status)) {
+ done += 1;
+ } else if (r.status === "active" || r.status === "running") {
+ const settled = r.subs.filter((s) => SETTLED.has(s)).length;
+ // No sub-list → half the phase. With one, a just-started phase still nudges
+ // the bar (0.15) so "working" is visible before the first service is up.
+ done += r.subs.length === 0 ? 0.5 : Math.min(0.95, Math.max(0.15, settled / r.subs.length));
+ }
+ }
+ return Math.min(99, Math.max(4, Math.round((done / rows.length) * 100)));
+}
+
+/** 1-based index of the phase in flight — or how many have settled while nothing
+ * is active yet (queued, or between phases). */
+export function installStepIndex(rows: readonly { status: StepStatus }[]): number {
+ const active = rows.findIndex((r) => r.status === "active" || r.status === "running");
+ if (active >= 0) return active + 1;
+ const settled = rows.filter((r) => SETTLED.has(r.status)).length;
+ return Math.min(rows.length, Math.max(1, settled));
+}
+
+/** Compact elapsed time: "42s", "3m 08s", "1h 12m". */
+function formatElapsed(ms: number): string {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ if (s < 60) return `${s}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
+}
+
+/**
+ * The aside's live install readout: a real progress bar (width = the stepper's own
+ * completion), the phase in flight, its step counter and per-service tally, and an
+ * elapsed clock. Replaces the status pill while installing — the pill said
+ * "Installing" beside a spinner already saying it, and a heavy app can sit in one
+ * phase for minutes with nothing to show that anything is moving.
+ */
+function InstallProgressPanel({
+ title,
+ percent,
+ phaseLabel,
+ metaLine,
+ elapsed,
+}: {
+ title: string;
+ percent: number;
+ phaseLabel: string;
+ /** "Step 2 of 4 · 3 of 10 services ready" — assembled by the caller (i18n). */
+ metaLine: string;
+ elapsed: string | null;
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+ {percent}%
+
+
+
+
+ {/* Progress is the WIDTH; the sweep only says "still moving", so a long
+ phase doesn't read as a hung bar without faking advancement. */}
+
+
+
+ {phaseLabel && (
+
{phaseLabel}
+ )}
+
+ {metaLine}
+ {elapsed && (
+
+
+ {elapsed}
+
+ )}
+
+
+ );
+}
+
+/** The chosen-configuration read-out (destination, endpoints, settings). */
+function ConfigSummaryCard({ title, rows }: { title: string; rows: DeploySummaryRow[] }) {
+ return (
+
+
+ {title}
+
+
+ {rows.map((r) => (
+
+
+ {r.label}
+
+
+ {r.value}
+
+
+ ))}
+
+
+ );
+}
+
/**
* Live log panel — a clean, theme-aware monospace console (an elevated surface
* that adapts to light/dark, not a hardcoded black box) with a titlebar, a
@@ -249,6 +402,8 @@ export function CleanDeployProgressCard({
appSetupSteps,
firstLogin,
connect,
+ summary,
+ startedAt,
}: {
appId: string;
title: string;
@@ -288,9 +443,29 @@ export function CleanDeployProgressCard({
serverId?: string | null;
deployTarget?: string | null;
};
+ /** What this install was configured with, rendered in the aside. Rows the
+ * caller can't know (e.g. the destination after a mid-install refresh) are
+ * simply absent — never guessed. */
+ summary?: DeploySummaryRow[];
+ /** Epoch ms the install started, for the elapsed clock. Omit → no clock. */
+ startedAt?: number | null;
}) {
const { t } = useI18n();
const w = t.projectSettings.appInstall;
+ // Elapsed clock, ticked once in the parent because the progress panel renders
+ // twice (sticky aside + the mobile inline card). `now` stays null until an
+ // effect runs, so SSR emits no time rather than a frozen one.
+ const [now, setNow] = useState(null);
+ useEffect(() => {
+ if (phase !== "installing") {
+ setNow(null);
+ return;
+ }
+ setNow(Date.now());
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [phase]);
+ const elapsed = startedAt != null && now != null ? formatElapsed(now - startedAt) : null;
const liveHost = liveUrl ? liveUrl.replace(/^https?:\/\//, "") : null;
// `phases` is the switch: the app wizard always passes its state object (even
// empty → all-pending preview); the mail wizard passes nothing.
@@ -455,12 +630,23 @@ export function CleanDeployProgressCard({
// The app-setup phase only exists when the app has prepare steps — otherwise
// it would hang "pending" forever after the deploy went live.
const hasAppSetup = (appSetupSteps?.length ?? 0) > 0 || phases?.["app-setup"] != null;
- const stepItems: StepItem[] = INSTALL_PHASES.filter(
+ const phaseRows: PhaseRow[] = INSTALL_PHASES.filter(
(p) => p.id !== "app-setup" || hasAppSetup,
).map((p) => ({
id: p.id,
label: phaseLabelFor[p.id],
status: phases?.[p.id] ?? "pending",
+ subs:
+ p.id === "services"
+ ? serviceItems.map((s) => s.status)
+ : p.id === "app-setup"
+ ? appSetupItems.map((s) => s.status)
+ : [],
+ }));
+ const stepItems: StepItem[] = phaseRows.map((p) => ({
+ id: p.id,
+ label: p.label,
+ status: p.status,
children:
p.id === "services" && serviceItems.length > 0 ? (
@@ -469,15 +655,63 @@ export function CleanDeployProgressCard({
) : undefined,
}));
- // Aside status pill — theme tokens only, mirrors PROJECT_STATUS_META.
+ // Aside status pill — theme tokens only, mirrors PROJECT_STATUS_META. Carries a
+ // glyph rather than a bare dot: a coloured dot beside a word the header already
+ // shows was decoration, and while installing the pill is replaced outright by
+ // the live progress panel below.
const pill =
phase === "installing"
- ? { badge: "bg-info-bg text-info", dot: "bg-info-solid", label: w.statusInstalling }
+ ? {
+ badge: "bg-info-bg text-info",
+ icon: ,
+ label: w.statusInstalling,
+ }
: phase === "done"
- ? { badge: "bg-success-bg text-success", dot: "bg-success-solid", label: w.statusLive }
+ ? {
+ badge: "bg-success-bg text-success",
+ icon: ,
+ label: w.statusLive,
+ }
: cancelled
- ? { badge: "bg-muted text-muted-foreground", dot: "bg-muted-foreground", label: w.statusCancelled }
- : { badge: "bg-danger-bg text-danger", dot: "bg-danger-solid", label: w.statusFailed };
+ ? {
+ badge: "bg-muted text-muted-foreground",
+ icon: ,
+ label: w.statusCancelled,
+ }
+ : {
+ badge: "bg-danger-bg text-danger",
+ icon: ,
+ label: w.statusFailed,
+ };
+
+ // Live install readout for the aside (and the mobile card): the stepper's own
+ // completion as a bar, the phase in flight, its step counter + service tally.
+ const servicesDone = serviceItems.filter((s) => s.status === "done").length;
+ const metaLine = [
+ interpolate(w.progressStep, {
+ current: String(installStepIndex(phaseRows)),
+ total: String(phaseRows.length),
+ }),
+ serviceItems.length > 0
+ ? interpolate(w.progressServicesReady, {
+ done: String(servicesDone),
+ total: String(serviceItems.length),
+ })
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ const progressPanel =
+ phase === "installing" ? (
+
+ ) : null;
+ const summaryRows = summary ?? [];
const btnPrimary =
"inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90";
@@ -633,12 +867,20 @@ export function CleanDeployProgressCard({
- {/* MAIN — the substance for the current phase. */}
+ {/* MAIN — the substance for the current phase. The aside is desktop-only,
+ so its progress panel, actions and config read-out are mirrored here
+ for narrow viewports. */}
{mainContent}
- {actions && (
-
- {actions}
+ {(progressPanel || actions) && (
+
+ {progressPanel}
+ {actions &&
{actions}
}
+
+ )}
+ {summaryRows.length > 0 && (
+
+
)}
@@ -658,19 +900,26 @@ export function CleanDeployProgressCard({
{title}
-
-
-
- {pill.label}
-
+ {/* Installing → the live progress readout; settled → the status
+ pill, which now carries a real verdict rather than a dot. */}
+
+ {progressPanel ?? (
+
+ {pill.icon}
+ {pill.label}
+
+ )}
{phase === "done" && liveHost && (
{liveHost}
)}
{actions &&
{actions}
}
+ {summaryRows.length > 0 && (
+
+ )}
diff --git a/apps/dashboard/src/components/import-project/ComposeServices.tsx b/apps/dashboard/src/components/import-project/ComposeServices.tsx
index 8188e6c33..22318d9be 100644
--- a/apps/dashboard/src/components/import-project/ComposeServices.tsx
+++ b/apps/dashboard/src/components/import-project/ComposeServices.tsx
@@ -708,12 +708,14 @@ const ServiceCard: React.FC<{
const missingCount = missingEnvCount(service);
const envCount = Object.keys(service.environment).length;
const [envModalOpen, setEnvModalOpen] = useState(false);
- // #336: in the folder-upload flow the scan masks env — reveal THIS service's
- // real values from the upload session (write-gated on the API). Only wired
- // when an upload session exists; git/edit flows have no session-scoped source.
+ // #336: in the folder-upload flow the scan masks env — reveal the opened keys of
+ // THIS service from the upload session (write-gated on the API, and scoped to one
+ // service so a sibling's secrets never come along). Only wired when an upload
+ // session exists; git/edit flows have no session-scoped source.
const uploadSessionId = config.uploadSessionId;
- const onRevealAll = uploadSessionId
- ? async () => (await folderApi.reveal(uploadSessionId)).environments[service.name] ?? {}
+ const onReveal = uploadSessionId
+ ? async (keys: string[]) =>
+ (await folderApi.reveal(uploadSessionId, service.name, keys)).environment
: undefined;
const [envRows, setEnvRows] = useState(() =>
envToArray(service.environment, {}, service.environmentMeta),
@@ -907,7 +909,7 @@ const ServiceCard: React.FC<{
envVars={envRows}
envMeta={service.environmentMeta}
onEnvVarsChange={handleEnvChange}
- onRevealAll={onRevealAll}
+ onReveal={onReveal}
/>
diff --git a/apps/dashboard/src/components/import-project/EnvironmentVariables.tsx b/apps/dashboard/src/components/import-project/EnvironmentVariables.tsx
index 30e0a4680..7c87fcd61 100644
--- a/apps/dashboard/src/components/import-project/EnvironmentVariables.tsx
+++ b/apps/dashboard/src/components/import-project/EnvironmentVariables.tsx
@@ -59,12 +59,14 @@ interface EnvironmentVariablesPropsOptional {
envMeta?: Record;
onEnvVarsChange?: (envVars: EnvironmentVariableRow[]) => void;
/**
- * #336: fetch the REAL (unmasked) env values, keyed by env key. When provided
- * and any row is masked (`••••••••`), a "Show values" toggle appears that
- * reveals them in a display-only overlay. Omit when there's no reveal source
- * (a new, unsaved service) — the toggle simply won't show.
+ * #336: fetch the REAL (unmasked) values for EXACTLY `keys` — one row's eye
+ * asks for that one key, the header's "Show values" asks for every masked key.
+ * Never a "give me everything" call: the API requires the key names, so a
+ * single reveal discloses a single secret. When provided and any row is masked
+ * (`••••••••`), the reveal affordances appear. Omit when there's no reveal
+ * source (a new, unsaved service) — they simply won't show.
*/
- onRevealAll?: () => Promise>;
+ onReveal?: (keys: string[]) => Promise>;
}
const EnvironmentVariables: React.FC = ({
@@ -82,7 +84,7 @@ const EnvironmentVariables: React.FC = ({
envVars: externalEnvVars,
envMeta,
onEnvVarsChange,
- onRevealAll,
+ onReveal,
}) => {
const deployment = useOptionalDeployment();
const { showToast } = useToast();
@@ -148,7 +150,8 @@ const EnvironmentVariables: React.FC = ({
// out of `currentEnvVars` on purpose: the row value stays the mask sentinel
// until the user actually edits it, so revealing never marks the form dirty
// and a save still round-trips the sentinel (backend keeps the stored secret).
- const [revealedValues, setRevealedValues] = useState | null>(null);
+ // Fills in per key — a row's eye only ever puts THAT row's secret in here.
+ const [revealedValues, setRevealedValues] = useState>({});
// Which masked rows are currently SHOWN as text, keyed by env key — a local
// overlay, NOT the row's `visible` field. A masked row's value is a sentinel, so
// its visibility is a pure display concern; routing it through `updateEnvVars`
@@ -156,80 +159,137 @@ const EnvironmentVariables: React.FC = ({
// whose Record bridge (envToRows/rowsToEnv) can't carry `visible`.
// Keeping it here makes the eye work identically in every host.
const [shownKeys, setShownKeys] = useState>(() => new Set());
- const [revealing, setRevealing] = useState(false);
- const hasMaskedRow = currentEnvVars.some((env) => isMaskedValue(env.value));
-
- // Fetch the real values from the server ONCE (via onRevealAll) into the overlay.
- // The ref dedupes concurrent callers — a rapid double-click, or the header toggle
- // racing a per-row eye — so onRevealAll fires a single time. Resolves to the map
- // (null when no reveal source is wired) and rejects — after toasting — so callers
- // bail without surfacing a second error.
- const revealPromiseRef = useRef> | null>(null);
- const ensureRevealed = useCallback(async (): Promise | null> => {
- if (revealedValues) return revealedValues;
- if (!onRevealAll) return null;
- if (!revealPromiseRef.current) {
- setRevealing(true);
- revealPromiseRef.current = onRevealAll()
- .then((vals) => {
- setRevealedValues(vals);
- return vals;
- })
- .catch((err) => {
- showToast(ev.reveal?.error ?? "Failed to reveal values", "error", ev.toast.title);
- throw err;
- })
- .finally(() => {
- setRevealing(false);
- revealPromiseRef.current = null;
- });
- }
- return revealPromiseRef.current;
- }, [revealedValues, onRevealAll, showToast, ev]);
+ const [revealingKeys, setRevealingKeys] = useState>(() => new Set());
+ const maskedKeys = currentEnvVars.filter((env) => isMaskedValue(env.value)).map((env) => env.key);
+ const hasMaskedRow = maskedKeys.length > 0;
+ // Every masked row shown → the header flips to "Hide values". Until then it
+ // reads "Show values" and fetches whatever is still hidden, so the pair is
+ // monotone: show-all → hide-all, with no dead end after a single row's eye.
+ const allShown = hasMaskedRow && maskedKeys.every((key) => shownKeys.has(key));
+
+ // Fetch plaintext for EXACTLY `keys`, minus what's already in the overlay. The
+ // ref holds one in-flight promise per key, so a rapid double-click — or the
+ // header racing a row's eye — shares a request instead of re-asking the server
+ // for the same secret. Rejects (after toasting) so callers bail without
+ // surfacing a second error; resolves null when no reveal source is wired.
+ const inFlightRef = useRef>>>(new Map());
+ const ensureRevealed = useCallback(
+ async (keys: string[]): Promise | null> => {
+ if (!onReveal) return null;
+ const known: Record = {};
+ const pending: Promise>[] = [];
+ const toFetch: string[] = [];
+ for (const key of keys) {
+ // hasOwn, not `in`: an env var named `constructor` would otherwise "hit"
+ // the overlay and hand back a function off Object.prototype.
+ if (Object.hasOwn(revealedValues, key)) known[key] = revealedValues[key];
+ else {
+ const inFlight = inFlightRef.current.get(key);
+ if (inFlight) pending.push(inFlight);
+ else toFetch.push(key);
+ }
+ }
+ if (toFetch.length > 0) {
+ const request = onReveal(toFetch)
+ .then((vals) => {
+ setRevealedValues((prev) => ({ ...prev, ...vals }));
+ return vals;
+ })
+ .catch((err) => {
+ showToast(ev.reveal?.error ?? "Failed to reveal values", "error", ev.toast.title);
+ throw err;
+ })
+ .finally(() => {
+ for (const key of toFetch) inFlightRef.current.delete(key);
+ setRevealingKeys((prev) => {
+ const next = new Set(prev);
+ for (const key of toFetch) next.delete(key);
+ return next;
+ });
+ });
+ for (const key of toFetch) inFlightRef.current.set(key, request);
+ setRevealingKeys((prev) => new Set([...prev, ...toFetch]));
+ pending.push(request);
+ }
+ if (pending.length === 0) return known;
+ return Object.assign(known, ...(await Promise.all(pending)));
+ },
+ [revealedValues, onReveal, showToast, ev]
+ );
+
+ // Reveal `keys` and show exactly the ones that came back. A key the source no
+ // longer has stays masked — displaying the sentinel as "plaintext" would be a
+ // lie — and the operator gets the error toast.
+ const revealAndShow = useCallback(
+ async (keys: string[]) => {
+ const vals = await ensureRevealed(keys).catch(() => null);
+ if (!vals) return;
+ const got = keys.filter((key) => Object.hasOwn(vals, key));
+ if (got.length < keys.length) {
+ showToast(ev.reveal?.error ?? "Failed to reveal values", "error", ev.toast.title);
+ }
+ if (got.length > 0) setShownKeys((prev) => new Set([...prev, ...got]));
+ },
+ [ensureRevealed, showToast, ev]
+ );
- // The per-row eye. A masked row holds only the sentinel: the first reveal fetches
- // the real values from the server (once) and then this row is toggled shown/hidden
- // via the local `shownKeys` overlay. A plaintext row (new / already-typed) is the
- // plain local password/text flip on its own `visible` field.
+ // Drop plaintext for `keys` from the overlay as they're hidden, so a revealed
+ // secret doesn't linger in component state after the operator hides it. Showing
+ // it again re-fetches (per key) — one round trip is worth not holding it.
+ const hideKeys = useCallback((keys: string[]) => {
+ const dropped = new Set(keys);
+ setShownKeys((prev) => new Set([...prev].filter((key) => !dropped.has(key))));
+ setRevealedValues((prev) =>
+ Object.fromEntries(Object.entries(prev).filter(([key]) => !dropped.has(key)))
+ );
+ }, []);
+
+ // The per-row eye. A masked row holds only the sentinel, so showing it fetches
+ // THAT key's real value (nothing else) into the overlay. A plaintext row (new /
+ // already-typed) is the plain local password/text flip on its own `visible` field.
const toggleEnvVisibility = useCallback(
async (index: number) => {
const target = currentEnvVars[index];
if (!target) return;
if (isMaskedValue(target.value)) {
- if (onRevealAll && !(await ensureRevealed().catch(() => null))) return;
- setShownKeys((prev) => {
- const next = new Set(prev);
- if (next.has(target.key)) next.delete(target.key);
- else next.add(target.key);
- return next;
- });
+ if (shownKeys.has(target.key)) hideKeys([target.key]);
+ else await revealAndShow([target.key]);
return;
}
updateEnvVars(
currentEnvVars.map((env, i) => (i === index ? { ...env, visible: !env.visible } : env))
);
},
- [currentEnvVars, updateEnvVars, onRevealAll, ensureRevealed]
+ [currentEnvVars, updateEnvVars, shownKeys, hideKeys, revealAndShow]
);
- // Header "Show values" / "Hide values": reveal (fetch once) and show every masked
- // row via the overlay so the button is meaningful on its own; hide clears the
- // overlay and the shown set so nothing is left exposed.
+ // Header "Show values" / "Hide values": the explicit bulk action — the only
+ // request that names every masked key at once. Hiding clears the overlay so
+ // nothing is left exposed.
const toggleRevealAll = useCallback(async () => {
- if (revealedValues) {
- setRevealedValues(null);
- setShownKeys(new Set());
+ if (allShown) {
+ hideKeys(maskedKeys);
return;
}
- if (!(await ensureRevealed().catch(() => null))) return;
- setShownKeys(new Set(currentEnvVars.filter((e) => isMaskedValue(e.value)).map((e) => e.key)));
- }, [revealedValues, ensureRevealed, currentEnvVars]);
+ await revealAndShow(maskedKeys);
+ }, [allShown, maskedKeys, hideKeys, revealAndShow]);
const handleKeyChange = (index: number, value: string) => {
updateEnvVar(index, "key", value);
};
const handleValueChange = (index: number, value: string) => {
+ const row = currentEnvVars[index];
+ // Editing a REVEALED row turns it into a plaintext row, which reads visibility
+ // from its own `visible` flag instead of `shownKeys` — so carry the shown state
+ // over, or the value the operator is typing flips back to dots mid-keystroke in
+ // any host whose rows start `visible: false` (the migration wizard's do).
+ if (row && isMaskedValue(row.value) && shownKeys.has(row.key)) {
+ updateEnvVars(
+ currentEnvVars.map((env, i) => (i === index ? { ...env, value, visible: true } : env))
+ );
+ return;
+ }
updateEnvVar(index, "value", value);
};
@@ -637,18 +697,18 @@ const EnvironmentVariables: React.FC = ({
>
)}
- {/* #336: reveal masked values. Only when a reveal source is wired
- (onRevealAll) and there's something masked, or already revealed. */}
- {onRevealAll && (hasMaskedRow || revealedValues) && (
+ {/* #336: bulk reveal — the one action that asks for every masked key at
+ once. Only when a reveal source is wired and something is masked. */}
+ {onReveal && hasMaskedRow && (
void toggleRevealAll()}
- disabled={revealing}
+ disabled={revealingKeys.size > 0}
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-foreground bg-muted/60 hover:bg-muted rounded-lg transition-colors disabled:opacity-50"
- title={revealedValues ? ev.reveal?.hide : ev.reveal?.show}
+ title={allShown ? ev.reveal?.hide : ev.reveal?.show}
>
- {revealedValues ? : }
- {revealedValues ? ev.reveal?.hide : ev.reveal?.show}
+ {allShown ? : }
+ {allShown ? ev.reveal?.hide : ev.reveal?.show}
)}
{collapsible && (
@@ -695,6 +755,18 @@ const EnvironmentVariables: React.FC = ({
{currentEnvVars.map((env, index) => {
const resolution = getEnvResolutionState(envMeta?.[env.key], env.value, t);
const inputStateClass = resolution?.inputClass ?? "";
+ // #336: a masked row holds only the sentinel — the real value arrives in
+ // the overlay when THAT key is revealed, and its visibility lives in
+ // `shownKeys`. A plaintext row (new / typed) shows its own value and keeps
+ // its own `visible` flag. Masked with no reveal source wired: no eye at
+ // all, since the toggle could only ever display the sentinel as text.
+ const masked = isMaskedValue(env.value);
+ const showAsText = masked ? shownKeys.has(env.key) : env.visible;
+ const displayValue =
+ masked && Object.hasOwn(revealedValues, env.key)
+ ? revealedValues[env.key]
+ : env.value;
+ const canToggleValue = !masked || Boolean(onReveal);
return (
{resolution && (
@@ -715,19 +787,6 @@ const EnvironmentVariables: React.FC
= ({
} ${inputStateClass}`}
/>
- {(() => {
- // #336: masked rows display the revealed overlay value when
- // shown; the underlying state stays the sentinel until edited
- // (see revealedValues). Real/typed rows display their own value
- // unchanged. Masked-row visibility is the local `shownKeys`
- // overlay; plaintext rows keep their own `visible` flag.
- const masked = isMaskedValue(env.value);
- const showAsText = masked ? shownKeys.has(env.key) : env.visible;
- const displayValue =
- masked && revealedValues && env.key in revealedValues
- ? revealedValues[env.key]
- : env.value;
- return (
= ({
!isEditingMode ? 'cursor-default bg-muted/20' : 'bg-muted/30'
} ${inputStateClass}`}
/>
- );
- })()}
- void toggleEnvVisibility(index)}
- className="absolute end-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
- type="button"
- >
- {(isMaskedValue(env.value) ? shownKeys.has(env.key) : env.visible) ? (
-
- ) : (
-
- )}
-
+ {canToggleValue && (
+ void toggleEnvVisibility(index)}
+ disabled={revealingKeys.has(env.key)}
+ className="absolute end-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors disabled:opacity-40"
+ type="button"
+ >
+ {showAsText ? : }
+
+ )}
{showEditControls && isEditingMode && (
diff --git a/apps/dashboard/src/components/infra/InfraFleetCard.render.test.tsx b/apps/dashboard/src/components/infra/InfraFleetCard.render.test.tsx
new file mode 100644
index 000000000..0730405ed
--- /dev/null
+++ b/apps/dashboard/src/components/infra/InfraFleetCard.render.test.tsx
@@ -0,0 +1,188 @@
+// No DOM needed: renderToStaticMarkup runs no effects, and the card is pure.
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { I18nProvider } from "@/components/i18n-provider";
+import type { ContainerApplyActive, ContainerApplyStep } from "@/lib/api/system";
+import type { ApplyOutcome } from "@/lib/infra-apply-status";
+import { InfraFleetCard } from "./InfraFleetCard";
+
+type Counts = {
+ attention: number;
+ updates: number;
+ healthy: number;
+ stopped: number;
+ behind: number;
+ applying: number;
+};
+
+const IDLE: Counts = { attention: 0, updates: 0, healthy: 3, stopped: 0, behind: 0, applying: 0 };
+
+function steps(running: ContainerApplyStep["id"] | null): ContainerApplyStep[] {
+ return (["pull", "recreate", "verify"] as const).map((id, i) => ({
+ id,
+ label: id,
+ status:
+ running === null
+ ? "pending"
+ : i < (["pull", "recreate", "verify"] as const).indexOf(running)
+ ? "done"
+ : id === running
+ ? "running"
+ : "pending",
+ }));
+}
+
+function target(over: Partial = {}): ContainerApplyActive {
+ return {
+ serverId: "srv_1",
+ serverName: "web-1",
+ component: "edge",
+ state: "running",
+ intent: "update",
+ sessionId: "capp_1",
+ steps: steps("pull"),
+ ...over,
+ };
+}
+
+function render(
+ over: {
+ counts?: Partial;
+ active?: ContainerApplyActive[];
+ outcome?: ApplyOutcome | null;
+ onViewLogs?: (t: ContainerApplyActive) => void;
+ } = {},
+) {
+ return renderToStaticMarkup(
+
+ {}}
+ onApply={() => {}}
+ onViewLogs={over.onViewLogs ?? (() => {})}
+ />
+ ,
+ );
+}
+
+function text(html: string) {
+ return html
+ .replace(/<[^>]+>/g, " ")
+ .replace(/'|'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/&/g, "&")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+describe("resting state", () => {
+ it("says everything is up to date when there is nothing to do", () => {
+ const out = text(render());
+ expect(out).toContain("All components up to date");
+ expect(out).not.toContain("Updating");
+ });
+
+ it("offers the bulk actions for work that is not underway", () => {
+ const out = text(
+ render({ counts: { updates: 1, behind: 2, stopped: 1, healthy: 2, attention: 1 } }),
+ );
+ expect(out).toContain("Update all (2)");
+ expect(out).toContain("Restart stopped (1)");
+ expect(out).toContain("1 with updates");
+ });
+
+ it("agrees with itself at one — never '1 need attention'", () => {
+ const out = text(render({ counts: { attention: 1, healthy: 2 } }));
+ expect(out).toContain("1 needs attention");
+ expect(out).not.toContain("1 need attention");
+ });
+});
+
+describe("in flight", () => {
+ it("reports the run, its phase and its percentage", () => {
+ const out = text(render({ counts: { applying: 1 }, active: [target()] }));
+ expect(out).toContain("Updating 1 component");
+ expect(out).toContain("Pulling image");
+ expect(out).toContain("17%");
+ expect(out).toContain("web-1");
+ });
+
+ it("does not claim everything is up to date while a swap is running", () => {
+ const out = text(render({ counts: { applying: 1 }, active: [target()] }));
+ expect(out).not.toContain("All components up to date");
+ });
+
+ it("follows the step model", () => {
+ expect(text(render({ active: [target({ steps: steps("recreate") })] }))).toContain("Recreating");
+ expect(text(render({ active: [target({ steps: steps("verify") })] }))).toContain("Verifying");
+ });
+
+ it("calls a restart a restart, and never narrates a pull for one", () => {
+ const out = text(
+ render({ active: [target({ intent: "repair", steps: steps(null) })] }),
+ );
+ expect(out).toContain("Restarting 1 component");
+ expect(out).not.toContain("Pulling image");
+ });
+
+ it("shows a queued target as queued, with no log to open", () => {
+ const out = render({
+ active: [target({ state: "queued", sessionId: undefined, steps: undefined })],
+ });
+ expect(text(out)).toContain("Queued");
+ expect(text(out)).not.toContain("View logs");
+ });
+
+ it("offers the live log for a running target", () => {
+ expect(text(render({ active: [target()] }))).toContain("View logs");
+ });
+
+ it("collapses a long queue instead of growing the card", () => {
+ const many = ["srv_1", "srv_2", "srv_3", "srv_4", "srv_5"].map((id) =>
+ target({ serverId: id, serverName: id }),
+ );
+ const out = text(render({ active: many }));
+ expect(out).toContain("Updating 5 components");
+ expect(out).toContain("+2 more");
+ expect(out).not.toContain("srv_5");
+ });
+
+ it("keeps the counts for work it is NOT doing, and disables the actions", () => {
+ const html = render({
+ counts: { attention: 2, updates: 1, applying: 1, behind: 1 },
+ active: [target()],
+ });
+ expect(text(html)).toContain("2 need attention");
+ expect(text(html)).toContain("Updating 1 component");
+ // A second bulk while one is running re-queues containers already accepted.
+ expect(html).toContain("disabled");
+ });
+});
+
+describe("settled", () => {
+ it("reports the finish the counts can never carry", () => {
+ const out = text(render({ outcome: { done: 1, failed: 0 } }));
+ expect(out).toContain("1 component updated");
+ expect(out).not.toContain("All components up to date");
+ });
+
+ it("names a failure and its reason", () => {
+ const out = text(
+ render({
+ counts: { updates: 1, behind: 1 },
+ outcome: { done: 0, failed: 1, error: "could not pull the image" },
+ }),
+ );
+ expect(out).toContain("1 component didn't finish");
+ expect(out).toContain("could not pull the image");
+ });
+
+ it("stays quiet about the last run while a new one is going", () => {
+ const out = text(render({ active: [target()], outcome: { done: 1, failed: 0 } }));
+ expect(out).not.toContain("1 component updated");
+ });
+});
diff --git a/apps/dashboard/src/components/infra/InfraFleetCard.tsx b/apps/dashboard/src/components/infra/InfraFleetCard.tsx
index 74b24ac0c..b06182d49 100644
--- a/apps/dashboard/src/components/infra/InfraFleetCard.tsx
+++ b/apps/dashboard/src/components/infra/InfraFleetCard.tsx
@@ -7,42 +7,74 @@ import {
CheckCircle2,
Loader2,
RefreshCw,
+ ScrollText,
ShieldAlert,
+ TriangleAlert,
Wrench,
} from "lucide-react";
import { useI18n, interpolate } from "@/components/i18n-provider";
-import type { ContainerApplyIntent } from "@/lib/api/system";
+import type { ContainerApplyActive, ContainerApplyIntent } from "@/lib/api/system";
+import { applyIntentOf, applyPercent, applyPhase, type ApplyOutcome } from "@/lib/infra-apply-status";
/** Header controls — shared so the scan button and the tracker link sit level. */
const ICON_BUTTON =
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50";
+/** Per-target lines shown in full before the rest collapse into a "+N more". */
+const TARGET_LINES = 3;
+
/**
* Servers tab → right column: the fleet roll-up for managed containers (edge /
- * mail) plus the two bulk actions. Presentational — the counts and both callbacks
- * come from {@link useInfraFleet}, which drives the same endpoints a single row
- * uses, so a bulk apply is just many of the per-server sessions started at once.
+ * mail), the two bulk actions, and the LIVE state of anything being applied.
+ * Presentational — the counts, the in-flight set and both callbacks come from
+ * {@link useInfraFleet}, which drives the same endpoints a single row uses, so a
+ * bulk apply is just many of the per-server sessions started at once.
*
- * Buttons appear only when they have something to act on, and the whole body
- * collapses to one "all up to date" line when the fleet is clean.
+ * The three states are deliberately separate lines rather than one mode switch:
+ * counts describe what is left to do (work already underway is excluded from them,
+ * so nothing here offers the same swap twice), the progress block describes what is
+ * happening, and the settled line reports how the last run ended — which is the one
+ * beat the drift counts can never carry, because a component that lands clears its
+ * update and its in-progress mark in the same write and simply disappears.
*/
export function InfraFleetCard({
counts,
scanning,
applying,
+ active,
+ outcome,
onScan,
onApply,
+ onViewLogs,
}: {
- counts: { attention: number; updates: number; healthy: number; stopped: number; behind: number };
+ counts: {
+ attention: number;
+ updates: number;
+ healthy: number;
+ stopped: number;
+ behind: number;
+ applying: number;
+ };
scanning: boolean;
applying: ContainerApplyIntent | null;
+ /** Components mid-apply, queued ones included. Empty when the fleet is idle. */
+ active: ContainerApplyActive[];
+ /** How the run that just finished ended, for a few seconds after it did. */
+ outcome: ApplyOutcome | null;
onScan: () => void;
onApply: (intent: ContainerApplyIntent) => void;
+ /** Open the live log for one in-flight component (re-attaches, never restarts). */
+ onViewLogs?: (target: ContainerApplyActive) => void;
}) {
const { t } = useI18n();
const c = t.servers.list.infra;
+ const inFlight = active.length > 0;
+ // Counts exclude in-flight work, so "nothing left to report" can be true while a
+ // swap is still running — the progress block is what speaks then.
const clean = counts.attention === 0 && counts.updates === 0;
+ const canAct = counts.behind > 0 || counts.stopped > 0;
+ const busy = applying !== null || inFlight;
return (
@@ -73,81 +105,213 @@ export function InfraFleetCard({
- {clean ? (
+ {!clean && (
+
+ {counts.attention > 0 && (
+
+ {interpolate(counts.attention === 1 ? c.attentionOne : c.attentionMany, {
+ n: String(counts.attention),
+ })}
+
+ )}
+ {counts.attention > 0 && counts.updates > 0 && (
+ ·
+ )}
+ {counts.updates > 0 && (
+
+ {interpolate(c.updates, { n: String(counts.updates) })}
+
+ )}
+ {counts.healthy > 0 && (
+ <>
+ ·
+
+ {interpolate(c.healthy, { n: String(counts.healthy) })}
+
+ >
+ )}
+
+ )}
+
+ {inFlight &&
}
+
+ {/* The settled beat, once nothing is left running. */}
+ {!inFlight && outcome && (
+
+ {outcome.done > 0 && (
+
+
+ {interpolate(outcome.done === 1 ? c.doneOne : c.doneMany, {
+ n: String(outcome.done),
+ })}
+
+ )}
+ {outcome.failed > 0 && (
+ <>
+
+
+ {interpolate(outcome.failed === 1 ? c.failedOne : c.failedMany, {
+ n: String(outcome.failed),
+ })}
+
+ {outcome.error && (
+
{outcome.error}
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Nothing to report and nothing happening — the resting state. */}
+ {clean && !inFlight && !outcome && (
{c.allHealthy}
- ) : (
- <>
-
- {counts.attention > 0 && (
-
- {interpolate(c.attention, { n: String(counts.attention) })}
-
- )}
- {counts.attention > 0 && counts.updates > 0 && (
- ·
- )}
- {counts.updates > 0 && (
-
- {interpolate(c.updates, { n: String(counts.updates) })}
-
- )}
- {counts.healthy > 0 && (
- <>
- ·
-
- {interpolate(c.healthy, { n: String(counts.healthy) })}
-
- >
- )}
-
-
- {(counts.behind > 0 || counts.stopped > 0) && (
-
- {counts.behind > 0 && (
-
onApply("update")}
- disabled={applying !== null}
- className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-warning-bg px-3 py-2 text-[12.5px] font-medium text-warning transition-colors hover:bg-warning/20 disabled:opacity-50"
- >
- {applying === "update" ? (
-
- ) : (
-
- )}
- {interpolate(c.updateAll, { n: String(counts.behind) })}
-
+ )}
+
+ {canAct && (
+
+ {counts.behind > 0 && (
+
onApply("update")}
+ disabled={busy}
+ className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-warning-bg px-3 py-2 text-[12.5px] font-medium text-warning transition-colors hover:bg-warning/20 disabled:opacity-50"
+ >
+ {applying === "update" ? (
+
+ ) : (
+
)}
- {counts.stopped > 0 && (
- onApply("repair")}
- disabled={applying !== null}
- className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-danger-bg px-3 py-2 text-[12.5px] font-medium text-danger transition-colors hover:bg-danger/20 disabled:opacity-50"
- >
- {applying === "repair" ? (
-
- ) : (
-
- )}
- {interpolate(c.restartStopped, { n: String(counts.stopped) })}
-
+ {interpolate(c.updateAll, { n: String(counts.behind) })}
+
+ )}
+ {counts.stopped > 0 && (
+
onApply("repair")}
+ disabled={busy}
+ className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-danger-bg px-3 py-2 text-[12.5px] font-medium text-danger transition-colors hover:bg-danger/20 disabled:opacity-50"
+ >
+ {applying === "repair" ? (
+
+ ) : (
+
)}
-
+ {interpolate(c.restartStopped, { n: String(counts.stopped) })}
+
)}
+
+ )}
- {/* Attention with nothing bulk-safe left (absent edge, gone container):
- the fix is on the server's own page, so say so instead of offering
- a button that would skip everything. */}
- {counts.attention > 0 && counts.behind === 0 && counts.stopped === 0 && (
-
{c.openServerHint}
- )}
- >
+ {/* Attention with nothing bulk-safe left (absent edge, gone container):
+ the fix is on the server's own page, so say so instead of offering
+ a button that would skip everything. */}
+ {counts.attention > 0 && !canAct && !inFlight && (
+
{c.openServerHint}
)}
);
}
+
+/**
+ * The live block: one headline for the whole run, a bar, and a line per component.
+ *
+ * Progress is the bar's WIDTH; the sweep only says "still moving", so a long image
+ * pull doesn't read as a hung bar. "View logs" re-attaches to a running session
+ * rather than starting anything — it is offered only for a component that has one
+ * (a queued target has nothing to show yet).
+ */
+function ApplyingBlock({
+ active,
+ onViewLogs,
+}: {
+ active: ContainerApplyActive[];
+ onViewLogs?: (target: ContainerApplyActive) => void;
+}) {
+ const { t } = useI18n();
+ const c = t.servers.list.infra;
+ const percent = applyPercent(active);
+ const restart = applyIntentOf(active) === "repair";
+ const head = restart
+ ? active.length === 1
+ ? c.restartingOne
+ : c.restartingMany
+ : active.length === 1
+ ? c.applyingOne
+ : c.applyingMany;
+ const phaseLabel: Record, string> = {
+ queued: c.stateQueued,
+ starting: t.servers.containers.starting,
+ pull: c.stepPull,
+ recreate: c.stepRecreate,
+ verify: c.stepVerify,
+ };
+ const shown = active.slice(0, TARGET_LINES);
+ const hidden = active.length - shown.length;
+ const watchable = active.find((target) => target.sessionId);
+
+ return (
+
+
+
+
+ {interpolate(head, { n: String(active.length) })}
+
+
+ {percent}%
+
+
+
+
+
+
+ {shown.map((target) => (
+
+
+
+ {target.component === "mail"
+ ? t.servers.containers.componentMail
+ : t.servers.containers.componentEdge}
+ {" · "}
+ {target.serverName}
+
+ {phaseLabel[applyPhase(target)]}
+
+ ))}
+ {hidden > 0 && (
+
+ {interpolate(c.moreTargets, { n: String(hidden) })}
+
+ )}
+
+
+ {watchable && onViewLogs && (
+
onViewLogs(watchable)}
+ className="mt-2.5 inline-flex items-center gap-1.5 rounded-lg px-2 py-1 -mx-2 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ >
+
+ {c.viewLogs}
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx b/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx
index 20ace65ed..969ddaba0 100644
--- a/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx
+++ b/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx
@@ -2200,7 +2200,9 @@ export function ServerMigrationWizard({
{m.wizard.targetLabel}
- setTargetId(s?.id ?? null)} compact />
+ {/* dropUp: this card is `overflow-hidden` and the picker sits at its
+ bottom, so a down-opening menu is hard-clipped. */}
+ setTargetId(s?.id ?? null)} compact dropUp />
setKillOriginals(e.target.checked)} className="size-4 rounded border-border" />
{m.wizard.killOriginals}
@@ -3123,15 +3125,18 @@ function ServiceConfigCard({
const envRecord = envOverride ?? service.env;
const envRows = useMemo(() => envToRows(envRecord), [envRecord]);
// On-demand reveal: the scan masks env, so the eye / "Show values" fetches the
- // real values for THIS container from the source server. Only wired when there's
- // a running container to read (repo-only `isNew` cards have no server-side env).
+ // real values for the opened keys of THIS container from the source server. Only
+ // wired when there's a running container to read (repo-only `isNew` cards have no
+ // server-side env).
const containerId = service.containerId;
- const onRevealAll = useMemo(() => {
+ const onReveal = useMemo(() => {
if (!sourceServerId || !containerId) return undefined;
const serverId = sourceServerId;
const cid = containerId;
- return () =>
- dockerMigrationApi.revealEnv({ serverId, containerId: cid }).then((r) => r.environment);
+ return (keys: string[]) =>
+ dockerMigrationApi
+ .revealEnv({ serverId, containerId: cid, keys })
+ .then((r) => r.environment);
}, [sourceServerId, containerId]);
// Image-supplied vars not yet pinned as config — importing them adds them to the
// override, which empties this list and bumps the env count.
@@ -3416,7 +3421,7 @@ function ServiceConfigCard({
borderless
envVars={envRows}
onEnvVarsChange={(rows) => onSetEnv(rowsToEnv(rows))}
- onRevealAll={onRevealAll}
+ onReveal={onReveal}
/>
diff --git a/apps/dashboard/src/components/servers/server-form.render.test.tsx b/apps/dashboard/src/components/servers/server-form.render.test.tsx
index 6b2612a2e..bcfc8cb6b 100644
--- a/apps/dashboard/src/components/servers/server-form.render.test.tsx
+++ b/apps/dashboard/src/components/servers/server-form.render.test.tsx
@@ -3,15 +3,23 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/components/i18n-provider";
+import { PlatformProvider } from "@/context/PlatformContext";
import { ServerForm } from "./server-form";
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ showToast: () => {} }) }));
-function render(props: Partial> = {}) {
+/** `deployMode` defaults to "docker" — the compose/VPS install, where the API is a
+ * container and a host key path resolves inside it. Pass "desktop" for the shell. */
+function render(
+ props: Partial> = {},
+ deployMode = "docker",
+) {
return renderToStaticMarkup(
-
- {}} {...props} />
- ,
+
+
+ {}} {...props} />
+
+ ,
);
}
@@ -121,8 +129,6 @@ describe("ServerForm variants", () => {
const html = render({ server });
const out = text(html);
- expect(out).toContain("Paste / Upload");
- expect(out).toContain("Host path");
expect(out).toContain("Upload key file");
expect(out).toContain("Pasted keys are encrypted at rest and never leave the server.");
// The paste textarea (placeholder lives inside the tag, so check raw html),
@@ -131,6 +137,59 @@ describe("ServerForm variants", () => {
expect(placeholders(html)).not.toContain("/root/.ssh/id_ed25519");
});
+ /**
+ * A host path is read by `readFileSync` inside the API process. On a compose
+ * install that process is a container that mounts no ~/.ssh, so a path an
+ * operator can `cat` on their VPS resolves to nothing and the save fails as
+ * "Invalid auth configuration". The option is therefore desktop-only — the one
+ * mode where the browser and the API share a filesystem.
+ */
+ describe("host-path mode is offered only where a path can resolve", () => {
+ const keyAuth = {
+ id: "s1",
+ sshHost: "10.0.0.1",
+ sshAuthMethod: "key",
+ } as unknown as React.ComponentProps["server"];
+
+ it("offers no host-path option on a VPS install", () => {
+ for (const mode of ["docker", "bare"]) {
+ const out = text(render({ server: keyAuth }, mode));
+ expect(out, mode).not.toContain("Host path");
+ // Paste/upload is the whole control now, so the toggle's own label goes
+ // with it — the textarea and its upload button are what remain.
+ expect(out, mode).not.toContain("Paste / Upload");
+ expect(out, mode).toContain("Upload key file");
+ }
+ });
+
+ it("offers both sub-modes in the desktop shell", () => {
+ const out = text(render({ server: keyAuth }, "desktop"));
+ expect(out).toContain("Paste / Upload");
+ expect(out).toContain("Host path");
+ expect(out).toContain("Upload key file");
+ });
+
+ /**
+ * Grandfathering, and it is load-bearing: hiding the toggle for a row that
+ * already stores a path would drop it into paste mode, where a save sends
+ * `sshKeyPath: null` with no material to replace it. Renaming such a server
+ * would strip the only credential it has.
+ */
+ it("keeps the toggle for a row that already stores a path, on any install", () => {
+ const stored = {
+ ...keyAuth,
+ sshKeyPath: "/root/.ssh/id_ed25519",
+ } as unknown as React.ComponentProps["server"];
+
+ for (const mode of ["docker", "bare", "desktop"]) {
+ const html = render({ server: stored }, mode);
+ expect(text(html), mode).toContain("Host path");
+ // And it opens ON the path, showing the operator what is stored.
+ expect(placeholders(html), mode).toContain("/root/.ssh/id_ed25519");
+ }
+ });
+ });
+
/**
* The API never returns key material — only a boolean that one is stored. Edit
* mode must say so and let a blank save keep it, or reopening a server and saving
diff --git a/apps/dashboard/src/components/servers/server-form.tsx b/apps/dashboard/src/components/servers/server-form.tsx
index ab3e0f79a..b00eb179d 100644
--- a/apps/dashboard/src/components/servers/server-form.tsx
+++ b/apps/dashboard/src/components/servers/server-form.tsx
@@ -20,6 +20,7 @@ import { getApiErrorMessage, systemApi } from "@/lib/api";
import type { ServerInfo, SshProbeInput } from "@/lib/api/system";
import { useToast } from "@/context/ToastContext";
import { useI18n } from "@/components/i18n-provider";
+import { usePlatform } from "@/context/PlatformContext";
const INPUT =
"w-full px-3.5 py-2.5 rounded-xl border border-border/50 bg-muted/30 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none transition-all focus:ring-2 focus:ring-primary/20";
@@ -57,6 +58,7 @@ export function ServerForm({
}: ServerFormProps) {
const { showToast } = useToast();
const { t } = useI18n();
+ const { deployMode } = usePlatform();
const isEditing = !!server;
const [saving, setSaving] = useState(false);
@@ -87,6 +89,26 @@ export function ServerForm({
const [sshKeyMode, setSshKeyMode] = useState<"paste" | "path">(
server?.sshKeyPath ? "path" : "paste",
);
+ /**
+ * Is a host path even a coherent answer here?
+ *
+ * Only where the browser and the API read the same filesystem — the desktop
+ * shell. Everywhere else the path is resolved by `readFileSync` inside the API
+ * process (apps/api/src/lib/ssh-manager.ts), and on a compose install that
+ * process is a container whose volume list carries the docker socket, the edge
+ * tree and the host-channel key — no `~/.ssh` (apps/cli/src/lib/compose.ts).
+ * So `/root/.ssh/id_ed25519` typed on a VPS resolves in the container, finds
+ * nothing, and buildSshConfig returns null: "Invalid auth configuration" for a
+ * key the operator can `cat` on the host.
+ *
+ * A row that ALREADY stores a path keeps the toggle regardless of mode. Hiding
+ * it would drop such a row into paste mode, where a save sends
+ * `sshKeyPath: null` with no material to replace it — a rename would silently
+ * strip the only credential the server has.
+ */
+ const pathModeOffered = deployMode === "desktop" || !!server?.sshKeyPath;
+ /** The mode actually in force — `sshKeyMode` is only meaningful when offered. */
+ const keyMode: "paste" | "path" = pathModeOffered ? sshKeyMode : "paste";
// Whether the server already has an encrypted pasted key stored. Lets edit mode
// say "a key is stored — paste to replace" and skip the require-a-key check.
const hasStoredKey = !!server?.hasStoredKeyMaterial;
@@ -156,7 +178,7 @@ export function ServerForm({
}
if (sshAuthMethod === "key") {
- if (sshKeyMode === "paste") {
+ if (keyMode === "paste") {
// Pasted material is required unless we're editing a server that already
// has a key stored (blank = keep the stored one).
if (!sshPrivateKey.trim() && !hasStoredKey) {
@@ -186,7 +208,7 @@ export function ServerForm({
data.sshPassword = sshPassword;
}
if (sshAuthMethod === "key") {
- if (sshKeyMode === "paste") {
+ if (keyMode === "paste") {
// Only send material when the user actually typed/uploaded one — an
// empty value would WIPE the stored key. Clear any stale host path.
if (sshPrivateKey.trim()) data.sshPrivateKey = sshPrivateKey;
@@ -228,7 +250,7 @@ export function ServerForm({
}
if (sshAuthMethod === "key") {
- if (sshKeyMode === "paste") {
+ if (keyMode === "paste") {
// A stored key can't be tested — the client never receives it — so a
// paste-mode test always needs freshly entered material.
if (!sshPrivateKey.trim()) {
@@ -254,7 +276,7 @@ export function ServerForm({
payload.sshPassword = sshPassword;
}
if (sshAuthMethod === "key") {
- if (sshKeyMode === "paste") {
+ if (keyMode === "paste") {
if (sshPrivateKey.trim()) payload.sshPrivateKey = sshPrivateKey;
} else if (sshKeyPath) {
payload.sshKeyPath = sshKeyPath;
@@ -502,37 +524,41 @@ export function ServerForm({
) : (
- {/* Sub-mode: paste/upload the key in the browser (works on a
- remote instance where the key lives on the operator's laptop,
- not the API host) vs. a path to a file on the API host. */}
-
- setSshKeyMode("paste")}
- className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium rounded-lg transition-all ${
- sshKeyMode === "paste"
- ? "bg-card text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground/70"
- }`}
- >
-
- {t.servers.form.keyModePaste}
-
- setSshKeyMode("path")}
- className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium rounded-lg transition-all ${
- sshKeyMode === "path"
- ? "bg-card text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground/70"
- }`}
- >
-
- {t.servers.form.keyModePath}
-
-
+ {/* Sub-mode: paste/upload the key in the browser vs. a path to a
+ file on the API host. Only rendered where a path can resolve —
+ see `pathModeOffered`. With one option there is no choice to
+ present, so paste/upload stands alone rather than as a lone
+ segment in a segmented control. */}
+ {pathModeOffered && (
+
+ setSshKeyMode("paste")}
+ className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium rounded-lg transition-all ${
+ keyMode === "paste"
+ ? "bg-card text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground/70"
+ }`}
+ >
+
+ {t.servers.form.keyModePaste}
+
+ setSshKeyMode("path")}
+ className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium rounded-lg transition-all ${
+ keyMode === "path"
+ ? "bg-card text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground/70"
+ }`}
+ >
+
+ {t.servers.form.keyModePath}
+
+
+ )}
- {sshKeyMode === "paste" ? (
+ {keyMode === "paste" ? (
{t.servers.form.keyPaste}
);
}
diff --git a/apps/dashboard/src/context/ToastContext.tsx b/apps/dashboard/src/context/ToastContext.tsx
index 862eec610..5993e4532 100644
--- a/apps/dashboard/src/context/ToastContext.tsx
+++ b/apps/dashboard/src/context/ToastContext.tsx
@@ -15,13 +15,13 @@ import React from 'react';
import { useToast as useGlassyToast } from '@/components/toast';
interface ToastContextProps {
- showToast: (message: string, type: 'success' | 'error', title?: string) => void;
+ showToast: (message: string, type: 'success' | 'error' | 'info', title?: string) => void;
}
export const useToast = (): ToastContextProps => {
const { toast } = useGlassyToast();
const showToast = React.useCallback(
- (message: string, type: 'success' | 'error', title?: string) => toast(type, message, title),
+ (message: string, type: 'success' | 'error' | 'info', title?: string) => toast(type, message, title),
[toast],
);
return React.useMemo(() => ({ showToast }), [showToast]);
diff --git a/apps/dashboard/src/hooks/useInfraFleet.ts b/apps/dashboard/src/hooks/useInfraFleet.ts
index 4254ddbfa..a5d6ecf13 100644
--- a/apps/dashboard/src/hooks/useInfraFleet.ts
+++ b/apps/dashboard/src/hooks/useInfraFleet.ts
@@ -2,33 +2,50 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { systemApi, type ServerContainerGroup } from "@/lib/api/system";
+import { systemApi, type ContainerApplyActive, type ServerContainerGroup } from "@/lib/api/system";
import type { ContainerApplyIntent } from "@/lib/api/system";
+import { applyKey, summarizeSettled, type ApplyOutcome } from "@/lib/infra-apply-status";
+import { summarizeInfraFleet, type InfraBucket } from "@/lib/infra-fleet-state";
import { infraScanStale, markInfraScanned } from "@/lib/infra-autoscan";
-/** What one server's managed containers add up to, for chips and filtering. */
-export interface InfraServerSummary {
- /** Down components that still exist (a one-click restart), by component. */
- down: ("edge" | "mail")[];
- /** Components running an older image than we pin. */
- updates: number;
- /** Down but gone — the fix is the setup path, so it's not bulk-restartable. */
- missing: ("edge" | "mail")[];
- /** No edge row at all AND projects deployed here → the edge needs installing. */
- edgeAbsent: boolean;
-}
+export type { InfraBucket, InfraServerSummary } from "@/lib/infra-fleet-state";
+export type InfraSegment = "all" | InfraBucket;
+
+/** How long the settled line stays up before the card returns to its resting state. */
+const OUTCOME_MS = 8000;
+/** Progress cadence while something is in flight — a step at a time, not a spinner. */
+const POLL_MS = 2500;
+/**
+ * Polls in a row where the cache claims work is in flight but the API reports NOTHING
+ * running (~1 min). That combination is the stale-flag case — a control plane that
+ * died mid-swap leaves the flag set, and its session died with it. It is not the slow
+ * case: a queued target always has a running sibling, and a long image pull reports
+ * itself as running for as long as it takes, so neither trips this.
+ */
+const STALL_BUDGET = 24;
+/** Absolute ceiling (~20 min) so even a wedged SSH session stops being polled. */
+const WATCH_BUDGET = 480;
-export type InfraSegment = "all" | "attention" | "updates" | "healthy";
+const keysOf = (active: ContainerApplyActive[]): Set
=>
+ new Set(active.map((t) => applyKey(t.serverId, t.component)));
-/** 5 s × 60 ≈ 5 min of settle-watching per apply — an image swap is far quicker. */
-const POLL_BUDGET = 60;
+const sameKeys = (a: Set, b: Set): boolean =>
+ a.size === b.size && [...a].every((k) => b.has(k));
/**
* Fleet-wide managed-container state for the Servers tab: the cached grouped view,
- * a per-server summary, the counts the header/segments render, and the two bulk
- * actions. Detect (Scan) and apply (Update all / Restart stopped) both go through
- * the same endpoints the per-server rows use, so nothing here re-implements a fix —
- * an apply started in bulk is the same replayable session a row click re-attaches to.
+ * a per-server summary, the counts the header/segments render, live apply progress,
+ * and the two bulk actions. Detect (Scan) and apply (Update all / Restart stopped)
+ * both go through the same endpoints the per-server rows use, so nothing here
+ * re-implements a fix — an apply started in bulk is the same replayable session a
+ * row click re-attaches to.
+ *
+ * The in-flight half is why this hook holds two reads. The cached rows say WHICH
+ * components were accepted (queued targets included, since the bulk endpoint flags
+ * them before it answers); `/containers/applying` says how far the running ones have
+ * got and how the finished ones ended. A row alone can't tell a caller that a swap
+ * succeeded: it clears its drift and its in-progress flag in the same write, so
+ * watching rows only ever shows work disappear.
*
* `enabled` is the self-hosted/desktop gate: on cloud these endpoints are
* `assertNotCloud`, so we never call them.
@@ -37,13 +54,35 @@ export function useInfraFleet(enabled: boolean) {
const [groups, setGroups] = useState(null);
const [scanning, setScanning] = useState(false);
const [applying, setApplying] = useState(null);
+ const [active, setActive] = useState([]);
+ const [outcome, setOutcome] = useState(null);
+ /** Every (server, component) seen in flight since the last idle — the outcome is
+ * reported only for these, so a surface opened mid-run never announces a finish
+ * it didn't watch. */
+ const watched = useRef>(new Set());
+ const activeKeys = useRef>(new Set());
+ /** Consecutive polls with a flag set but nothing running (see STALL_BUDGET). */
+ const stallTicks = useRef(0);
+ /** Polls since this watch began (see WATCH_BUDGET). */
+ const watchTicks = useRef(0);
+ const [pollTick, setPollTick] = useState(0);
+ const alive = useRef(true);
+ useEffect(() => {
+ alive.current = true;
+ return () => {
+ alive.current = false;
+ };
+ }, []);
- const load = useCallback(async () => {
- if (!enabled) return;
+ const load = useCallback(async (): Promise => {
+ if (!enabled) return [];
try {
- setGroups(await systemApi.listAllContainers());
+ const fresh = await systemApi.listAllContainers();
+ if (alive.current) setGroups(fresh);
+ return fresh;
} catch {
- setGroups([]); // cloud / read error → the card simply doesn't render
+ if (alive.current) setGroups([]); // cloud / read error → the card simply doesn't render
+ return [];
}
}, [enabled]);
@@ -90,82 +129,97 @@ export function useInfraFleet(enabled: boolean) {
};
}, [enabled, load]);
- // While any apply is in flight (started here or from a server's own page), re-read
- // the cached view every few seconds so the counts settle on their own. Chains off
- // `groups`, so it stops as soon as nothing is in progress — no interval to clear.
- //
- // Bounded, because `latestInProgress` is cleared by the apply's own `finally`: if
- // the API restarts mid-swap the flag is stuck true forever, and an unbounded chain
- // would poll an open tab for the rest of the session. After the budget the counts
- // just need a manual Scan.
- const polls = useRef(0);
- const inFlight = (groups ?? []).some((g) => g.components.some((r) => r.latestInProgress));
+ /** Components the cache says are mid-apply — the durable half of "in flight". */
+ const flagged = useMemo(
+ () =>
+ new Set(
+ (groups ?? []).flatMap((g) =>
+ g.components.filter((r) => r.latestInProgress).map((r) => applyKey(g.server.id, r.component)),
+ ),
+ ),
+ [groups],
+ );
+ /** The same set as reported by the progress read — covers a run whose row is gone. */
+ const live = useMemo(() => keysOf(active), [active]);
+ const busy = flagged.size > 0 || live.size > 0;
+
+ /**
+ * One progress read. Anything that changed the in-flight SET also re-reads the
+ * rows, so the counts settle from the same beat that moved the status — and a
+ * drained set produces the outcome line before the polling stops.
+ */
+ const poll = useCallback(async () => {
+ watchTicks.current += 1;
+ const next = await systemApi.applyingContainers().catch(() => null);
+ if (next && alive.current) {
+ // A session anywhere means the control plane is alive and doing the work, so
+ // only its absence counts toward the stall bound.
+ stallTicks.current = next.active.some((t) => t.state === "running")
+ ? 0
+ : stallTicks.current + 1;
+ const nextKeys = keysOf(next.active);
+ for (const k of nextKeys) watched.current.add(k);
+ const moved = !sameKeys(activeKeys.current, nextKeys);
+ activeKeys.current = nextKeys;
+ setActive(next.active);
+ if (moved) {
+ await load(); // the set changed — let the counts follow the same beat
+ if (nextKeys.size === 0) {
+ const settled = summarizeSettled(next.recent, watched.current);
+ watched.current = new Set();
+ if (settled && alive.current) setOutcome(settled);
+ }
+ }
+ }
+ if (alive.current) setPollTick((n) => n + 1);
+ }, [load]);
+
+ // Chained (never overlapping) while anything is in flight, driven by `pollTick` so
+ // a failed request still schedules the next attempt. Only the opening tick of a
+ // watch is immediate — that's the one that turns a just-accepted bulk into a
+ // visible queue; everything after it is spaced, including the ticks that follow a
+ // transition, so a fast-moving set can't turn into a request loop.
useEffect(() => {
- if (!inFlight) polls.current = 0;
- }, [inFlight]);
+ if (!enabled || !busy) return;
+ if (stallTicks.current >= STALL_BUDGET || watchTicks.current >= WATCH_BUDGET) return;
+ const opening = watchTicks.current === 0 && live.size === 0;
+ const timer = setTimeout(() => void poll(), opening ? 0 : POLL_MS);
+ return () => clearTimeout(timer);
+ }, [enabled, busy, live.size, pollTick, poll]);
+
+ // Idle again → forget the last run's progress so a later apply starts clean.
+ useEffect(() => {
+ if (busy) return;
+ stallTicks.current = 0;
+ watchTicks.current = 0;
+ activeKeys.current = new Set();
+ }, [busy]);
+
useEffect(() => {
- if (!enabled || !inFlight || polls.current >= POLL_BUDGET) return;
- const timer = setTimeout(() => {
- polls.current += 1;
- void load();
- }, 5000);
+ if (!outcome) return;
+ const timer = setTimeout(() => setOutcome(null), OUTCOME_MS);
return () => clearTimeout(timer);
- }, [enabled, inFlight, groups, load]);
-
- /** Per-server summary keyed by server id. */
- const summaries = useMemo(() => {
- const map = new Map();
- for (const g of groups ?? []) {
- const down: ("edge" | "mail")[] = [];
- const missing: ("edge" | "mail")[] = [];
- let updates = 0;
- for (const r of g.components) {
- if (r.behind) updates++;
- else if (r.detail?.down) (r.detail.containerMissing ? missing : down).push(r.component);
- }
- map.set(g.server.id, {
- down,
- updates,
- missing,
- edgeAbsent: g.server.projectCount > 0 && !g.components.some((r) => r.component === "edge"),
- });
- }
- return map;
- }, [groups]);
-
- const counts = useMemo(() => {
- let attention = 0;
- let updates = 0;
- let healthy = 0;
- /** Bulk-restartable components (stopped in place), fleet-wide. */
- let stopped = 0;
- /** Behind components, fleet-wide — what "Update all (N)" acts on. */
- let behind = 0;
- for (const s of summaries.values()) {
- const needs = s.down.length + s.missing.length + (s.edgeAbsent ? 1 : 0);
- if (needs > 0) attention++;
- else if (s.updates > 0) updates++;
- else healthy++;
- stopped += s.down.length;
- behind += s.updates;
- }
- return { attention, updates, healthy, stopped, behind };
- }, [summaries]);
+ }, [outcome]);
+
+ /** Per-server summary + the fleet counts, from the one shared derivation. */
+ const { summaries, counts } = useMemo(() => summarizeInfraFleet(groups, live), [groups, live]);
/**
* Bulk apply. Targets are derived server-side from the cache — we only say which
* classes to act on — then the view is re-read so the rows flip to "Updating…".
+ * The re-read is in the `finally`: a POST that aborts or 5xxs may still have
+ * started work, and leaving the card idle over running applies is the worse lie.
*/
const applyAll = useCallback(
async (intent: ContainerApplyIntent) => {
if (!enabled) return;
setApplying(intent);
+ setOutcome(null);
try {
- const res = await systemApi.applyAllContainers([intent]);
- await load();
- return res;
+ return await systemApi.applyAllContainers([intent]);
} finally {
setApplying(null);
+ await load();
}
},
[enabled, load],
@@ -177,6 +231,10 @@ export function useInfraFleet(enabled: boolean) {
counts,
scanning,
applying,
+ /** Live per-component progress for everything in flight (queued + running). */
+ active,
+ /** The just-settled beat, or null. Cleared automatically. */
+ outcome,
scan,
applyAll,
reload: load,
diff --git a/apps/dashboard/src/i18n/i18n-parity.test.ts b/apps/dashboard/src/i18n/i18n-parity.test.ts
index 2c4593671..679d51b9c 100644
--- a/apps/dashboard/src/i18n/i18n-parity.test.ts
+++ b/apps/dashboard/src/i18n/i18n-parity.test.ts
@@ -43,7 +43,12 @@ const MISSING_BASELINE: Record = {
// to every send-only relay, not just SES). Translated in tr, which is the only
// other locale with an appInstall block; the other 7 fall back to English via
// deepMerge, so the wizard renders correctly everywhere.
- projectSettings: 1282,
+ //
+ // +35: appInstall's live progress readout — 5 keys for the aside that replaced
+ // the status dot (the "step N of M" counter, the per-service tally, and the three
+ // labels of the chosen-configuration card). Translated in tr, English-first in
+ // the other 7 via deepMerge, same as every other key in this block.
+ projectSettings: 1317,
jobs: 876,
// +15: discover.envFromImage/Hint/Import — the collapsed "vars come from the
// image" row and its one-click import. Translated in ar/fr/tr; the other 5
@@ -89,7 +94,12 @@ const MISSING_BASELINE: Record = {
// confirmation, and a mistranslated security prompt is worse than an untranslated
// one. The other 8 locales fall back via deepMerge, so the panel renders
// correctly everywhere.
- settings: 1392,
+ // +64: MCP call tracking — 8 net-new English keys × 8 locales: the per-credential
+ // call counters on the MCP connection and PAT rows (callsOne/callsMany in both
+ // blocks), the connection's Activity link into its own audit feed, and the audit
+ // tab's agent filter + the "Agent" detail row that names WHICH assistant a row
+ // came from. English-first via deepMerge like the access-editor block above.
+ settings: 1456,
// +164: the Sending tab's rebuild — 21 net-new English keys (the direct-vs-relay
// path picker and its switch-back action, the "which senders relay?" card with the
// individual-sender editor, the manual SPF include for providers whose token is
@@ -160,7 +170,16 @@ const MISSING_BASELINE: Record = {
// new paste-or-upload SSH-key sub-mode (a browser can now hold the key material
// instead of pointing at a file on the API host). English-first in the other 8
// locales via deepMerge, same as the rest of this form's block.
- servers: 281,
+ //
+ // +24: banner.hostChannelAuthTitle/hostChannelAuthBody/hostChannelReauthorizeFix —
+ // 3 keys for the host channel that ANSWERS and then refuses the key (#527), which
+ // previously rendered through the generic "SSH credentials rejected" card. Kept
+ // English-first deliberately rather than machine-translated: every other
+ // `hostChannel*` key in this block is already English-only in all 8 locales, so
+ // translating only these three would render a banner whose title was localized above
+ // an English impact paragraph. They fall back via deepMerge, so the card is correct
+ // and internally consistent in every locale.
+ servers: 305,
importProject: 81,
onboarding: 60,
// +16: setup.startFailed / startFailedFallback — the mail wizard had no way to
@@ -203,4 +222,74 @@ describe("i18n locale parity vs the English source", () => {
it("introduces no NEW stale (extra) locale keys beyond the baseline", () => {
expect(report.totalExtra).toBeLessThanOrEqual(EXTRA_BASELINE);
});
+
+ /**
+ * The check the two above CANNOT make.
+ *
+ * Both of them reason about key PRESENCE. A key that exists in all nine locales
+ * while still holding the English sentence is invisible to them — the locale is
+ * "complete", the suite is green, and the UI renders English. That is not
+ * hypothetical: `verifyEmail.code*` sat like that in 7 of 8 locales, and when the
+ * password-reset flow started rendering those same strings, the reset form shipped
+ * English labels and English error text to every non-English user while every test
+ * passed. Worse, `pendingSentTo` kept telling people to click a verification link
+ * for months after the email stopped containing one.
+ *
+ * So this ratchets on VALUES: a locale string byte-identical to English, restricted
+ * to prose (multi-word — see `looksTranslatable`) so that Email/DNS/GitHub/OK and
+ * `you@example.com` don't drown the signal. Same contract as MISSING_BASELINE:
+ * freeze today's backlog per namespace, fail only when it GROWS, and lower the
+ * number as things get translated.
+ *
+ * A namespace absent from this map must stay at zero.
+ */
+ const UNTRANSLATED_BASELINE: Record = {
+ deploy: 133,
+ settings: 99,
+ projectSettings: 48,
+ importProject: 43,
+ billing: 28,
+ onboarding: 24,
+ projectDetail: 19,
+ library: 18,
+ misc: 18,
+ projects: 17,
+ deployments: 14,
+ emailsAdmin: 12,
+ overview: 12,
+ chrome: 10,
+ widgets: 9,
+ dashboard: 6,
+ emails: 6,
+ servers: 6,
+ migration: 3,
+ jobs: 2,
+ // `auth` is deliberately absent, i.e. pinned at 0: it was brought to zero when
+ // password reset moved from a link to a code, and it is the flow where an
+ // English-rendering string is most costly — somebody locked out of their account
+ // reading instructions they cannot follow.
+ };
+
+ it("introduces no NEW untranslated values (present, but still the English string)", () => {
+ const regressions: string[] = [];
+ const namespaces = new Set([
+ ...Object.keys(UNTRANSLATED_BASELINE),
+ ...Object.keys(report.byNamespaceUntranslated),
+ ]);
+ for (const ns of namespaces) {
+ const actual = report.byNamespaceUntranslated[ns] ?? 0;
+ const allowed = UNTRANSLATED_BASELINE[ns] ?? 0;
+ if (actual > allowed) {
+ regressions.push(`${ns}: ${actual} untranslated (baseline ${allowed})`);
+ }
+ }
+ expect(
+ regressions,
+ "A locale now copies English verbatim where it did not before — the UI will " +
+ "render English there while key parity still looks clean. Translate the " +
+ "values (run `bun run i18n:check --full`), or if you translated some, lower " +
+ "UNTRANSLATED_BASELINE.\n" +
+ regressions.join("\n"),
+ ).toEqual([]);
+ });
});
diff --git a/apps/dashboard/src/i18n/locales/ar/deploy.json b/apps/dashboard/src/i18n/locales/ar/deploy.json
index f9b0c6da5..8c89cbeee 100644
--- a/apps/dashboard/src/i18n/locales/ar/deploy.json
+++ b/apps/dashboard/src/i18n/locales/ar/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "غير متاح",
"domainTitle": "النطاق",
"domainHintProxy": "يملك خادم بريدك بالفعل اسم المضيف هذا - سنوجّهه عبر خادم البريد الافتراضي (VPS) الخاص بك إلى حِمل عمل Opshcloud. لا حاجة لأي تغييرات في DNS.",
+ "domainHintMailHost": "اسم المضيف الخاص بخادم البريد نفسه - فهو يشير إلى هنا بالفعل ولديه شهادة بالفعل، لذا لا يوجد ما يجب إعداده. استخدم webmail.<نطاقك> بدلاً من ذلك إن كنت تفضّل الفصل بينهما.",
"domainHintCloud": "وجّه سجل CNAME إلى رابط *.opsh.io الذي نوفّره (ستراه بعد النشر).",
"domainHintDefault": "الرابط الذي سيزوره المشغّلون. يجب أن يشير DNS إلى وجهة النشر.",
"summary": "الملخص",
diff --git a/apps/dashboard/src/i18n/locales/ar/emailsAdmin.json b/apps/dashboard/src/i18n/locales/ar/emailsAdmin.json
index ab816ad0f..4f88e52d4 100644
--- a/apps/dashboard/src/i18n/locales/ar/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/ar/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "نظرة عامة",
"domains": "النطاقات",
"mailboxes": "صناديق البريد",
+ "inbound": "الوارد",
"dns": "DNS",
"health": "الصحة",
"test": "اختبار",
@@ -12,6 +13,56 @@
},
"ariaLabel": "أقسام إدارة البريد"
},
+ "inbound": {
+ "heading": "قواعد البريد الوارد",
+ "description": "تلقَّ إشعارًا في قناة إشعارات عند وصول بريد إلى عنوان على هذا الخادم.",
+ "newRule": "قاعدة جديدة",
+ "newTitle": "قاعدة بريد وارد جديدة",
+ "editTitle": "تعديل قاعدة البريد الوارد",
+ "edit": "تعديل",
+ "emptyTitle": "لا توجد قواعد بريد وارد بعد",
+ "emptyBody": "أضِف قاعدة لتصلك إشعارات في Slack أو Telegram أو Discord أو عبر webhook عند وصول بريد إلى صندوق بريد أو نطاق على هذا الخادم.",
+ "noChannels": "لا توجد لديك قنوات إشعارات موثّقة بعد. أضِف قناة ووثّقها من الإعدادات → الإشعارات، ثم عد إلى هنا لإنشاء قاعدة.",
+ "fieldName": "الاسم",
+ "namePlaceholder": "صندوق بريد الدعم → #support",
+ "fieldScope": "المراقبة",
+ "scopeMailbox": "صندوق بريد واحد",
+ "scopeDomain": "نطاق كامل",
+ "scopeAll": "جميع النطاقات على هذا الخادم",
+ "scopeAllSummary": "جميع النطاقات",
+ "fieldAddress": "العنوان",
+ "fieldDomain": "النطاق",
+ "mailboxCaveat": "تتم المطابقة من ترويستي To و Cc، لذا لن يُطابَق البريد الذي وصل إلى صندوق البريد هذا عبر Bcc أو من خلال اسم مستعار. راقِب النطاق بالكامل إن كنت بحاجة إلى كل رسالة.",
+ "fieldFrom": "المرسِل يحتوي على",
+ "fieldSubject": "الموضوع يحتوي على",
+ "patternPlaceholder": "اختياري",
+ "patternHint": "غير حسّاس لحالة الأحرف. استخدم * كرمز بديل - وكل ما عداه يُطابَق حرفيًا.",
+ "fieldChannels": "أبلِغ هذه القنوات",
+ "fieldEnabled": "القاعدة نشطة",
+ "channelCount": "{count} قناة",
+ "colRule": "القاعدة",
+ "colWatch": "المراقبة",
+ "colChannels": "القنوات",
+ "colStatus": "الحالة",
+ "active": "نشطة",
+ "disabled": "معطّلة",
+ "paused": "متوقّفة مؤقتًا",
+ "deleteTitle": "حذف قاعدة البريد الوارد",
+ "deleteConfirm": "حذف القاعدة",
+ "deleting": "جارٍ الحذف…",
+ "deleteHint": "لا تتأثّر الإشعارات المُرسَلة سابقًا. أمّا البريد الذي التُقط ولم يُعالَج بعد فيُحذَف.",
+ "save": "حفظ القاعدة",
+ "saving": "جارٍ الحفظ…",
+ "cancel": "إلغاء",
+ "test": "اختبر الآن",
+ "testing": "جارٍ الاختبار…",
+ "testResult": "تمت قراءة {read} رسالة ملتقطة: طابقت {matched} قاعدةً، واستُبعدت {dropped} بالتصفية. لم يُرسل أي شيء ولم يُحذف.",
+ "testFailed": "تعذّر تشغيل الاختبار.",
+ "loadFailed": "تعذّر تحميل قواعد البريد الوارد.",
+ "saveFailed": "تعذّر حفظ القاعدة.",
+ "deleteFailed": "تعذّر حذف القاعدة.",
+ "confirmDelete": "حذف القاعدة “{name}”؟ يتوقّف الالتقاط لعنوانها إلا إذا كانت قاعدة أخرى لا تزال بحاجة إليه."
+ },
"overview": {
"mailServer": "خادم البريد",
"protocolDetails": "تفاصيل البروتوكول ←",
@@ -267,6 +318,11 @@
"logs": "السجلات",
"openLogs": "فتح السجلات",
"up": "يعمل منذ {time}",
+ "optional": "اختياري",
+ "daemonHint": {
+ "fatal": "استسلم supervisord - لن يعيد المحاولة من تلقاء نفسه. أعد تشغيله من القائمة.",
+ "backoff": "يتعطّل بشكل متكرر؛ لا يزال supervisord يحاول. راجع السجلات لمعرفة السبب."
+ },
"menu": {
"restart": "إعادة التشغيل",
"restarting": "جارٍ إعادة التشغيل…",
@@ -284,7 +340,9 @@
"restartFailed": "فشلت إعادة التشغيل",
"startFailedTitle": "فشل تشغيل {label}",
"stopFailedTitle": "فشل إيقاف {label}",
- "restartFailedTitle": "فشلت إعادة تشغيل {label}"
+ "restartFailedTitle": "فشلت إعادة تشغيل {label}",
+ "notConfirmedTitle": "لم يبدأ {label} بعد",
+ "notConfirmed": "قبل المشرف الطلب، لكن {label} في حالة {state}. افتح السجلات لمعرفة السبب."
},
"daemonStatus": {
"running": "قيد التشغيل",
@@ -292,6 +350,7 @@
"stopping": "قيد الإيقاف",
"stopped": "متوقّف",
"failed": "فشل",
+ "crashed": "تعطّل",
"missing": "غير موجود",
"unknown": "غير معروف"
},
@@ -319,6 +378,10 @@
"partDnsOne": "{count} سجل DNS مفقود",
"partDnsOther": "{count} سجلات DNS مفقودة",
"partDelivery": "البريد الصادر لا يتم تسليمه",
+ "degradedLabel": "يعمل، مع حماية منقوصة",
+ "partScanning": "{names} غير قيد التشغيل - يتم تسليم البريد الجديد دون فحص الفيروسات",
+ "partSignatures": "{names} غير قيد التشغيل - لم تعد بصمات الفيروسات تُحدَّث، لذا يُفحص البريد بمجموعة بصمات تتقادم",
+ "partAdvisory": "{names} غير قيد التشغيل",
"almostSubQueueOne": "{count} رسالة تنتظر الخروج - راجع التسليم الصادر أدناه.",
"almostSubQueueOther": "{count} رسالة تنتظر الخروج - راجع التسليم الصادر أدناه."
},
@@ -554,6 +617,6 @@
"close": "إغلاق",
"logsLoadFailed": "تعذّر تحميل السجلات",
"loadingLogs": "جارٍ تحميل السجلات…",
- "noJournal": "لا توجد إدخالات سجل لهذه الوحدة."
+ "noLogLines": "لا توجد أسطر سجل لهذا المكوّن بعد."
}
}
diff --git a/apps/dashboard/src/i18n/locales/ar/servers.json b/apps/dashboard/src/i18n/locales/ar/servers.json
index 5f26f35dc..7bdd02a5a 100644
--- a/apps/dashboard/src/i18n/locales/ar/servers.json
+++ b/apps/dashboard/src/i18n/locales/ar/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "مكوّنات المنصّة",
"subtitle": "الحافة والبريد عبر خوادمك",
- "attention": "{n} بحاجة إلى انتباه",
+ "attentionOne": "مكوّن واحد بحاجة إلى انتباه",
+ "attentionMany": "{n} بحاجة إلى انتباه",
"updates": "{n} بها تحديثات",
"healthy": "{n} سليمة",
"allHealthy": "جميع المكوّنات محدَّثة",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "لا توجد حافة",
"chipUpdateOne": "تحديث واحد",
"chipUpdates": "{n} تحديثات",
- "started": "جارٍ تحديث {n} مكوّنات",
- "startedRestart": "جارٍ تشغيل {n} مكوّنات",
- "skipped": "{n} بحاجة إلى انتباه في صفحتها الخاصة",
+ "chipUpdating": "قيد التحديث",
+ "applyingOne": "جارٍ تحديث مكوّن واحد",
+ "applyingMany": "جارٍ تحديث {n} مكوّنات",
+ "restartingOne": "جارٍ تشغيل مكوّن واحد",
+ "restartingMany": "جارٍ تشغيل {n} مكوّنات",
+ "stateQueued": "في الانتظار",
+ "stepPull": "جارٍ سحب الصورة",
+ "stepRecreate": "جارٍ إعادة الإنشاء",
+ "stepVerify": "جارٍ التحقق",
+ "moreTargets": "و{n} أخرى",
+ "viewLogs": "عرض السجل",
+ "doneOne": "تم تحديث مكوّن واحد",
+ "doneMany": "تم تحديث {n} مكوّنات",
+ "failedOne": "مكوّن واحد لم يكتمل",
+ "failedMany": "{n} مكوّنات لم تكتمل",
+ "skippedOne": "مكوّن واحد بحاجة إلى انتباه في صفحته الخاصة",
+ "skippedMany": "{n} بحاجة إلى انتباه في صفحتها الخاصة",
"nothingToDo": "لا يوجد ما يُطبَّق",
"applyFailed": "تعذّر بدء التحديثات"
}
diff --git a/apps/dashboard/src/i18n/locales/de/deploy.json b/apps/dashboard/src/i18n/locales/de/deploy.json
index d5b1691cf..092640fd8 100644
--- a/apps/dashboard/src/i18n/locales/de/deploy.json
+++ b/apps/dashboard/src/i18n/locales/de/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "Nicht verfügbar",
"domainTitle": "Domain",
"domainHintProxy": "Ihr Mail-Server besitzt diesen Hostnamen bereits – wir leiten ihn über Ihren Mail-VPS an den Opshcloud-Workload weiter. Keine DNS-Änderungen nötig.",
+ "domainHintMailHost": "Der eigene Hostname deines Mailservers - er zeigt schon hierher und hat schon ein Zertifikat, es ist also nichts einzurichten. Nutze stattdessen webmail., wenn du beides getrennt halten willst.",
"domainHintCloud": "Richten Sie einen CNAME auf die *.opsh.io-URL, die wir bereitstellen (Sie sehen sie nach dem Deploy).",
"domainHintDefault": "Die URL, die Betreiber besuchen. DNS muss auf das Deploy-Ziel verweisen.",
"summary": "Zusammenfassung",
diff --git a/apps/dashboard/src/i18n/locales/de/emailsAdmin.json b/apps/dashboard/src/i18n/locales/de/emailsAdmin.json
index 82fd134d2..97e107795 100644
--- a/apps/dashboard/src/i18n/locales/de/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/de/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "Übersicht",
"domains": "Domains",
"mailboxes": "Postfächer",
+ "inbound": "Eingang",
"dns": "DNS",
"health": "Zustand",
"test": "Test",
@@ -12,6 +13,56 @@
},
"ariaLabel": "Mail-Admin-Bereiche"
},
+ "inbound": {
+ "heading": "Eingangsregeln",
+ "description": "Lassen Sie sich in einem Benachrichtigungskanal informieren, wenn Mail an eine Adresse auf diesem Server eintrifft.",
+ "newRule": "Neue Regel",
+ "newTitle": "Neue Eingangsregel",
+ "editTitle": "Eingangsregel bearbeiten",
+ "edit": "Bearbeiten",
+ "emptyTitle": "Noch keine Eingangsregeln",
+ "emptyBody": "Fügen Sie eine Regel hinzu, um in Slack, Telegram, Discord oder per Webhook benachrichtigt zu werden, wenn Mail an ein Postfach oder eine Domain auf diesem Server eintrifft.",
+ "noChannels": "Sie haben noch keine verifizierten Benachrichtigungskanäle. Fügen Sie unter Einstellungen → Benachrichtigungen einen hinzu, verifizieren Sie ihn und kommen Sie dann zurück, um eine Regel zu erstellen.",
+ "fieldName": "Name",
+ "namePlaceholder": "Support-Postfach → #support",
+ "fieldScope": "Überwachen",
+ "scopeMailbox": "Ein Postfach",
+ "scopeDomain": "Eine ganze Domain",
+ "scopeAll": "Jede Domain auf diesem Server",
+ "scopeAllSummary": "Jede Domain",
+ "fieldAddress": "Adresse",
+ "fieldDomain": "Domain",
+ "mailboxCaveat": "Der Abgleich erfolgt über die Kopfzeilen To und Cc – Mail, die dieses Postfach per Bcc oder über einen Alias erreicht hat, passt daher nicht. Überwachen Sie die ganze Domain, wenn Sie jede Nachricht benötigen.",
+ "fieldFrom": "Absender enthält",
+ "fieldSubject": "Betreff enthält",
+ "patternPlaceholder": "optional",
+ "patternHint": "Groß-/Kleinschreibung wird ignoriert. Verwenden Sie * als Platzhalter – alles andere wird wörtlich abgeglichen.",
+ "fieldChannels": "Diese Kanäle benachrichtigen",
+ "fieldEnabled": "Regel ist aktiv",
+ "channelCount": "{count} Kanal/Kanäle",
+ "colRule": "Regel",
+ "colWatch": "Überwacht",
+ "colChannels": "Kanäle",
+ "colStatus": "Status",
+ "active": "Aktiv",
+ "disabled": "Aus",
+ "paused": "Pausiert",
+ "deleteTitle": "Eingangsregel löschen",
+ "deleteConfirm": "Regel löschen",
+ "deleting": "Wird gelöscht…",
+ "deleteHint": "Bereits gesendete Benachrichtigungen bleiben unberührt. Bereits erfasste, aber noch nicht verarbeitete Mail wird verworfen.",
+ "save": "Regel speichern",
+ "saving": "Wird gespeichert…",
+ "cancel": "Abbrechen",
+ "test": "Jetzt testen",
+ "testing": "Wird getestet…",
+ "testResult": "{read} erfasste Nachricht(en) gelesen: {matched} passten zu einer Regel, {dropped} wurden herausgefiltert. Es wurde nichts gesendet oder gelöscht.",
+ "testFailed": "Der Test konnte nicht ausgeführt werden.",
+ "loadFailed": "Eingangsregeln konnten nicht geladen werden.",
+ "saveFailed": "Die Regel konnte nicht gespeichert werden.",
+ "deleteFailed": "Die Regel konnte nicht gelöscht werden.",
+ "confirmDelete": "Die Regel „{name}“ löschen? Die Erfassung für ihre Adresse wird beendet, sofern keine andere Regel sie noch benötigt."
+ },
"overview": {
"mailServer": "Mailserver",
"protocolDetails": "Protokolldetails →",
@@ -267,6 +318,11 @@
"logs": "Protokolle",
"openLogs": "Protokolle öffnen",
"up": "Aktiv seit {time}",
+ "optional": "Optional",
+ "daemonHint": {
+ "fatal": "supervisord hat aufgegeben - es versucht es nicht von selbst erneut. Starte es über das Menü neu.",
+ "backoff": "Stürzt wiederholt ab; supervisord versucht es weiter. Die Protokolle nennen den Grund."
+ },
"menu": {
"restart": "Neu starten",
"restarting": "Wird neu gestartet…",
@@ -284,7 +340,9 @@
"restartFailed": "Neustart fehlgeschlagen",
"startFailedTitle": "Start von {label} fehlgeschlagen",
"stopFailedTitle": "Stopp von {label} fehlgeschlagen",
- "restartFailedTitle": "Neustart von {label} fehlgeschlagen"
+ "restartFailedTitle": "Neustart von {label} fehlgeschlagen",
+ "notConfirmedTitle": "{label} ist nicht gestartet",
+ "notConfirmed": "Der Supervisor hat die Anfrage angenommen, aber {label} ist {state}. Öffne die Protokolle, um den Grund zu sehen."
},
"daemonStatus": {
"running": "Läuft",
@@ -292,6 +350,7 @@
"stopping": "Wird gestoppt",
"stopped": "Gestoppt",
"failed": "Fehlgeschlagen",
+ "crashed": "Abgestürzt",
"missing": "Fehlt",
"unknown": "Unbekannt"
},
@@ -319,6 +378,10 @@
"partDnsOne": "{count} DNS-Eintrag fehlt",
"partDnsOther": "{count} DNS-Einträge fehlen",
"partDelivery": "ausgehende Mail wird nicht zugestellt",
+ "degradedLabel": "Funktioniert, mit eingeschränktem Schutz",
+ "partScanning": "{names} läuft nicht - neue Mail wird OHNE Virenprüfung zugestellt",
+ "partSignatures": "{names} läuft nicht - Virensignaturen werden nicht mehr aktualisiert, Mail wird also gegen einen veraltenden Signaturstand geprüft",
+ "partAdvisory": "{names} läuft nicht",
"almostSubQueueOne": "{count} Nachricht wartet auf den Versand – siehe Ausgehender Versand unten.",
"almostSubQueueOther": "{count} Nachrichten warten auf den Versand – siehe Ausgehender Versand unten."
},
@@ -554,6 +617,6 @@
"close": "Schließen",
"logsLoadFailed": "Protokolle konnten nicht geladen werden",
"loadingLogs": "Protokolle werden geladen…",
- "noJournal": "Keine Journal-Einträge für diese Unit."
+ "noLogLines": "Noch keine Protokollzeilen für diese Komponente."
}
}
diff --git a/apps/dashboard/src/i18n/locales/de/servers.json b/apps/dashboard/src/i18n/locales/de/servers.json
index a2485fdae..43391f3fb 100644
--- a/apps/dashboard/src/i18n/locales/de/servers.json
+++ b/apps/dashboard/src/i18n/locales/de/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "Plattform-Komponenten",
"subtitle": "Edge & Mail über deine Server",
- "attention": "{n} benötigen Aufmerksamkeit",
+ "attentionOne": "1 benötigt Aufmerksamkeit",
+ "attentionMany": "{n} benötigen Aufmerksamkeit",
"updates": "{n} mit Updates",
"healthy": "{n} in Ordnung",
"allHealthy": "Alle Komponenten sind aktuell",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "kein Edge",
"chipUpdateOne": "1 Update",
"chipUpdates": "{n} Updates",
- "started": "{n} Komponenten werden aktualisiert",
- "startedRestart": "{n} Komponenten werden gestartet",
- "skipped": "{n} benötigen Aufmerksamkeit auf ihrer eigenen Seite",
+ "chipUpdating": "wird aktualisiert",
+ "applyingOne": "1 Komponente wird aktualisiert",
+ "applyingMany": "{n} Komponenten werden aktualisiert",
+ "restartingOne": "1 Komponente wird gestartet",
+ "restartingMany": "{n} Komponenten werden gestartet",
+ "stateQueued": "In Warteschlange",
+ "stepPull": "Image wird geladen",
+ "stepRecreate": "Wird neu erstellt",
+ "stepVerify": "Wird geprüft",
+ "moreTargets": "+{n} weitere",
+ "viewLogs": "Protokoll ansehen",
+ "doneOne": "1 Komponente aktualisiert",
+ "doneMany": "{n} Komponenten aktualisiert",
+ "failedOne": "1 Komponente wurde nicht fertig",
+ "failedMany": "{n} Komponenten wurden nicht fertig",
+ "skippedOne": "1 benötigt Aufmerksamkeit auf der eigenen Seite",
+ "skippedMany": "{n} benötigen Aufmerksamkeit auf ihrer eigenen Seite",
"nothingToDo": "Nichts anzuwenden",
"applyFailed": "Updates konnten nicht gestartet werden"
}
diff --git a/apps/dashboard/src/i18n/locales/en/deploy.json b/apps/dashboard/src/i18n/locales/en/deploy.json
index 2582b81d3..8ad033c56 100644
--- a/apps/dashboard/src/i18n/locales/en/deploy.json
+++ b/apps/dashboard/src/i18n/locales/en/deploy.json
@@ -297,6 +297,7 @@
"notAvailable": "Not available",
"domainTitle": "Domain",
"domainHintProxy": "Your mail server already owns this hostname - we'll proxy it through your mail VPS to the Opshcloud workload. No DNS changes needed.",
+ "domainHintMailHost": "Your mail server's own hostname - it already points here and already has a certificate, so there is nothing to set up. Use webmail. instead if you would rather keep them separate.",
"domainHintCloud": "Point a CNAME at the *.opsh.io URL we provision (you'll see it after deploy).",
"domainHintDefault": "The URL operators will visit. DNS must point at the deploy target.",
"summary": "Summary",
diff --git a/apps/dashboard/src/i18n/locales/en/emailsAdmin.json b/apps/dashboard/src/i18n/locales/en/emailsAdmin.json
index 923a37adf..ce673279e 100644
--- a/apps/dashboard/src/i18n/locales/en/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/en/emailsAdmin.json
@@ -5,6 +5,7 @@
"domains": "Domains",
"mailboxes": "Mailboxes",
"aliases": "Aliases",
+ "inbound": "Inbound",
"dns": "DNS",
"health": "Health",
"test": "Test",
@@ -14,6 +15,56 @@
},
"ariaLabel": "Mail admin sections"
},
+ "inbound": {
+ "heading": "Inbound rules",
+ "description": "Get told in a notification channel when mail arrives at an address on this server.",
+ "newRule": "New rule",
+ "newTitle": "New inbound rule",
+ "editTitle": "Edit inbound rule",
+ "edit": "Edit",
+ "emptyTitle": "No inbound rules yet",
+ "emptyBody": "Add a rule to be notified in Slack, Telegram, Discord or a webhook when mail arrives at a mailbox or domain on this server.",
+ "noChannels": "You have no verified notification channels yet. Add and verify one in Settings → Notifications, then come back to create a rule.",
+ "fieldName": "Name",
+ "namePlaceholder": "Support inbox → #support",
+ "fieldScope": "Watch",
+ "scopeMailbox": "One mailbox",
+ "scopeDomain": "A whole domain",
+ "scopeAll": "Every domain on this server",
+ "scopeAllSummary": "Every domain",
+ "fieldAddress": "Address",
+ "fieldDomain": "Domain",
+ "mailboxCaveat": "Matched from the To and Cc headers, so mail that reached this mailbox by Bcc or through an alias will not match. Watch the whole domain if you need every message.",
+ "fieldFrom": "From contains",
+ "fieldSubject": "Subject contains",
+ "patternPlaceholder": "optional",
+ "patternHint": "Case-insensitive. Use * as a wildcard — everything else is matched literally.",
+ "fieldChannels": "Notify these channels",
+ "fieldEnabled": "Rule is active",
+ "channelCount": "{count} channel(s)",
+ "colRule": "Rule",
+ "colWatch": "Watching",
+ "colChannels": "Channels",
+ "colStatus": "Status",
+ "active": "Active",
+ "disabled": "Off",
+ "paused": "Paused",
+ "deleteTitle": "Delete inbound rule",
+ "deleteConfirm": "Delete rule",
+ "deleting": "Deleting…",
+ "deleteHint": "Existing notifications are unaffected. Mail already captured but not yet processed is discarded.",
+ "save": "Save rule",
+ "saving": "Saving…",
+ "cancel": "Cancel",
+ "test": "Test now",
+ "testing": "Testing…",
+ "testResult": "Read {read} captured message(s): {matched} matched a rule, {dropped} filtered out. Nothing was sent or deleted.",
+ "testFailed": "Could not run the test.",
+ "loadFailed": "Could not load inbound rules.",
+ "saveFailed": "Could not save the rule.",
+ "deleteFailed": "Could not delete the rule.",
+ "confirmDelete": "Delete the rule “{name}”? Capture stops for its address unless another rule still needs it."
+ },
"sending": {
"title": "Outbound sending",
"subtitle": "Receiving stays on this server. Choose how outbound mail leaves it.",
@@ -385,6 +436,11 @@
"logs": "Logs",
"openLogs": "Open logs",
"up": "Up {time}",
+ "optional": "Optional",
+ "daemonHint": {
+ "fatal": "supervisord has given up - it will not retry on its own. Restart it from the menu.",
+ "backoff": "Crashing repeatedly; supervisord is still retrying. Check the logs for why."
+ },
"menu": {
"restart": "Restart",
"restarting": "Restarting…",
@@ -402,7 +458,9 @@
"restartFailed": "restart failed",
"startFailedTitle": "{label} start failed",
"stopFailedTitle": "{label} stop failed",
- "restartFailedTitle": "{label} restart failed"
+ "restartFailedTitle": "{label} restart failed",
+ "notConfirmedTitle": "{label} has not come up",
+ "notConfirmed": "The supervisor accepted the request, but {label} is {state}. Open the logs to see why."
},
"daemonStatus": {
"running": "Running",
@@ -410,6 +468,7 @@
"stopping": "Stopping",
"stopped": "Stopped",
"failed": "Failed",
+ "crashed": "Crashed",
"missing": "Missing",
"unknown": "Unknown"
},
@@ -437,6 +496,10 @@
"partDnsOne": "{count} DNS record missing",
"partDnsOther": "{count} DNS records missing",
"partDelivery": "outbound mail is not being delivered",
+ "degradedLabel": "Working, with reduced protection",
+ "partScanning": "{names} not running - new mail is being delivered WITHOUT virus scanning",
+ "partSignatures": "{names} not running - virus signatures are no longer being updated, so mail is scanned against an ageing signature set",
+ "partAdvisory": "{names} not running",
"almostSubQueueOne": "{count} message is waiting to go out - see Outbound delivery below.",
"almostSubQueueOther": "{count} messages are waiting to go out - see Outbound delivery below."
},
@@ -672,6 +735,6 @@
"close": "Close",
"logsLoadFailed": "Failed to load logs",
"loadingLogs": "Loading logs…",
- "noJournal": "No journal entries for this unit."
+ "noLogLines": "No log lines for this component yet."
}
}
diff --git a/apps/dashboard/src/i18n/locales/en/projectSettings.json b/apps/dashboard/src/i18n/locales/en/projectSettings.json
index 2ad1137e9..b8848a0da 100644
--- a/apps/dashboard/src/i18n/locales/en/projectSettings.json
+++ b/apps/dashboard/src/i18n/locales/en/projectSettings.json
@@ -119,6 +119,11 @@
"breadcrumbApps": "Apps",
"appEyebrow": "App",
"stopFailed": "Couldn't stop the install",
+ "progressStep": "Step {current} of {total}",
+ "progressServicesReady": "{done} of {total} services ready",
+ "summaryTitle": "Configuration",
+ "summaryDestination": "Destination",
+ "summaryServices": "Services",
"mail": {
"title": "Set up mail",
"subtitle": "Deploy webmail on your own domain, or connect an existing mailbox.",
diff --git a/apps/dashboard/src/i18n/locales/en/servers.json b/apps/dashboard/src/i18n/locales/en/servers.json
index 500a8150e..11a1a9da5 100644
--- a/apps/dashboard/src/i18n/locales/en/servers.json
+++ b/apps/dashboard/src/i18n/locales/en/servers.json
@@ -73,7 +73,8 @@
"infra": {
"title": "Platform components",
"subtitle": "Edge & mail across your fleet",
- "attention": "{n} need attention",
+ "attentionOne": "1 needs attention",
+ "attentionMany": "{n} need attention",
"updates": "{n} with updates",
"healthy": "{n} healthy",
"allHealthy": "All components up to date",
@@ -92,9 +93,23 @@
"chipEdgeMissing": "no edge",
"chipUpdateOne": "1 update",
"chipUpdates": "{n} updates",
- "started": "Updating {n} components",
- "startedRestart": "Restarting {n} components",
- "skipped": "{n} need attention on their own page",
+ "chipUpdating": "updating",
+ "applyingOne": "Updating 1 component",
+ "applyingMany": "Updating {n} components",
+ "restartingOne": "Restarting 1 component",
+ "restartingMany": "Restarting {n} components",
+ "stateQueued": "Queued",
+ "stepPull": "Pulling image",
+ "stepRecreate": "Recreating",
+ "stepVerify": "Verifying",
+ "moreTargets": "+{n} more",
+ "viewLogs": "View logs",
+ "doneOne": "1 component updated",
+ "doneMany": "{n} components updated",
+ "failedOne": "1 component didn't finish",
+ "failedMany": "{n} components didn't finish",
+ "skippedOne": "1 needs attention on its own page",
+ "skippedMany": "{n} need attention on their own page",
"nothingToDo": "Nothing to apply",
"applyFailed": "Couldn't start the updates"
}
@@ -271,6 +286,9 @@
"hostChannelMissingBody": "Openship runs in a container here, and the SSH channel it uses to reach the host was never provisioned. There was no address to contact, so nothing here is down — host-level operations on this box just have nowhere to run.",
"hostChannelWouldUse": "Once provisioned the channel will reach the host at {target}. Nothing has contacted that address yet.",
"hostChannelProvisionFix": "Run this on the host to provision the channel, then retry:",
+ "hostChannelAuthTitle": "The host refused Openship's key",
+ "hostChannelAuthBody": "Openship runs in a container here and reaches the host over SSH at {target}. The host answered and then rejected the key, so host-level operations can't run. Either the key is no longer authorised for that account, or sshd doesn't permit that account to log in at all. The SSH credentials stored on this server are not used for this connection — the channel has its own key.",
+ "hostChannelReauthorizeFix": "Run this on the host to re-authorize the channel key, then retry:",
"unknownTitle": "Health check failed",
"unknownBody": "Openship couldn't talk to this server.",
"checkPowered": "Is the VPS / VM powered on?",
diff --git a/apps/dashboard/src/i18n/locales/en/settings.json b/apps/dashboard/src/i18n/locales/en/settings.json
index 613effc94..924f42ccf 100644
--- a/apps/dashboard/src/i18n/locales/en/settings.json
+++ b/apps/dashboard/src/i18n/locales/en/settings.json
@@ -353,6 +353,8 @@
"badgeReadOnly": "read-only",
"badgeScoped": "scoped",
"metaLine": " · last used {lastUsed} · expires {expires}",
+ "callsOne": " · 1 call",
+ "callsMany": " · {count} calls",
"revoke": "Revoke",
"expiry": {
"none": "No expiry",
@@ -404,11 +406,14 @@
"orgPrefix": "{org} · ",
"authorized": "Authorized {date}",
"lastUsedSuffix": " · last used {date}",
+ "callsOne": " · 1 call",
+ "callsMany": " · {count} calls",
"toast": {
"disconnected": "MCP client disconnected",
"disconnectFailed": "Failed to disconnect"
},
"editAccess": "Edit access",
+ "viewActivity": "Activity",
"editScopeTitle": "{client} access",
"editScopeSubtitle": "Changes apply on the agent's next request — it does not need to reconnect.",
"editScopeOrg": "In {org}",
@@ -730,6 +735,8 @@
},
"filters": {
"anyone": "Anyone",
+ "anyAgent": "Any agent",
+ "agentCalls": "{count} events",
"period": "Period",
"allTime": "All time",
"today": "Today",
@@ -758,6 +765,7 @@
"who": "Who",
"when": "When",
"cameFrom": "Came from",
+ "viaAgent": "Agent",
"technical": "Technical details",
"eventType": "Event type"
}
diff --git a/apps/dashboard/src/i18n/locales/es/deploy.json b/apps/dashboard/src/i18n/locales/es/deploy.json
index ebf50e727..7589e3467 100644
--- a/apps/dashboard/src/i18n/locales/es/deploy.json
+++ b/apps/dashboard/src/i18n/locales/es/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "No disponible",
"domainTitle": "Dominio",
"domainHintProxy": "Tu servidor de correo ya es propietario de este nombre de host; lo enrutaremos a través de tu VPS de correo hacia la carga de trabajo de Opshcloud. No se necesitan cambios de DNS.",
+ "domainHintMailHost": "El nombre de host de tu propio servidor de correo: ya apunta aqui y ya tiene un certificado, asi que no hay nada que configurar. Usa webmail. si prefieres mantenerlos separados.",
"domainHintCloud": "Apunta un CNAME a la URL *.opsh.io que aprovisionamos (la verás después del despliegue).",
"domainHintDefault": "La URL que visitarán los operadores. El DNS debe apuntar al destino de despliegue.",
"summary": "Resumen",
diff --git a/apps/dashboard/src/i18n/locales/es/emailsAdmin.json b/apps/dashboard/src/i18n/locales/es/emailsAdmin.json
index ab6977637..e3717ef49 100644
--- a/apps/dashboard/src/i18n/locales/es/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/es/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "Resumen",
"domains": "Dominios",
"mailboxes": "Buzones",
+ "inbound": "Entrantes",
"dns": "DNS",
"health": "Estado",
"test": "Prueba",
@@ -12,6 +13,56 @@
},
"ariaLabel": "Secciones de administración de correo"
},
+ "inbound": {
+ "heading": "Reglas de correo entrante",
+ "description": "Recibe un aviso en un canal de notificaciones cuando llegue correo a una dirección de este servidor.",
+ "newRule": "Nueva regla",
+ "newTitle": "Nueva regla de correo entrante",
+ "editTitle": "Editar regla de correo entrante",
+ "edit": "Editar",
+ "emptyTitle": "Aún no hay reglas de correo entrante",
+ "emptyBody": "Añade una regla para recibir un aviso en Slack, Telegram, Discord o un webhook cuando llegue correo a un buzón o a un dominio de este servidor.",
+ "noChannels": "Aún no tienes canales de notificación verificados. Añade y verifica uno en Ajustes → Notificaciones y luego vuelve aquí para crear una regla.",
+ "fieldName": "Nombre",
+ "namePlaceholder": "Bandeja de soporte → #support",
+ "fieldScope": "Vigilar",
+ "scopeMailbox": "Un buzón",
+ "scopeDomain": "Un dominio completo",
+ "scopeAll": "Todos los dominios de este servidor",
+ "scopeAllSummary": "Todos los dominios",
+ "fieldAddress": "Dirección",
+ "fieldDomain": "Dominio",
+ "mailboxCaveat": "La coincidencia se busca en las cabeceras To y Cc, así que el correo que llegó a este buzón por Bcc o a través de un alias no coincidirá. Vigila el dominio completo si necesitas todos los mensajes.",
+ "fieldFrom": "El remitente contiene",
+ "fieldSubject": "El asunto contiene",
+ "patternPlaceholder": "opcional",
+ "patternHint": "No distingue mayúsculas de minúsculas. Usa * como comodín; todo lo demás se compara literalmente.",
+ "fieldChannels": "Avisar a estos canales",
+ "fieldEnabled": "La regla está activa",
+ "channelCount": "{count} canal(es)",
+ "colRule": "Regla",
+ "colWatch": "Vigilando",
+ "colChannels": "Canales",
+ "colStatus": "Estado",
+ "active": "Activa",
+ "disabled": "Desactivada",
+ "paused": "Pausada",
+ "deleteTitle": "Eliminar regla de correo entrante",
+ "deleteConfirm": "Eliminar regla",
+ "deleting": "Eliminando…",
+ "deleteHint": "Las notificaciones ya enviadas no se ven afectadas. El correo que ya se capturó pero aún no se ha procesado se descarta.",
+ "save": "Guardar regla",
+ "saving": "Guardando…",
+ "cancel": "Cancelar",
+ "test": "Probar ahora",
+ "testing": "Probando…",
+ "testResult": "Se leyeron {read} mensaje(s) capturado(s): {matched} coincidieron con una regla y {dropped} se descartaron por los filtros. No se envió ni se eliminó nada.",
+ "testFailed": "No se pudo ejecutar la prueba.",
+ "loadFailed": "No se pudieron cargar las reglas de correo entrante.",
+ "saveFailed": "No se pudo guardar la regla.",
+ "deleteFailed": "No se pudo eliminar la regla.",
+ "confirmDelete": "¿Eliminar la regla «{name}»? La captura se detiene para su dirección salvo que otra regla siga necesitándola."
+ },
"overview": {
"mailServer": "Servidor de correo",
"protocolDetails": "Detalles del protocolo →",
@@ -267,6 +318,11 @@
"logs": "Registros",
"openLogs": "Abrir registros",
"up": "Activo {time}",
+ "optional": "Opcional",
+ "daemonHint": {
+ "fatal": "supervisord se ha rendido: no volverá a intentarlo por su cuenta. Reinícialo desde el menú.",
+ "backoff": "Se cae repetidamente; supervisord sigue reintentando. Revisa los registros para ver por qué."
+ },
"menu": {
"restart": "Reiniciar",
"restarting": "Reiniciando…",
@@ -284,7 +340,9 @@
"restartFailed": "el reinicio falló",
"startFailedTitle": "El inicio de {label} falló",
"stopFailedTitle": "La detención de {label} falló",
- "restartFailedTitle": "El reinicio de {label} falló"
+ "restartFailedTitle": "El reinicio de {label} falló",
+ "notConfirmedTitle": "{label} no se ha iniciado",
+ "notConfirmed": "El supervisor aceptó la solicitud, pero {label} está {state}. Abre los registros para ver por qué."
},
"daemonStatus": {
"running": "En ejecución",
@@ -292,6 +350,7 @@
"stopping": "Deteniendo",
"stopped": "Detenido",
"failed": "Fallido",
+ "crashed": "Caído",
"missing": "Ausente",
"unknown": "Desconocido"
},
@@ -319,6 +378,10 @@
"partDnsOne": "falta {count} registro DNS",
"partDnsOther": "faltan {count} registros DNS",
"partDelivery": "el correo saliente no se está entregando",
+ "degradedLabel": "Funciona, con protección reducida",
+ "partScanning": "{names} no está en ejecución - el correo nuevo se entrega SIN análisis de virus",
+ "partSignatures": "{names} no está en ejecución - las firmas de virus ya no se actualizan, así que el correo se analiza con un conjunto de firmas desactualizándose",
+ "partAdvisory": "{names} no está en ejecución",
"almostSubQueueOne": "{count} mensaje está esperando para salir; consulta Envío saliente más abajo.",
"almostSubQueueOther": "{count} mensajes están esperando para salir; consulta Envío saliente más abajo."
},
@@ -554,6 +617,6 @@
"close": "Cerrar",
"logsLoadFailed": "No se pudieron cargar los registros",
"loadingLogs": "Cargando registros…",
- "noJournal": "No hay entradas de diario para esta unidad."
+ "noLogLines": "Aún no hay líneas de registro para este componente."
}
}
diff --git a/apps/dashboard/src/i18n/locales/es/servers.json b/apps/dashboard/src/i18n/locales/es/servers.json
index 3d83eacb6..6c6eaaca4 100644
--- a/apps/dashboard/src/i18n/locales/es/servers.json
+++ b/apps/dashboard/src/i18n/locales/es/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "Componentes de la plataforma",
"subtitle": "Edge y correo en toda tu flota",
- "attention": "{n} necesitan atención",
+ "attentionOne": "1 necesita atención",
+ "attentionMany": "{n} necesitan atención",
"updates": "{n} con actualizaciones",
"healthy": "{n} correctos",
"allHealthy": "Todos los componentes están actualizados",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "sin edge",
"chipUpdateOne": "1 actualización",
"chipUpdates": "{n} actualizaciones",
- "started": "Actualizando {n} componentes",
- "startedRestart": "Iniciando {n} componentes",
- "skipped": "{n} necesitan atención en su propia página",
+ "chipUpdating": "actualizando",
+ "applyingOne": "Actualizando 1 componente",
+ "applyingMany": "Actualizando {n} componentes",
+ "restartingOne": "Iniciando 1 componente",
+ "restartingMany": "Iniciando {n} componentes",
+ "stateQueued": "En cola",
+ "stepPull": "Descargando imagen",
+ "stepRecreate": "Recreando",
+ "stepVerify": "Verificando",
+ "moreTargets": "+{n} más",
+ "viewLogs": "Ver registro",
+ "doneOne": "1 componente actualizado",
+ "doneMany": "{n} componentes actualizados",
+ "failedOne": "1 componente no terminó",
+ "failedMany": "{n} componentes no terminaron",
+ "skippedOne": "1 necesita atención en su propia página",
+ "skippedMany": "{n} necesitan atención en su propia página",
"nothingToDo": "Nada que aplicar",
"applyFailed": "No se pudieron iniciar las actualizaciones"
}
diff --git a/apps/dashboard/src/i18n/locales/fr/deploy.json b/apps/dashboard/src/i18n/locales/fr/deploy.json
index 6292a5a03..485d25a71 100644
--- a/apps/dashboard/src/i18n/locales/fr/deploy.json
+++ b/apps/dashboard/src/i18n/locales/fr/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "Non disponible",
"domainTitle": "Domaine",
"domainHintProxy": "Votre serveur de messagerie possède déjà ce nom d'hôte - nous le proxyfierons via votre VPS de messagerie vers la charge de travail Opshcloud. Aucune modification DNS nécessaire.",
+ "domainHintMailHost": "Le nom d'hote de votre serveur de messagerie - il pointe deja ici et possede deja un certificat, il n'y a donc rien a configurer. Utilisez plutot webmail. si vous preferez les separer.",
"domainHintCloud": "Pointez un CNAME vers l'URL *.opsh.io que nous provisionnons (vous la verrez après le déploiement).",
"domainHintDefault": "L'URL que les opérateurs visiteront. Le DNS doit pointer vers la cible de déploiement.",
"summary": "Récapitulatif",
diff --git a/apps/dashboard/src/i18n/locales/fr/emailsAdmin.json b/apps/dashboard/src/i18n/locales/fr/emailsAdmin.json
index 5eda38a41..aacfa1bc2 100644
--- a/apps/dashboard/src/i18n/locales/fr/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/fr/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "Aperçu",
"domains": "Domaines",
"mailboxes": "Boîtes aux lettres",
+ "inbound": "Entrant",
"dns": "DNS",
"health": "État",
"test": "Test",
@@ -12,6 +13,56 @@
},
"ariaLabel": "Sections d'administration de la messagerie"
},
+ "inbound": {
+ "heading": "Règles de courrier entrant",
+ "description": "Soyez averti dans un canal de notification lorsqu'un courrier arrive à une adresse de ce serveur.",
+ "newRule": "Nouvelle règle",
+ "newTitle": "Nouvelle règle de courrier entrant",
+ "editTitle": "Modifier la règle de courrier entrant",
+ "edit": "Modifier",
+ "emptyTitle": "Aucune règle de courrier entrant pour l'instant",
+ "emptyBody": "Ajoutez une règle pour être averti dans Slack, Telegram, Discord ou par un webhook lorsqu'un courrier arrive dans une boîte aux lettres ou sur un domaine de ce serveur.",
+ "noChannels": "Vous n'avez encore aucun canal de notification vérifié. Ajoutez-en un et vérifiez-le dans Paramètres → Notifications, puis revenez ici pour créer une règle.",
+ "fieldName": "Nom",
+ "namePlaceholder": "Boîte du support → #support",
+ "fieldScope": "Surveiller",
+ "scopeMailbox": "Une boîte aux lettres",
+ "scopeDomain": "Un domaine entier",
+ "scopeAll": "Tous les domaines de ce serveur",
+ "scopeAllSummary": "Tous les domaines",
+ "fieldAddress": "Adresse",
+ "fieldDomain": "Domaine",
+ "mailboxCaveat": "La correspondance se fait sur les en-têtes To et Cc : un courrier arrivé dans cette boîte aux lettres par Bcc ou via un alias ne correspondra donc pas. Surveillez le domaine entier s'il vous faut tous les messages.",
+ "fieldFrom": "L'expéditeur contient",
+ "fieldSubject": "L'objet contient",
+ "patternPlaceholder": "facultatif",
+ "patternHint": "Insensible à la casse. Utilisez * comme joker — tout le reste est comparé littéralement.",
+ "fieldChannels": "Notifier ces canaux",
+ "fieldEnabled": "Règle active",
+ "channelCount": "{count} canal(aux)",
+ "colRule": "Règle",
+ "colWatch": "Surveillance",
+ "colChannels": "Canaux",
+ "colStatus": "Statut",
+ "active": "Active",
+ "disabled": "Désactivée",
+ "paused": "En pause",
+ "deleteTitle": "Supprimer la règle de courrier entrant",
+ "deleteConfirm": "Supprimer la règle",
+ "deleting": "Suppression…",
+ "deleteHint": "Les notifications déjà envoyées ne sont pas affectées. Le courrier déjà capturé mais pas encore traité est supprimé.",
+ "save": "Enregistrer la règle",
+ "saving": "Enregistrement…",
+ "cancel": "Annuler",
+ "test": "Tester maintenant",
+ "testing": "Test en cours…",
+ "testResult": "{read} message(s) capturé(s) lu(s) : {matched} ont correspondu à une règle, {dropped} ont été filtrés. Rien n'a été envoyé ni supprimé.",
+ "testFailed": "Impossible d'exécuter le test.",
+ "loadFailed": "Impossible de charger les règles de courrier entrant.",
+ "saveFailed": "Impossible d'enregistrer la règle.",
+ "deleteFailed": "Impossible de supprimer la règle.",
+ "confirmDelete": "Supprimer la règle « {name} » ? La capture s'arrête pour son adresse, sauf si une autre règle en a encore besoin."
+ },
"overview": {
"mailServer": "Serveur de messagerie",
"protocolDetails": "Détails du protocole →",
@@ -267,6 +318,11 @@
"logs": "Journaux",
"openLogs": "Ouvrir les journaux",
"up": "Actif {time}",
+ "optional": "Facultatif",
+ "daemonHint": {
+ "fatal": "supervisord a abandonné - il ne réessaiera pas de lui-même. Redémarrez-le depuis le menu.",
+ "backoff": "Plante à répétition ; supervisord réessaie encore. Consultez les journaux pour savoir pourquoi."
+ },
"menu": {
"restart": "Redémarrer",
"restarting": "Redémarrage…",
@@ -284,7 +340,9 @@
"restartFailed": "échec du redémarrage",
"startFailedTitle": "échec du démarrage de {label}",
"stopFailedTitle": "échec de l'arrêt de {label}",
- "restartFailedTitle": "échec du redémarrage de {label}"
+ "restartFailedTitle": "échec du redémarrage de {label}",
+ "notConfirmedTitle": "{label} n'a pas démarré",
+ "notConfirmed": "Le superviseur a accepté la demande, mais {label} est {state}. Ouvrez les journaux pour savoir pourquoi."
},
"daemonStatus": {
"running": "En cours d'exécution",
@@ -292,6 +350,7 @@
"stopping": "Arrêt",
"stopped": "Arrêté",
"failed": "En échec",
+ "crashed": "Planté",
"missing": "Absent",
"unknown": "Inconnu"
},
@@ -319,6 +378,10 @@
"partDnsOne": "{count} enregistrement DNS manquant",
"partDnsOther": "{count} enregistrements DNS manquants",
"partDelivery": "le courrier sortant n'est pas distribué",
+ "degradedLabel": "Fonctionne, avec une protection réduite",
+ "partScanning": "{names} n'est pas en cours d'exécution - les nouveaux messages sont livrés SANS analyse antivirus",
+ "partSignatures": "{names} n'est pas en cours d'exécution - les signatures antivirus ne sont plus mises à jour, le courrier est donc analysé avec un jeu de signatures qui vieillit",
+ "partAdvisory": "{names} n'est pas en cours d'exécution",
"almostSubQueueOne": "{count} message attend d'être envoyé - voir Envoi sortant ci-dessous.",
"almostSubQueueOther": "{count} messages attendent d'être envoyés - voir Envoi sortant ci-dessous."
},
@@ -554,6 +617,6 @@
"close": "Fermer",
"logsLoadFailed": "Échec du chargement des journaux",
"loadingLogs": "Chargement des journaux…",
- "noJournal": "Aucune entrée de journal pour cette unité."
+ "noLogLines": "Aucune ligne de journal pour ce composant pour le moment."
}
}
diff --git a/apps/dashboard/src/i18n/locales/fr/servers.json b/apps/dashboard/src/i18n/locales/fr/servers.json
index 3e118e3cd..cfe8125c0 100644
--- a/apps/dashboard/src/i18n/locales/fr/servers.json
+++ b/apps/dashboard/src/i18n/locales/fr/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "Composants de la plateforme",
"subtitle": "Edge et messagerie sur votre parc",
- "attention": "{n} à surveiller",
+ "attentionOne": "1 à surveiller",
+ "attentionMany": "{n} à surveiller",
"updates": "{n} avec des mises à jour",
"healthy": "{n} en bon état",
"allHealthy": "Tous les composants sont à jour",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "aucun edge",
"chipUpdateOne": "1 mise à jour",
"chipUpdates": "{n} mises à jour",
- "started": "Mise à jour de {n} composants",
- "startedRestart": "Démarrage de {n} composants",
- "skipped": "{n} à traiter depuis leur propre page",
+ "chipUpdating": "en cours",
+ "applyingOne": "Mise à jour d'un composant",
+ "applyingMany": "Mise à jour de {n} composants",
+ "restartingOne": "Démarrage d'un composant",
+ "restartingMany": "Démarrage de {n} composants",
+ "stateQueued": "En attente",
+ "stepPull": "Téléchargement de l'image",
+ "stepRecreate": "Recréation",
+ "stepVerify": "Vérification",
+ "moreTargets": "+{n} autres",
+ "viewLogs": "Voir le journal",
+ "doneOne": "1 composant mis à jour",
+ "doneMany": "{n} composants mis à jour",
+ "failedOne": "1 composant n'a pas abouti",
+ "failedMany": "{n} composants n'ont pas abouti",
+ "skippedOne": "1 à traiter depuis sa propre page",
+ "skippedMany": "{n} à traiter depuis leur propre page",
"nothingToDo": "Rien à appliquer",
"applyFailed": "Impossible de lancer les mises à jour"
}
diff --git a/apps/dashboard/src/i18n/locales/ja/deploy.json b/apps/dashboard/src/i18n/locales/ja/deploy.json
index bc3891efc..684d39fca 100644
--- a/apps/dashboard/src/i18n/locales/ja/deploy.json
+++ b/apps/dashboard/src/i18n/locales/ja/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "利用不可",
"domainTitle": "ドメイン",
"domainHintProxy": "メールサーバーはすでにこのホスト名を所有しています - メールVPS経由でOpshcloudワークロードにプロキシします。DNSの変更は不要です。",
+ "domainHintMailHost": "メールサーバー自身のホスト名です。すでにここを指しており証明書もあるため、設定は不要です。分けて運用したい場合は webmail.<お使いのドメイン> を使ってください。",
"domainHintCloud": "プロビジョニングする *.opsh.io のURLにCNAMEを向けてください(デプロイ後に表示されます)。",
"domainHintDefault": "オペレーターがアクセスするURLです。DNSはデプロイ先を指している必要があります。",
"summary": "概要",
diff --git a/apps/dashboard/src/i18n/locales/ja/emailsAdmin.json b/apps/dashboard/src/i18n/locales/ja/emailsAdmin.json
index 1da5e36cf..cf0909109 100644
--- a/apps/dashboard/src/i18n/locales/ja/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/ja/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "概要",
"domains": "ドメイン",
"mailboxes": "メールボックス",
+ "inbound": "受信ルール",
"dns": "DNS",
"health": "ヘルス",
"test": "テスト",
@@ -12,6 +13,56 @@
},
"ariaLabel": "メール管理セクション"
},
+ "inbound": {
+ "heading": "受信ルール",
+ "description": "このサーバー上のアドレスにメールが届いたときに、通知チャンネルでお知らせします。",
+ "newRule": "新しいルール",
+ "newTitle": "新しい受信ルール",
+ "editTitle": "受信ルールを編集",
+ "edit": "編集",
+ "emptyTitle": "受信ルールがまだありません",
+ "emptyBody": "このサーバー上のメールボックスまたはドメインにメールが届いたときに Slack、Telegram、Discord、webhook で通知を受け取るには、ルールを追加してください。",
+ "noChannels": "検証済みの通知チャンネルがまだありません。設定 → 通知 で追加して検証したうえで、ここに戻ってルールを作成してください。",
+ "fieldName": "名前",
+ "namePlaceholder": "サポート受信トレイ → #support",
+ "fieldScope": "監視対象",
+ "scopeMailbox": "1 つのメールボックス",
+ "scopeDomain": "ドメイン全体",
+ "scopeAll": "このサーバー上のすべてのドメイン",
+ "scopeAllSummary": "すべてのドメイン",
+ "fieldAddress": "アドレス",
+ "fieldDomain": "ドメイン",
+ "mailboxCaveat": "To および Cc ヘッダーで照合するため、Bcc やエイリアス経由でこのメールボックスに届いたメールは一致しません。すべてのメッセージが必要な場合はドメイン全体を監視してください。",
+ "fieldFrom": "From に含まれる文字列",
+ "fieldSubject": "件名に含まれる文字列",
+ "patternPlaceholder": "任意",
+ "patternHint": "大文字と小文字は区別しません。ワイルドカードとして * を使用できます — それ以外はすべてそのまま照合されます。",
+ "fieldChannels": "通知するチャンネル",
+ "fieldEnabled": "ルールを有効にする",
+ "channelCount": "{count} 件のチャンネル",
+ "colRule": "ルール",
+ "colWatch": "監視対象",
+ "colChannels": "チャンネル",
+ "colStatus": "ステータス",
+ "active": "オン",
+ "disabled": "オフ",
+ "paused": "一時停止中",
+ "deleteTitle": "受信ルールを削除",
+ "deleteConfirm": "ルールを削除",
+ "deleting": "削除中…",
+ "deleteHint": "既存の通知には影響しません。取り込み済みでまだ処理されていないメールは破棄されます。",
+ "save": "ルールを保存",
+ "saving": "保存中…",
+ "cancel": "キャンセル",
+ "test": "今すぐテスト",
+ "testing": "テスト中…",
+ "testResult": "取り込んだメッセージ {read} 件を読み取りました: {matched} 件がルールに一致し、{dropped} 件は除外されました。送信も削除も行っていません。",
+ "testFailed": "テストを実行できませんでした。",
+ "loadFailed": "受信ルールを読み込めませんでした。",
+ "saveFailed": "ルールを保存できませんでした。",
+ "deleteFailed": "ルールを削除できませんでした。",
+ "confirmDelete": "ルール「{name}」を削除しますか? ほかのルールがまだ必要としている場合を除き、そのアドレスの取り込みは停止します。"
+ },
"overview": {
"mailServer": "メールサーバー",
"protocolDetails": "プロトコルの詳細 →",
@@ -267,6 +318,11 @@
"logs": "ログ",
"openLogs": "ログを開く",
"up": "稼働 {time}",
+ "optional": "任意",
+ "daemonHint": {
+ "fatal": "supervisord は再試行を諦めました。自動では再起動しません。メニューから再起動してください。",
+ "backoff": "繰り返しクラッシュしています。supervisord はまだ再試行中です。理由はログを確認してください。"
+ },
"menu": {
"restart": "再起動",
"restarting": "再起動中…",
@@ -284,7 +340,9 @@
"restartFailed": "再起動に失敗しました",
"startFailedTitle": "{label} の起動に失敗しました",
"stopFailedTitle": "{label} の停止に失敗しました",
- "restartFailedTitle": "{label} の再起動に失敗しました"
+ "restartFailedTitle": "{label} の再起動に失敗しました",
+ "notConfirmedTitle": "{label} は起動していません",
+ "notConfirmed": "スーパーバイザーは要求を受け付けましたが、{label} は {state} です。理由はログで確認してください。"
},
"daemonStatus": {
"running": "稼働中",
@@ -292,6 +350,7 @@
"stopping": "停止中",
"stopped": "停止済み",
"failed": "失敗",
+ "crashed": "クラッシュ",
"missing": "存在しません",
"unknown": "不明"
},
@@ -319,6 +378,10 @@
"partDnsOne": "{count} 件の DNS レコードが不足",
"partDnsOther": "{count} 件の DNS レコードが不足",
"partDelivery": "送信メールが配送されていません",
+ "degradedLabel": "動作中(保護レベルは低下)",
+ "partScanning": "{names} が動作していません - 新着メールはウイルススキャンなしで配信されています",
+ "partSignatures": "{names} が動作していません - ウイルス定義が更新されなくなるため、古くなっていく定義でスキャンされます",
+ "partAdvisory": "{names} が動作していません",
"almostSubQueueOne": "{count} 件のメールが送信待ちです - 下の「送信の状態」を確認してください。",
"almostSubQueueOther": "{count} 件のメールが送信待ちです - 下の「送信の状態」を確認してください。"
},
@@ -554,6 +617,6 @@
"close": "閉じる",
"logsLoadFailed": "ログの読み込みに失敗しました",
"loadingLogs": "ログを読み込み中…",
- "noJournal": "このユニットのジャーナルエントリはありません。"
+ "noLogLines": "このコンポーネントのログ行はまだありません。"
}
}
diff --git a/apps/dashboard/src/i18n/locales/ja/servers.json b/apps/dashboard/src/i18n/locales/ja/servers.json
index d68466295..bd395a56f 100644
--- a/apps/dashboard/src/i18n/locales/ja/servers.json
+++ b/apps/dashboard/src/i18n/locales/ja/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "プラットフォームコンポーネント",
"subtitle": "サーバー全体のエッジとメール",
- "attention": "対応が必要: {n}",
+ "attentionOne": "対応が必要: 1",
+ "attentionMany": "対応が必要: {n}",
"updates": "更新あり: {n}",
"healthy": "正常: {n}",
"allHealthy": "すべてのコンポーネントは最新です",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "エッジなし",
"chipUpdateOne": "更新 1 件",
"chipUpdates": "更新 {n} 件",
- "started": "{n} 件のコンポーネントを更新中",
- "startedRestart": "{n} 件のコンポーネントを起動中",
- "skipped": "{n} 件はサーバーのページで対応が必要です",
+ "chipUpdating": "更新中",
+ "applyingOne": "1 件のコンポーネントを更新中",
+ "applyingMany": "{n} 件のコンポーネントを更新中",
+ "restartingOne": "1 件のコンポーネントを起動中",
+ "restartingMany": "{n} 件のコンポーネントを起動中",
+ "stateQueued": "待機中",
+ "stepPull": "イメージを取得中",
+ "stepRecreate": "再作成中",
+ "stepVerify": "確認中",
+ "moreTargets": "他 {n} 件",
+ "viewLogs": "ログを表示",
+ "doneOne": "1 件のコンポーネントを更新しました",
+ "doneMany": "{n} 件のコンポーネントを更新しました",
+ "failedOne": "1 件のコンポーネントが完了しませんでした",
+ "failedMany": "{n} 件のコンポーネントが完了しませんでした",
+ "skippedOne": "1 件はサーバーのページで対応が必要です",
+ "skippedMany": "{n} 件はサーバーのページで対応が必要です",
"nothingToDo": "適用する項目はありません",
"applyFailed": "更新を開始できませんでした"
}
diff --git a/apps/dashboard/src/i18n/locales/pt/deploy.json b/apps/dashboard/src/i18n/locales/pt/deploy.json
index 778f65bfd..f183ec8fe 100644
--- a/apps/dashboard/src/i18n/locales/pt/deploy.json
+++ b/apps/dashboard/src/i18n/locales/pt/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "Não disponível",
"domainTitle": "Domínio",
"domainHintProxy": "O seu servidor de e-mail já possui este hostname - vamos fazer proxy dele através do seu VPS de e-mail até a carga de trabalho do Opshcloud. Nenhuma alteração de DNS necessária.",
+ "domainHintMailHost": "O proprio nome de host do seu servidor de e-mail - ele ja aponta para ca e ja tem um certificado, entao nao ha nada a configurar. Use webmail. se preferir manter os dois separados.",
"domainHintCloud": "Aponte um CNAME para a URL *.opsh.io que provisionamos (você a verá após a implantação).",
"domainHintDefault": "A URL que os operadores visitarão. O DNS deve apontar para o destino da implantação.",
"summary": "Resumo",
diff --git a/apps/dashboard/src/i18n/locales/pt/emailsAdmin.json b/apps/dashboard/src/i18n/locales/pt/emailsAdmin.json
index 38bebe654..b2afbbd61 100644
--- a/apps/dashboard/src/i18n/locales/pt/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/pt/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "Visão geral",
"domains": "Domínios",
"mailboxes": "Caixas de correio",
+ "inbound": "Entrada",
"dns": "DNS",
"health": "Saúde",
"test": "Teste",
@@ -12,6 +13,56 @@
},
"ariaLabel": "Seções de administração de e-mail"
},
+ "inbound": {
+ "heading": "Regras de entrada",
+ "description": "Receba um aviso em um canal de notificação quando um e-mail chegar a um endereço deste servidor.",
+ "newRule": "Nova regra",
+ "newTitle": "Nova regra de entrada",
+ "editTitle": "Editar regra de entrada",
+ "edit": "Editar",
+ "emptyTitle": "Nenhuma regra de entrada ainda",
+ "emptyBody": "Adicione uma regra para ser notificado no Slack, Telegram, Discord ou em um webhook quando um e-mail chegar a uma caixa de correio ou a um domínio deste servidor.",
+ "noChannels": "Você ainda não tem canais de notificação verificados. Adicione e verifique um em Configurações → Notificações e depois volte aqui para criar uma regra.",
+ "fieldName": "Nome",
+ "namePlaceholder": "Caixa de suporte → #support",
+ "fieldScope": "Monitorar",
+ "scopeMailbox": "Uma caixa de correio",
+ "scopeDomain": "Um domínio inteiro",
+ "scopeAll": "Todos os domínios deste servidor",
+ "scopeAllSummary": "Todos os domínios",
+ "fieldAddress": "Endereço",
+ "fieldDomain": "Domínio",
+ "mailboxCaveat": "A correspondência usa os cabeçalhos To e Cc, então um e-mail que chegou a esta caixa de correio por Bcc ou através de um alias não vai corresponder. Monitore o domínio inteiro se você precisar de todas as mensagens.",
+ "fieldFrom": "Remetente contém",
+ "fieldSubject": "Assunto contém",
+ "patternPlaceholder": "opcional",
+ "patternHint": "Não diferencia maiúsculas de minúsculas. Use * como curinga — todo o resto é comparado literalmente.",
+ "fieldChannels": "Notificar estes canais",
+ "fieldEnabled": "Regra ativa",
+ "channelCount": "{count} canal(is)",
+ "colRule": "Regra",
+ "colWatch": "Monitorando",
+ "colChannels": "Canais",
+ "colStatus": "Status",
+ "active": "Ativa",
+ "disabled": "Desativada",
+ "paused": "Pausada",
+ "deleteTitle": "Excluir regra de entrada",
+ "deleteConfirm": "Excluir regra",
+ "deleting": "Excluindo…",
+ "deleteHint": "As notificações já enviadas não são afetadas. O e-mail que já foi capturado, mas ainda não processado, é descartado.",
+ "save": "Salvar regra",
+ "saving": "Salvando…",
+ "cancel": "Cancelar",
+ "test": "Testar agora",
+ "testing": "Testando…",
+ "testResult": "{read} mensagem(ns) capturada(s) lida(s): {matched} corresponderam a uma regra, {dropped} foram filtradas. Nada foi enviado nem excluído.",
+ "testFailed": "Não foi possível executar o teste.",
+ "loadFailed": "Não foi possível carregar as regras de entrada.",
+ "saveFailed": "Não foi possível salvar a regra.",
+ "deleteFailed": "Não foi possível excluir a regra.",
+ "confirmDelete": "Excluir a regra “{name}”? A captura do endereço dela é interrompida, a menos que outra regra ainda precise dela."
+ },
"overview": {
"mailServer": "Servidor de e-mail",
"protocolDetails": "Detalhes do protocolo →",
@@ -267,6 +318,11 @@
"logs": "Logs",
"openLogs": "Abrir logs",
"up": "Ativo {time}",
+ "optional": "Opcional",
+ "daemonHint": {
+ "fatal": "O supervisord desistiu - ele não vai tentar de novo por conta própria. Reinicie pelo menu.",
+ "backoff": "Está caindo repetidamente; o supervisord continua tentando. Veja os logs para entender o motivo."
+ },
"menu": {
"restart": "Reiniciar",
"restarting": "Reiniciando…",
@@ -284,7 +340,9 @@
"restartFailed": "falha ao reiniciar",
"startFailedTitle": "Falha ao iniciar {label}",
"stopFailedTitle": "Falha ao parar {label}",
- "restartFailedTitle": "Falha ao reiniciar {label}"
+ "restartFailedTitle": "Falha ao reiniciar {label}",
+ "notConfirmedTitle": "{label} não iniciou",
+ "notConfirmed": "O supervisor aceitou a solicitação, mas {label} está {state}. Abra os logs para ver o motivo."
},
"daemonStatus": {
"running": "Em execução",
@@ -292,6 +350,7 @@
"stopping": "Parando",
"stopped": "Parado",
"failed": "Com falha",
+ "crashed": "Travado",
"missing": "Ausente",
"unknown": "Desconhecido"
},
@@ -319,6 +378,10 @@
"partDnsOne": "{count} registro DNS ausente",
"partDnsOther": "{count} registros DNS ausentes",
"partDelivery": "o e-mail de saída não está sendo entregue",
+ "degradedLabel": "Funcionando, com proteção reduzida",
+ "partScanning": "{names} não está em execução - as novas mensagens estão sendo entregues SEM verificação de vírus",
+ "partSignatures": "{names} não está em execução - as assinaturas de vírus não são mais atualizadas, então o e-mail é verificado com um conjunto de assinaturas envelhecendo",
+ "partAdvisory": "{names} não está em execução",
"almostSubQueueOne": "{count} mensagem aguardando para sair - veja Envio de saída abaixo.",
"almostSubQueueOther": "{count} mensagens aguardando para sair - veja Envio de saída abaixo."
},
@@ -554,6 +617,6 @@
"close": "Fechar",
"logsLoadFailed": "Falha ao carregar os logs",
"loadingLogs": "Carregando logs…",
- "noJournal": "Nenhuma entrada de journal para esta unidade."
+ "noLogLines": "Ainda não há linhas de log para este componente."
}
}
diff --git a/apps/dashboard/src/i18n/locales/pt/servers.json b/apps/dashboard/src/i18n/locales/pt/servers.json
index da61f7583..f70b589cb 100644
--- a/apps/dashboard/src/i18n/locales/pt/servers.json
+++ b/apps/dashboard/src/i18n/locales/pt/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "Componentes da plataforma",
"subtitle": "Edge e e-mail em toda a sua frota",
- "attention": "{n} precisam de atenção",
+ "attentionOne": "1 precisa de atenção",
+ "attentionMany": "{n} precisam de atenção",
"updates": "{n} com atualizações",
"healthy": "{n} saudáveis",
"allHealthy": "Todos os componentes estão atualizados",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "sem edge",
"chipUpdateOne": "1 atualização",
"chipUpdates": "{n} atualizações",
- "started": "Atualizando {n} componentes",
- "startedRestart": "Iniciando {n} componentes",
- "skipped": "{n} precisam de atenção na própria página",
+ "chipUpdating": "atualizando",
+ "applyingOne": "Atualizando 1 componente",
+ "applyingMany": "Atualizando {n} componentes",
+ "restartingOne": "Iniciando 1 componente",
+ "restartingMany": "Iniciando {n} componentes",
+ "stateQueued": "Na fila",
+ "stepPull": "Baixando imagem",
+ "stepRecreate": "Recriando",
+ "stepVerify": "Verificando",
+ "moreTargets": "+{n} outros",
+ "viewLogs": "Ver registro",
+ "doneOne": "1 componente atualizado",
+ "doneMany": "{n} componentes atualizados",
+ "failedOne": "1 componente não concluiu",
+ "failedMany": "{n} componentes não concluíram",
+ "skippedOne": "1 precisa de atenção na própria página",
+ "skippedMany": "{n} precisam de atenção na própria página",
"nothingToDo": "Nada a aplicar",
"applyFailed": "Não foi possível iniciar as atualizações"
}
diff --git a/apps/dashboard/src/i18n/locales/tr/deploy.json b/apps/dashboard/src/i18n/locales/tr/deploy.json
index 7331b817f..7d15bcca8 100644
--- a/apps/dashboard/src/i18n/locales/tr/deploy.json
+++ b/apps/dashboard/src/i18n/locales/tr/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "Kullanılamıyor",
"domainTitle": "Alan Adı",
"domainHintProxy": "E-posta sunucunuz zaten bu host adına sahip - bunu e-posta VPS'iniz üzerinden Opshcloud iş yüküne vekil sunucu (proxy) olarak yönlendireceğiz. DNS değişikliği gerekmez.",
+ "domainHintMailHost": "Posta sunucunuzun kendi ana bilgisayar adi - zaten buraya isaret ediyor ve zaten bir sertifikasi var, yani ayarlanacak bir sey yok. Ikisini ayri tutmak isterseniz bunun yerine webmail. kullanin.",
"domainHintCloud": "Oluşturduğumuz *.opsh.io URL'sine bir CNAME yönlendirin (dağıtımdan sonra göreceksiniz).",
"domainHintDefault": "Operatörlerin ziyaret edeceği URL. DNS, dağıtım hedefine yönlenmelidir.",
"summary": "Özet",
diff --git a/apps/dashboard/src/i18n/locales/tr/emailsAdmin.json b/apps/dashboard/src/i18n/locales/tr/emailsAdmin.json
index 7dbafab8e..6a152df63 100644
--- a/apps/dashboard/src/i18n/locales/tr/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/tr/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "Genel Bakış",
"domains": "Alan Adları",
"mailboxes": "Posta Kutuları",
+ "inbound": "Gelen",
"dns": "DNS",
"health": "Sistem Sağlığı",
"test": "Test",
@@ -13,6 +14,56 @@
},
"ariaLabel": "E-posta yönetim bölümleri"
},
+ "inbound": {
+ "heading": "Gelen posta kuralları",
+ "description": "Bu sunucudaki bir adrese posta geldiğinde bir bildirim kanalından haberdar olun.",
+ "newRule": "Yeni kural",
+ "newTitle": "Yeni gelen posta kuralı",
+ "editTitle": "Gelen posta kuralını düzenle",
+ "edit": "Düzenle",
+ "emptyTitle": "Henüz gelen posta kuralı yok",
+ "emptyBody": "Bu sunucudaki bir posta kutusuna veya alan adına posta geldiğinde Slack, Telegram, Discord ya da bir webhook üzerinden bildirim almak için bir kural ekleyin.",
+ "noChannels": "Henüz doğrulanmış bildirim kanalınız yok. Ayarlar → Bildirimler kısmından bir kanal ekleyip doğrulayın, ardından kural oluşturmak için buraya dönün.",
+ "fieldName": "Ad",
+ "namePlaceholder": "Destek gelen kutusu → #destek",
+ "fieldScope": "İzlenecek",
+ "scopeMailbox": "Tek bir posta kutusu",
+ "scopeDomain": "Alan adının tamamı",
+ "scopeAll": "Bu sunucudaki tüm alan adları",
+ "scopeAllSummary": "Tüm alan adları",
+ "fieldAddress": "Adres",
+ "fieldDomain": "Alan Adı",
+ "mailboxCaveat": "Eşleştirme To ve Cc başlıklarına göre yapılır; bu nedenle bu posta kutusuna Bcc ile veya bir takma ad üzerinden ulaşan postalar eşleşmez. Her mesajın yakalanması gerekiyorsa alan adının tamamını izleyin.",
+ "fieldFrom": "Gönderen şunu içeriyor",
+ "fieldSubject": "Konu şunu içeriyor",
+ "patternPlaceholder": "isteğe bağlı",
+ "patternHint": "Büyük/küçük harf duyarsızdır. Joker karakter olarak * kullanın — diğer her şey birebir eşleştirilir.",
+ "fieldChannels": "Şu kanallara bildir",
+ "fieldEnabled": "Kural aktif",
+ "channelCount": "{count} kanal",
+ "colRule": "Kural",
+ "colWatch": "İzlenen",
+ "colChannels": "Kanallar",
+ "colStatus": "Durum",
+ "active": "Aktif",
+ "disabled": "Kapalı",
+ "paused": "Duraklatıldı",
+ "deleteTitle": "Gelen posta kuralını sil",
+ "deleteConfirm": "Kuralı sil",
+ "deleting": "Siliniyor…",
+ "deleteHint": "Gönderilmiş bildirimler etkilenmez. Yakalanmış ancak henüz işlenmemiş postalar silinir.",
+ "save": "Kuralı kaydet",
+ "saving": "Kaydediliyor…",
+ "cancel": "İptal",
+ "test": "Şimdi test et",
+ "testing": "Test ediliyor…",
+ "testResult": "Yakalanan {read} mesaj okundu: {matched} tanesi bir kuralla eşleşti, {dropped} tanesi filtrelendi. Hiçbir şey gönderilmedi veya silinmedi.",
+ "testFailed": "Test çalıştırılamadı.",
+ "loadFailed": "Gelen posta kuralları yüklenemedi.",
+ "saveFailed": "Kural kaydedilemedi.",
+ "deleteFailed": "Kural silinemedi.",
+ "confirmDelete": "“{name}” kuralı silinsin mi? Başka bir kural hâlâ ihtiyaç duymadığı sürece bu adres için yakalama durur."
+ },
"sending": {
"title": "Giden e-posta gönderimi",
"subtitle": "Gelen e-postalar bu sunucuda kalır. Giden postaların sunucudan nasıl gönderileceğini seçin.",
@@ -327,6 +378,11 @@
"logs": "Günlükler",
"openLogs": "Günlükleri aç",
"up": "Çalışma süresi: {time}",
+ "optional": "İsteğe bağlı",
+ "daemonHint": {
+ "fatal": "supervisord vazgeçti - kendi başına yeniden denemeyecek. Menüden yeniden başlatın.",
+ "backoff": "Sürekli çöküyor; supervisord denemeye devam ediyor. Nedenini günlüklerde görebilirsiniz."
+ },
"menu": {
"restart": "Yeniden başlat",
"restarting": "Yeniden başlatılıyor…",
@@ -344,7 +400,9 @@
"restartFailed": "yeniden başlatma başarısız oldu",
"startFailedTitle": "{label} başlatılamadı",
"stopFailedTitle": "{label} durdurulamadı",
- "restartFailedTitle": "{label} yeniden başlatılamadı"
+ "restartFailedTitle": "{label} yeniden başlatılamadı",
+ "notConfirmedTitle": "{label} başlamadı",
+ "notConfirmed": "Süpervizör isteği kabul etti, ancak {label} durumu {state}. Nedenini görmek için günlükleri açın."
},
"daemonStatus": {
"running": "Çalışıyor",
@@ -352,6 +410,7 @@
"stopping": "Durduruluyor",
"stopped": "Durduruldu",
"failed": "Başarısız",
+ "crashed": "Çöktü",
"missing": "Eksik",
"unknown": "Bilinmiyor"
},
@@ -379,6 +438,10 @@
"partDnsOne": "{count} DNS kaydı eksik",
"partDnsOther": "{count} DNS kaydı eksik",
"partDelivery": "giden posta teslim edilmiyor",
+ "degradedLabel": "Çalışıyor, ancak koruma azaldı",
+ "partScanning": "{names} çalışmıyor - yeni postalar virüs taraması YAPILMADAN teslim ediliyor",
+ "partSignatures": "{names} çalışmıyor - virüs imzaları artık güncellenmiyor, bu nedenle posta eskiyen bir imza kümesiyle taranıyor",
+ "partAdvisory": "{names} çalışmıyor",
"almostSubQueueOne": "{count} mesaj çıkmayı bekliyor - aşağıdaki Giden teslim bölümüne bakın.",
"almostSubQueueOther": "{count} mesaj çıkmayı bekliyor - aşağıdaki Giden teslim bölümüne bakın."
},
@@ -614,6 +677,6 @@
"close": "Kapat",
"logsLoadFailed": "Günlükler yüklenemedi",
"loadingLogs": "Günlükler yükleniyor…",
- "noJournal": "Bu birim için günlük kaydı yok."
+ "noLogLines": "Bu bileşen için henüz günlük satırı yok."
}
}
diff --git a/apps/dashboard/src/i18n/locales/tr/projectSettings.json b/apps/dashboard/src/i18n/locales/tr/projectSettings.json
index 7cb3a4ff0..904402729 100644
--- a/apps/dashboard/src/i18n/locales/tr/projectSettings.json
+++ b/apps/dashboard/src/i18n/locales/tr/projectSettings.json
@@ -88,6 +88,11 @@
"breadcrumbApps": "Uygulamalar",
"appEyebrow": "Uygulama",
"stopFailed": "Yükleme durdurulamadı",
+ "progressStep": "Adım {current} / {total}",
+ "progressServicesReady": "{total} servisten {done} tanesi hazır",
+ "summaryTitle": "Yapılandırma",
+ "summaryDestination": "Hedef",
+ "summaryServices": "Servisler",
"mail": {
"title": "E-posta kur",
"subtitle": "Kendi alan adınızda webmail dağıtın veya mevcut bir posta kutusunu bağlayın.",
diff --git a/apps/dashboard/src/i18n/locales/tr/servers.json b/apps/dashboard/src/i18n/locales/tr/servers.json
index af90f74d7..93d4c34af 100644
--- a/apps/dashboard/src/i18n/locales/tr/servers.json
+++ b/apps/dashboard/src/i18n/locales/tr/servers.json
@@ -69,7 +69,8 @@
"infra": {
"title": "Platform bileşenleri",
"subtitle": "Sunucularınızdaki edge ve e-posta",
- "attention": "{n} ilgi bekliyor",
+ "attentionOne": "1 bileşen ilgi bekliyor",
+ "attentionMany": "{n} ilgi bekliyor",
"updates": "{n} güncelleme bekliyor",
"healthy": "{n} sorunsuz",
"allHealthy": "Tüm bileşenler güncel",
@@ -88,9 +89,23 @@
"chipEdgeMissing": "edge yok",
"chipUpdateOne": "1 güncelleme",
"chipUpdates": "{n} güncelleme",
- "started": "{n} bileşen güncelleniyor",
- "startedRestart": "{n} bileşen başlatılıyor",
- "skipped": "{n} bileşen kendi sayfasında ilgi bekliyor",
+ "chipUpdating": "güncelleniyor",
+ "applyingOne": "1 bileşen güncelleniyor",
+ "applyingMany": "{n} bileşen güncelleniyor",
+ "restartingOne": "1 bileşen başlatılıyor",
+ "restartingMany": "{n} bileşen başlatılıyor",
+ "stateQueued": "Sırada",
+ "stepPull": "İmaj indiriliyor",
+ "stepRecreate": "Yeniden oluşturuluyor",
+ "stepVerify": "Doğrulanıyor",
+ "moreTargets": "+{n} tane daha",
+ "viewLogs": "Günlüğü gör",
+ "doneOne": "1 bileşen güncellendi",
+ "doneMany": "{n} bileşen güncellendi",
+ "failedOne": "1 bileşen tamamlanmadı",
+ "failedMany": "{n} bileşen tamamlanmadı",
+ "skippedOne": "1 bileşen kendi sayfasında ilgi bekliyor",
+ "skippedMany": "{n} bileşen kendi sayfasında ilgi bekliyor",
"nothingToDo": "Uygulanacak bir şey yok",
"applyFailed": "Güncellemeler başlatılamadı"
}
diff --git a/apps/dashboard/src/i18n/locales/zh/deploy.json b/apps/dashboard/src/i18n/locales/zh/deploy.json
index 8e829e8f7..c2cdb1c13 100644
--- a/apps/dashboard/src/i18n/locales/zh/deploy.json
+++ b/apps/dashboard/src/i18n/locales/zh/deploy.json
@@ -282,6 +282,7 @@
"notAvailable": "不可用",
"domainTitle": "域名",
"domainHintProxy": "你的邮件服务器已拥有此主机名——我们会通过你的邮件 VPS 将其代理到 Opshcloud 工作负载。无需更改 DNS。",
+ "domainHintMailHost": "这是邮件服务器自己的主机名 —— 它已经指向这里,也已经有证书,因此无需任何配置。如果希望两者分开,请改用 webmail.<你的域名>。",
"domainHintCloud": "将 CNAME 指向我们预置的 *.opsh.io URL(部署后你会看到它)。",
"domainHintDefault": "操作员将访问的 URL。DNS 必须指向部署目标。",
"summary": "摘要",
diff --git a/apps/dashboard/src/i18n/locales/zh/emailsAdmin.json b/apps/dashboard/src/i18n/locales/zh/emailsAdmin.json
index c500f512d..7956619a9 100644
--- a/apps/dashboard/src/i18n/locales/zh/emailsAdmin.json
+++ b/apps/dashboard/src/i18n/locales/zh/emailsAdmin.json
@@ -4,6 +4,7 @@
"overview": "概览",
"domains": "域名",
"mailboxes": "邮箱",
+ "inbound": "入站",
"dns": "DNS",
"health": "健康状态",
"test": "测试",
@@ -12,6 +13,56 @@
},
"ariaLabel": "邮件管理分区"
},
+ "inbound": {
+ "heading": "入站规则",
+ "description": "当邮件送达此服务器上的某个地址时,在通知渠道中收到提醒。",
+ "newRule": "新建规则",
+ "newTitle": "新建入站规则",
+ "editTitle": "编辑入站规则",
+ "edit": "编辑",
+ "emptyTitle": "暂无入站规则",
+ "emptyBody": "添加一条规则,当邮件送达此服务器上的某个邮箱或域名时,即可在 Slack、Telegram、Discord 或 webhook 中收到通知。",
+ "noChannels": "你还没有已验证的通知渠道。请先在 设置 → 通知 中添加并验证一个,然后回来创建规则。",
+ "fieldName": "名称",
+ "namePlaceholder": "支持收件箱 → #support",
+ "fieldScope": "监听范围",
+ "scopeMailbox": "单个邮箱",
+ "scopeDomain": "整个域名",
+ "scopeAll": "此服务器上的所有域名",
+ "scopeAllSummary": "所有域名",
+ "fieldAddress": "地址",
+ "fieldDomain": "域名",
+ "mailboxCaveat": "按 To 和 Cc 邮件头进行匹配,因此通过 Bcc 或别名送达此邮箱的邮件不会被匹配。如果需要覆盖每一封邮件,请监听整个域名。",
+ "fieldFrom": "From 包含",
+ "fieldSubject": "主题包含",
+ "patternPlaceholder": "可选",
+ "patternHint": "不区分大小写。使用 * 作为通配符——其余内容均按字面匹配。",
+ "fieldChannels": "通知这些渠道",
+ "fieldEnabled": "规则已启用",
+ "channelCount": "{count} 个渠道",
+ "colRule": "规则",
+ "colWatch": "监听对象",
+ "colChannels": "渠道",
+ "colStatus": "状态",
+ "active": "已开启",
+ "disabled": "已关闭",
+ "paused": "已暂停",
+ "deleteTitle": "删除入站规则",
+ "deleteConfirm": "删除规则",
+ "deleting": "删除中…",
+ "deleteHint": "现有通知不受影响。已捕获但尚未处理的邮件将被丢弃。",
+ "save": "保存规则",
+ "saving": "保存中…",
+ "cancel": "取消",
+ "test": "立即测试",
+ "testing": "测试中…",
+ "testResult": "已读取 {read} 封捕获的邮件:{matched} 封匹配到规则,{dropped} 封被过滤掉。未发送或删除任何邮件。",
+ "testFailed": "无法运行测试。",
+ "loadFailed": "无法加载入站规则。",
+ "saveFailed": "无法保存规则。",
+ "deleteFailed": "无法删除规则。",
+ "confirmDelete": "删除规则“{name}”?除非仍有其他规则需要,否则将停止捕获其地址的邮件。"
+ },
"overview": {
"mailServer": "邮件服务器",
"protocolDetails": "协议详情 →",
@@ -267,6 +318,11 @@
"logs": "日志",
"openLogs": "打开日志",
"up": "已运行 {time}",
+ "optional": "可选",
+ "daemonHint": {
+ "fatal": "supervisord 已放弃,不会自行重试。请从菜单中重启。",
+ "backoff": "反复崩溃,supervisord 仍在重试。查看日志了解原因。"
+ },
"menu": {
"restart": "重启",
"restarting": "重启中…",
@@ -284,7 +340,9 @@
"restartFailed": "重启失败",
"startFailedTitle": "{label} 启动失败",
"stopFailedTitle": "{label} 停止失败",
- "restartFailedTitle": "{label} 重启失败"
+ "restartFailedTitle": "{label} 重启失败",
+ "notConfirmedTitle": "{label} 尚未启动",
+ "notConfirmed": "监控进程已接受请求,但 {label} 当前为 {state}。请打开日志查看原因。"
},
"daemonStatus": {
"running": "运行中",
@@ -292,6 +350,7 @@
"stopping": "停止中",
"stopped": "已停止",
"failed": "失败",
+ "crashed": "已崩溃",
"missing": "缺失",
"unknown": "未知"
},
@@ -319,6 +378,10 @@
"partDnsOne": "缺少 {count} 条 DNS 记录",
"partDnsOther": "缺少 {count} 条 DNS 记录",
"partDelivery": "外发邮件未被投递",
+ "degradedLabel": "可用,但防护能力下降",
+ "partScanning": "{names} 未运行 - 新邮件正在未经病毒扫描的情况下投递",
+ "partSignatures": "{names} 未运行 - 病毒特征库不再更新,邮件将使用逐渐过期的特征库进行扫描",
+ "partAdvisory": "{names} 未运行",
"almostSubQueueOne": "{count} 封邮件等待外发 - 请查看下方的外发投递。",
"almostSubQueueOther": "{count} 封邮件等待外发 - 请查看下方的外发投递。"
},
@@ -554,6 +617,6 @@
"close": "关闭",
"logsLoadFailed": "加载日志失败",
"loadingLogs": "正在加载日志…",
- "noJournal": "此单元没有日志条目。"
+ "noLogLines": "此组件暂无日志内容。"
}
}
diff --git a/apps/dashboard/src/i18n/locales/zh/servers.json b/apps/dashboard/src/i18n/locales/zh/servers.json
index a7e443453..d01be1f64 100644
--- a/apps/dashboard/src/i18n/locales/zh/servers.json
+++ b/apps/dashboard/src/i18n/locales/zh/servers.json
@@ -70,7 +70,8 @@
"infra": {
"title": "平台组件",
"subtitle": "各服务器上的边缘与邮件",
- "attention": "{n} 台需要处理",
+ "attentionOne": "1 台需要处理",
+ "attentionMany": "{n} 台需要处理",
"updates": "{n} 台有更新",
"healthy": "{n} 台正常",
"allHealthy": "所有组件均为最新",
@@ -89,9 +90,23 @@
"chipEdgeMissing": "无边缘",
"chipUpdateOne": "1 项更新",
"chipUpdates": "{n} 项更新",
- "started": "正在更新 {n} 个组件",
- "startedRestart": "正在启动 {n} 个组件",
- "skipped": "{n} 个需要在其服务器页面处理",
+ "chipUpdating": "更新中",
+ "applyingOne": "正在更新 1 个组件",
+ "applyingMany": "正在更新 {n} 个组件",
+ "restartingOne": "正在启动 1 个组件",
+ "restartingMany": "正在启动 {n} 个组件",
+ "stateQueued": "排队中",
+ "stepPull": "正在拉取镜像",
+ "stepRecreate": "正在重建",
+ "stepVerify": "正在验证",
+ "moreTargets": "还有 {n} 个",
+ "viewLogs": "查看日志",
+ "doneOne": "已更新 1 个组件",
+ "doneMany": "已更新 {n} 个组件",
+ "failedOne": "1 个组件未完成",
+ "failedMany": "{n} 个组件未完成",
+ "skippedOne": "1 个需要在其服务器页面处理",
+ "skippedMany": "{n} 个需要在其服务器页面处理",
"nothingToDo": "没有可应用的内容",
"applyFailed": "无法开始更新"
}
diff --git a/apps/dashboard/src/lib/api/audit.ts b/apps/dashboard/src/lib/api/audit.ts
index 6799bd366..e7733405a 100644
--- a/apps/dashboard/src/lib/api/audit.ts
+++ b/apps/dashboard/src/lib/api/audit.ts
@@ -23,6 +23,10 @@ export interface AuditEventRow {
/** Human name for the resource, resolved server-side. Null when unnameable. */
resourceName: string | null;
source: AuditSource | null;
+ /** Which client of that surface — `oauth:` / `pat:`. MCP only. */
+ sourceClientId: string | null;
+ /** That client's display name, resolved server-side. Null when unresolvable. */
+ sourceClientName: string | null;
before: unknown;
after: unknown;
ipAddress: string | null;
@@ -41,6 +45,8 @@ export interface AuditFacets {
total: number;
categories: { id: string; label: string; description: string; count: number }[];
sources: { source: AuditSource | null; count: number }[];
+ /** MCP clients that appear in the window, busiest first. Capped server-side. */
+ clients: { id: string; name: string | null; count: number }[];
actors: AuditActor[];
settings: AuditSettings;
canManage: boolean;
@@ -58,6 +64,7 @@ export interface AuditQuery {
eventType?: string;
actorUserId?: string;
source?: AuditSource | "";
+ sourceClientId?: string;
resourceType?: string;
resourceId?: string;
/** ISO strings — the client owns "last 7 days" so presets follow local midnight. */
diff --git a/apps/dashboard/src/lib/api/endpoints.ts b/apps/dashboard/src/lib/api/endpoints.ts
index 8d7aa7c8d..40b72dd82 100644
--- a/apps/dashboard/src/lib/api/endpoints.ts
+++ b/apps/dashboard/src/lib/api/endpoints.ts
@@ -63,7 +63,8 @@ export const endpoints = {
ensure: "projects/ensure",
folderSession: "projects/folder/session",
folderScan: (sessionId: string) => `projects/folder/scan/${sessionId}`,
- // #336: real (unmasked) per-service env for the folder-scan wizard reveal.
+ // #336: POST { service, keys } — real (unmasked) values for one folder-scan
+ // service's named keys.
folderEnvReveal: (sessionId: string) => `projects/folder/scan/${sessionId}/env-reveal`,
folderUpload: (sessionId: string) => `projects/folder/upload/${sessionId}`,
},
@@ -116,7 +117,7 @@ export const endpoints = {
`projects/${projectId}/services/${serviceId}/env`,
envSet: (projectId: string | number, serviceId: string) =>
`projects/${projectId}/services/${serviceId}/env`,
- // #336: real (unmasked) compose env for the "show values" reveal.
+ // #336: POST { keys } — real (unmasked) values for the named keys only.
envReveal: (projectId: string | number, serviceId: string) =>
`projects/${projectId}/services/${serviceId}/env-reveal`,
},
@@ -318,6 +319,8 @@ export const endpoints = {
allContainersScan: () => `system/containers/scan`,
// Fleet bulk apply — server-derived targets, body only picks the intents
allContainersApply: () => `system/containers/apply-all`,
+ // Live fleet progress: queued/running applies + the ones that just settled
+ allContainersApplying: () => `system/containers/applying`,
// Per-server GitHub auth (self-hosted)
serverGithub: (id: string) => `system/servers/${id}/github`,
serverGithubConnect: (id: string) => `system/servers/${id}/github/connect`,
@@ -391,6 +394,12 @@ export const endpoints = {
`mail/admin/${encodeURIComponent(serverId)}/aliases`,
alias: (serverId: string, id: number) =>
`mail/admin/${encodeURIComponent(serverId)}/aliases/${id}`,
+ inboundRules: (serverId: string) =>
+ `mail/admin/${encodeURIComponent(serverId)}/inbound-rules`,
+ inboundRule: (serverId: string, ruleId: string) =>
+ `mail/admin/${encodeURIComponent(serverId)}/inbound-rules/${encodeURIComponent(ruleId)}`,
+ inboundRulesTest: (serverId: string) =>
+ `mail/admin/${encodeURIComponent(serverId)}/inbound-rules/test`,
stats: (serverId: string) =>
`mail/admin/${encodeURIComponent(serverId)}/stats`,
dnsScan: (serverId: string) =>
diff --git a/apps/dashboard/src/lib/api/folder.ts b/apps/dashboard/src/lib/api/folder.ts
index 9caeb14d2..a6076470c 100644
--- a/apps/dashboard/src/lib/api/folder.ts
+++ b/apps/dashboard/src/lib/api/folder.ts
@@ -47,11 +47,12 @@ export const folderApi = {
scan: (sessionId: string) =>
api.post(endpoints.projects.folderScan(sessionId), {}),
- /** #336: real (unmasked) per-service env for the scan wizard's reveal toggle,
- * keyed by service name. Write-gated (project:write) on the API. */
- reveal: (sessionId: string) =>
- api.get<{ success: boolean; environments: Record> }>(
+ /** #336: real (unmasked) values for ONE service's named env keys. Write-gated
+ * (project:write) on the API, which rejects an empty `keys`. */
+ reveal: (sessionId: string, service: string, keys: string[]) =>
+ api.post<{ success: boolean; environment: Record }>(
endpoints.projects.folderEnvReveal(sessionId),
+ { service, keys },
),
/** Upload the gzipped tarball to the session's target. Destination-agnostic. */
diff --git a/apps/dashboard/src/lib/api/index.ts b/apps/dashboard/src/lib/api/index.ts
index 748ecd6c8..60722708c 100644
--- a/apps/dashboard/src/lib/api/index.ts
+++ b/apps/dashboard/src/lib/api/index.ts
@@ -158,6 +158,10 @@ export type {
BulkRestartResult,
MailBackupPolicy,
SaveMailBackupPolicyInput,
+ InboundRule,
+ InboundRulePayload,
+ InboundScope,
+ InboundTestResult,
} from "./mail-admin";
export type {
MailSetupStep,
@@ -174,6 +178,7 @@ export type {
PortUsage,
MailComponentHealth,
MailComponentStatus,
+ MailComponentSeverity,
MailComponentDef,
MailHealthResponse,
MailDeliveryHealth,
diff --git a/apps/dashboard/src/lib/api/mail-admin.ts b/apps/dashboard/src/lib/api/mail-admin.ts
index 3d5c08723..a3594cded 100644
--- a/apps/dashboard/src/lib/api/mail-admin.ts
+++ b/apps/dashboard/src/lib/api/mail-admin.ts
@@ -10,10 +10,51 @@
import type { RelayProviderId } from "@repo/core";
import { api } from "./client";
import { endpoints } from "./endpoints";
-import type { DnsRecords, DnsRecord } from "./mail";
+import type { DnsRecords, DnsRecord, MailComponentStatus } from "./mail";
import type { DnsPlanResult, DnsProvisionResult } from "./dns";
import type { BackupRun } from "./backups";
+// ─── Inbound rules (mail arrives → notification channel) ────────────────────
+
+/** What a rule watches. `all` covers every domain on the server. */
+export type InboundScope = "mailbox" | "domain" | "all";
+
+export interface InboundRule {
+ id: string;
+ name: string;
+ scope: InboundScope;
+ /** Address for `mailbox`, domain for `domain`, null for `all`. */
+ target: string | null;
+ fromPattern: string | null;
+ subjectPattern: string | null;
+ maxSpamScore: number | null;
+ channelIds: string[];
+ enabled: boolean;
+ /** Set when burst control paused the rule; the reason is operator-facing. */
+ pausedReason: string | null;
+ lastMatchedAt: string | null;
+ createdAt: string;
+}
+
+export interface InboundRulePayload {
+ name: string;
+ scope: InboundScope;
+ target?: string | null;
+ fromPattern?: string | null;
+ subjectPattern?: string | null;
+ maxSpamScore?: number | null;
+ channelIds: string[];
+ enabled?: boolean;
+}
+
+export interface InboundTestResult {
+ read: number;
+ matched: number;
+ emitted: number;
+ dropped: number;
+ errors: string[];
+}
+
// ─── Mail backup (plugs into the general backup system) ──────────────────────
/** A mail-server backup policy. Source columns (projectId/serviceId) are
@@ -346,6 +387,22 @@ export const mailAdminApi = {
delete: (serverId: string, id: number) =>
api.delete<{ ok: boolean }>(endpoints.mail.admin.alias(serverId, id)),
},
+ inbound: {
+ list: (serverId: string) =>
+ api.get<{ rules: InboundRule[] }>(endpoints.mail.admin.inboundRules(serverId)),
+ create: (serverId: string, payload: InboundRulePayload) =>
+ api.post<{ rule: InboundRule }>(endpoints.mail.admin.inboundRules(serverId), payload),
+ update: (serverId: string, ruleId: string, payload: Partial) =>
+ api.patch<{ rule: InboundRule }>(
+ endpoints.mail.admin.inboundRule(serverId, ruleId),
+ payload,
+ ),
+ remove: (serverId: string, ruleId: string) =>
+ api.delete<{ ok: boolean }>(endpoints.mail.admin.inboundRule(serverId, ruleId)),
+ /** Dry run: reports what WOULD notify, dispatching and deleting nothing. */
+ test: (serverId: string) =>
+ api.post(endpoints.mail.admin.inboundRulesTest(serverId), {}),
+ },
stats: {
get: (serverId: string) =>
api.get(endpoints.mail.admin.stats(serverId)),
@@ -438,12 +495,25 @@ export interface ComponentActionResult {
unit: string;
action: ComponentAction;
output: string;
+ /**
+ * The daemon's state a moment after the supervisor accepted the job. Absent when
+ * the state was still transitional or the server couldn't re-probe — "accepted,
+ * not confirmed", which is not the same as a failure.
+ */
+ settled?: {
+ status: MailComponentStatus;
+ subState?: string;
+ activeSince?: string;
+ detail?: string;
+ };
}
export interface ComponentLogs {
key: string;
unit: string;
lines: string[];
+ /** The read the server performed, in display form. Never re-derived client-side. */
+ source: string;
}
export interface BulkRestartResult {
diff --git a/apps/dashboard/src/lib/api/mail.ts b/apps/dashboard/src/lib/api/mail.ts
index 0acab2564..5cf795e90 100644
--- a/apps/dashboard/src/lib/api/mail.ts
+++ b/apps/dashboard/src/lib/api/mail.ts
@@ -36,12 +36,28 @@ export type MailComponentStatus =
| "missing"
| "unknown";
+/**
+ * Whether a mail server stops being one without this daemon. Server-decided (the
+ * catalog in mail-health.service.ts) — never re-derived here from a key list, which
+ * would be a second definition free to drift from the install gate's.
+ *
+ * `informational` is reported but never graded: nothing on the stack consults it, so
+ * letting its state colour the banner produced an amber that was always wrong (GH-240 —
+ * spamd, while amavis scores spam in-process). Keep this union in step with the server's.
+ */
+export type MailComponentSeverity = "required" | "advisory" | "informational";
+
export interface MailComponentHealth {
key: string;
label: string;
description: string;
unit: string;
+ severity: MailComponentSeverity;
status: MailComponentStatus;
+ /**
+ * The supervisor's state word, lower-cased. `status: "failed"` covers both
+ * supervisord FATAL (given up) and BACKOFF (still retrying); this separates them.
+ */
subState?: string;
activeSince?: string;
/** Why the status is `unknown` — the probe's own output. */
@@ -53,6 +69,7 @@ export interface MailComponentDef {
label: string;
description: string;
unit: string;
+ severity: MailComponentSeverity;
}
/** How outbound mail leaves the box. */
diff --git a/apps/dashboard/src/lib/api/server-migration.ts b/apps/dashboard/src/lib/api/server-migration.ts
index 3188a8b0b..6fe397b32 100644
--- a/apps/dashboard/src/lib/api/server-migration.ts
+++ b/apps/dashboard/src/lib/api/server-migration.ts
@@ -354,9 +354,10 @@ export const dockerMigrationApi = {
})();
}),
- /** On-demand reveal of ONE discovered container's real env (scan masks it).
- * Write-gated (server:write) — same bar as the service-env reveal (#336). */
- revealEnv: (input: { serverId: string; containerId: string }) =>
+ /** On-demand reveal of the named env keys of ONE discovered container (the scan
+ * masks them). Write-gated (server:write) — same bar as the service-env reveal
+ * (#336); the API rejects an empty `keys`. */
+ revealEnv: (input: { serverId: string; containerId: string; keys: string[] }) =>
api.post<{ success: boolean; environment: Record }>(
endpoints.dockerMigration.revealEnv,
input,
diff --git a/apps/dashboard/src/lib/api/services.ts b/apps/dashboard/src/lib/api/services.ts
index 80af429ea..6a808af59 100644
--- a/apps/dashboard/src/lib/api/services.ts
+++ b/apps/dashboard/src/lib/api/services.ts
@@ -281,10 +281,12 @@ export const servicesApi = {
`${endpoints.services.envGet(projectId, serviceId)}${environment ? `?environment=${environment}` : ""}`,
),
- /** #336: real (unmasked) compose `environment` map — write-gated on the API. */
- revealEnv: (projectId: string | number, serviceId: string) =>
- api.get<{ success: boolean; environment: Record }>(
+ /** #336: real (unmasked) values for the named env keys ONLY — write-gated on
+ * the API, which rejects an empty `keys` (no "reveal everything" request). */
+ revealEnv: (projectId: string | number, serviceId: string, keys: string[]) =>
+ api.post<{ success: boolean; environment: Record }>(
endpoints.services.envReveal(projectId, serviceId),
+ { keys },
),
/** Set environment variables for a service */
diff --git a/apps/dashboard/src/lib/api/system.ts b/apps/dashboard/src/lib/api/system.ts
index 57044b596..a128f5350 100644
--- a/apps/dashboard/src/lib/api/system.ts
+++ b/apps/dashboard/src/lib/api/system.ts
@@ -135,7 +135,10 @@ export type HostChannelCode =
| "disabled"
| "not_configured"
| "key_unreadable"
- | "unreachable";
+ | "unreachable"
+ /** Reached, then the key was refused — #527. Distinct from `unreachable` because the
+ * remedy is re-authorizing a key, not opening a firewall. */
+ | "auth_rejected";
/**
* GET /servers/:id/reachability — liveness plus the reason.
@@ -333,6 +336,39 @@ export interface BulkApplyResult {
}>;
}
+/**
+ * One managed component the org is applying right now. `queued` means the bulk run
+ * accepted it but hasn't reached it yet (it has no session, so no steps); `running`
+ * carries the live step model and the session id a log view re-attaches to.
+ */
+export interface ContainerApplyActive {
+ serverId: string;
+ serverName: string;
+ component: "edge" | "mail";
+ state: "queued" | "running";
+ /** What the operator asked for. Null for a run whose cached row is gone. */
+ intent: ContainerApplyIntent | null;
+ sessionId?: string;
+ steps?: ContainerApplyStep[];
+ startedAt?: string;
+}
+
+/** An apply that finished moments ago — the only source of a "done" beat. */
+export interface ContainerApplySettled {
+ serverId: string;
+ serverName: string;
+ component: "edge" | "mail";
+ ok: boolean;
+ error?: string;
+ finishedAt: string;
+}
+
+/** Live fleet progress: what's in flight, and what just settled. */
+export interface ContainerApplyProgress {
+ active: ContainerApplyActive[];
+ recent: ContainerApplySettled[];
+}
+
/** One step of a container image swap, for the progress bar. */
export interface ContainerApplyStep {
id: "pull" | "recreate" | "verify";
@@ -891,6 +927,14 @@ export const systemApi = {
timeout: 120_000,
}),
+ /**
+ * Live progress for every apply the org has in flight, plus the ones that settled
+ * in the last minute or so. Cheap (cached rows + in-memory sessions) — polled only
+ * while something is running.
+ */
+ applyingContainers: () =>
+ api.get(endpoints.system.allContainersApplying()),
+
// ── Rate Limiting (per-server) ─────────────────────────────────────────────
/** Get rate limit config for a server */
diff --git a/apps/dashboard/src/lib/api/tokens.ts b/apps/dashboard/src/lib/api/tokens.ts
index 313e4a7c8..32158452d 100644
--- a/apps/dashboard/src/lib/api/tokens.ts
+++ b/apps/dashboard/src/lib/api/tokens.ts
@@ -11,6 +11,8 @@ export interface AccessToken {
scoped: boolean;
expiresAt: string | null;
lastUsedAt: string | null;
+ /** Requests made with this token. Approximate — the write is fire-and-forget. */
+ useCount: number;
revokedAt: string | null;
createdAt: string;
}
@@ -78,6 +80,10 @@ export interface McpClient {
grantCount: number;
authorizedAt: string;
lastUsedAt: string | null;
+ /** Requests this client has made. Approximate — the write is fire-and-forget. */
+ useCount: number;
+ /** Key for this client's audit rows (`?client=` on the audit tab). */
+ auditClientId: string;
}
/**
diff --git a/apps/dashboard/src/lib/infra-apply-status.test.ts b/apps/dashboard/src/lib/infra-apply-status.test.ts
new file mode 100644
index 000000000..700230df0
--- /dev/null
+++ b/apps/dashboard/src/lib/infra-apply-status.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ applyIntentOf,
+ applyKey,
+ applyPercent,
+ applyPhase,
+ summarizeSettled,
+} from "./infra-apply-status";
+import type { ContainerApplyActive, ContainerApplyStep } from "@/lib/api/system";
+
+type StepStatus = ContainerApplyStep["status"];
+
+/** The swap's fixed three-step model, with the statuses under test. */
+function steps(pull: StepStatus, recreate: StepStatus, verify: StepStatus): ContainerApplyStep[] {
+ return [
+ { id: "pull", label: "Pull", status: pull },
+ { id: "recreate", label: "Recreate", status: recreate },
+ { id: "verify", label: "Verify", status: verify },
+ ];
+}
+
+function target(over: Partial = {}): ContainerApplyActive {
+ return {
+ serverId: "srv_1",
+ serverName: "A",
+ component: "edge",
+ state: "running",
+ intent: "update",
+ sessionId: "capp_1",
+ steps: steps("running", "pending", "pending"),
+ ...over,
+ };
+}
+
+describe("applyPhase", () => {
+ it("reports a queued target as queued, session or not", () => {
+ expect(applyPhase(target({ state: "queued", steps: undefined, sessionId: undefined }))).toBe("queued");
+ });
+
+ it("follows the running step", () => {
+ expect(applyPhase(target())).toBe("pull");
+ expect(applyPhase(target({ steps: steps("done", "running", "pending") }))).toBe("recreate");
+ expect(applyPhase(target({ steps: steps("done", "done", "running") }))).toBe("verify");
+ });
+
+ it("falls forward to the first unfinished step when nothing is marked running", () => {
+ expect(applyPhase(target({ steps: steps("done", "pending", "pending") }))).toBe("recreate");
+ });
+
+ it("stays on the last step once every step has settled", () => {
+ expect(applyPhase(target({ steps: steps("done", "done", "done") }))).toBe("verify");
+ expect(applyPhase(target({ steps: steps("error", "error", "error") }))).toBe("verify");
+ });
+
+ it("never narrates a pull for a repair — a restart has no image to fetch", () => {
+ expect(applyPhase(target({ intent: "repair", steps: steps("pending", "pending", "pending") }))).toBe(
+ "starting",
+ );
+ });
+
+ it("assumes the first step when a session exists but has sent no steps yet", () => {
+ expect(applyPhase(target({ steps: [] }))).toBe("pull");
+ });
+});
+
+describe("applyPercent", () => {
+ it("is zero with nothing in flight", () => {
+ expect(applyPercent([])).toBe(0);
+ });
+
+ it("floors at 4% so a just-started run still reads as started", () => {
+ expect(applyPercent([target({ state: "queued", steps: undefined })])).toBe(4);
+ expect(applyPercent([target({ steps: steps("pending", "pending", "pending") })])).toBe(4);
+ });
+
+ it("counts a running step as half its weight", () => {
+ // pull running = 0.5/3 → 17%
+ expect(applyPercent([target()])).toBe(17);
+ // pull done, recreate running = 1.5/3 → 50%
+ expect(applyPercent([target({ steps: steps("done", "running", "pending") })])).toBe(50);
+ });
+
+ it("caps at 99 — finished work leaves the set instead of reading 100", () => {
+ expect(applyPercent([target({ steps: steps("done", "done", "done") })])).toBe(99);
+ });
+
+ it("weighs every target equally, so a queue drags the total down honestly", () => {
+ const percent = applyPercent([
+ target({ steps: steps("done", "done", "done") }),
+ target({ serverId: "srv_2", state: "queued", steps: undefined }),
+ ]);
+ expect(percent).toBe(50);
+ });
+});
+
+describe("applyIntentOf", () => {
+ it("reads as a restart only when every target is one", () => {
+ expect(applyIntentOf([target({ intent: "repair" })])).toBe("repair");
+ expect(applyIntentOf([target({ intent: "repair" }), target({ serverId: "srv_2" })])).toBe("update");
+ // A run whose cached row is gone carries no intent — treat it as an update.
+ expect(applyIntentOf([target({ intent: null })])).toBe("update");
+ });
+});
+
+describe("summarizeSettled", () => {
+ const settled = (serverId: string, ok: boolean, error?: string, at = "2026-08-14T00:00:00.000Z") => ({
+ serverId,
+ serverName: serverId,
+ component: "edge" as const,
+ ok,
+ ...(error ? { error } : {}),
+ finishedAt: at,
+ });
+
+ it("counts only the runs this client watched", () => {
+ const watched = new Set([applyKey("srv_1", "edge")]);
+ expect(summarizeSettled([settled("srv_1", true), settled("srv_2", true)], watched)).toEqual({
+ done: 1,
+ failed: 0,
+ });
+ });
+
+ it("is null when nothing watched has settled", () => {
+ expect(summarizeSettled([settled("srv_9", true)], new Set([applyKey("srv_1", "edge")]))).toBeNull();
+ expect(summarizeSettled([], new Set([applyKey("srv_1", "edge")]))).toBeNull();
+ });
+
+ it("splits failures out and carries the first reason", () => {
+ const watched = new Set([applyKey("srv_1", "edge"), applyKey("srv_2", "edge")]);
+ expect(
+ summarizeSettled([settled("srv_1", true), settled("srv_2", false, "pull failed")], watched),
+ ).toEqual({ done: 1, failed: 1, error: "pull failed" });
+ });
+
+ it("counts one finish per component, however often it is reported", () => {
+ const watched = new Set([applyKey("srv_1", "edge")]);
+ expect(summarizeSettled([settled("srv_1", true), settled("srv_1", true)], watched)).toEqual({
+ done: 1,
+ failed: 0,
+ });
+ });
+
+ it("reports the newest attempt when a component was applied twice in the window", () => {
+ const watched = new Set([applyKey("srv_1", "edge")]);
+ const older = settled("srv_1", false, "pull failed", "2026-08-14T00:00:00.000Z");
+ const newer = settled("srv_1", true, undefined, "2026-08-14T00:02:00.000Z");
+ expect(summarizeSettled([older, newer], watched)).toEqual({ done: 1, failed: 0 });
+ // Order in the payload must not decide it.
+ expect(summarizeSettled([newer, older], watched)).toEqual({ done: 1, failed: 0 });
+ });
+});
diff --git a/apps/dashboard/src/lib/infra-apply-status.ts b/apps/dashboard/src/lib/infra-apply-status.ts
new file mode 100644
index 000000000..10528c319
--- /dev/null
+++ b/apps/dashboard/src/lib/infra-apply-status.ts
@@ -0,0 +1,112 @@
+import type {
+ ContainerApplyActive,
+ ContainerApplySettled,
+ ContainerApplyStep,
+} from "@/lib/api/system";
+
+/**
+ * Fleet apply progress, as arithmetic — the pure half of the managed-container
+ * status the Servers-tab roll-up renders while edge/mail swaps are running.
+ *
+ * Kept out of the hook and the card so the two things that are easy to get wrong
+ * are testable on their own: which phase word a target is in (a RESTART has no
+ * image to pull, so the swap step model doesn't describe it), and a percentage
+ * that never claims 100 while work is still in flight.
+ */
+
+/** Stable identity for one (server, component) apply across polls. */
+export function applyKey(serverId: string, component: "edge" | "mail"): string {
+ return `${serverId}:${component}`;
+}
+
+/** The phase a target is in, as a copy key rather than a server-sent English label. */
+export type ApplyPhase = "queued" | "starting" | "pull" | "recreate" | "verify";
+
+const SETTLED: ReadonlySet = new Set(["done", "error"]);
+
+/**
+ * What this target is doing right now.
+ *
+ * A queued target has no session at all. A REPAIR reuses the swap's three-step
+ * model but performs none of it (nothing is pulled to start a stopped container),
+ * so it reports the neutral "starting" phase instead of narrating a pull that
+ * isn't happening. Everything else reads the step model: the running step, else
+ * the first unfinished one, else the last (all done — the row is about to settle).
+ */
+export function applyPhase(target: ContainerApplyActive): ApplyPhase {
+ if (target.state === "queued") return "queued";
+ if (target.intent === "repair") return "starting";
+ const steps = target.steps ?? [];
+ if (steps.length === 0) return "pull";
+ return (steps.find((s) => s.status === "running") ?? steps.find((s) => !SETTLED.has(s.status)) ?? steps[steps.length - 1]!).id;
+}
+
+/**
+ * One percentage for the whole in-flight set: every target weighs the same, and a
+ * running step counts as half its own weight so a long pull still moves the bar.
+ *
+ * Clamped to 4..99 while anything is in flight — the same rule the install
+ * progress panel uses. Never 100: 100 means finished, and finished is when the
+ * target leaves this set.
+ */
+export function applyPercent(active: ContainerApplyActive[]): number {
+ if (active.length === 0) return 0;
+ let sum = 0;
+ for (const target of active) {
+ const steps = target.steps ?? [];
+ if (target.state === "queued" || steps.length === 0) continue;
+ const done = steps.filter((s) => SETTLED.has(s.status)).length;
+ const running = steps.filter((s) => s.status === "running").length;
+ sum += (done + running * 0.5) / steps.length;
+ }
+ return Math.min(99, Math.max(4, Math.round((sum / active.length) * 100)));
+}
+
+/** Which intent the in-flight set is mostly about — picks the headline wording. */
+export function applyIntentOf(active: ContainerApplyActive[]): "update" | "repair" {
+ return active.some((t) => t.intent !== "repair") ? "update" : "repair";
+}
+
+export interface ApplyOutcome {
+ done: number;
+ failed: number;
+ /** First failure reason, for the line under the count. */
+ error?: string;
+}
+
+/**
+ * Roll settled applies up into the "done" beat, restricted to the runs this client
+ * actually watched.
+ *
+ * The restriction is the point: the endpoint reports everything that finished in
+ * its window, so without it a surface mounted after the fact would announce work
+ * the operator never started here.
+ *
+ * One result per (server, component), and the LAST one wins: a component applied
+ * twice inside the reporting window appears twice, and the older attempt must not
+ * be the one that gets announced.
+ */
+export function summarizeSettled(
+ recent: ContainerApplySettled[],
+ watched: ReadonlySet,
+): ApplyOutcome | null {
+ const latest = new Map();
+ for (const entry of recent) {
+ const key = applyKey(entry.serverId, entry.component);
+ if (!watched.has(key)) continue;
+ const held = latest.get(key);
+ if (!held || held.finishedAt <= entry.finishedAt) latest.set(key, entry);
+ }
+ let done = 0;
+ let failed = 0;
+ let error: string | undefined;
+ for (const entry of latest.values()) {
+ if (entry.ok) done++;
+ else {
+ failed++;
+ error ??= entry.error;
+ }
+ }
+ if (done === 0 && failed === 0) return null;
+ return { done, failed, ...(error ? { error } : {}) };
+}
diff --git a/apps/dashboard/src/lib/infra-fleet-state.test.ts b/apps/dashboard/src/lib/infra-fleet-state.test.ts
new file mode 100644
index 000000000..ac321f84c
--- /dev/null
+++ b/apps/dashboard/src/lib/infra-fleet-state.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from "vitest";
+
+import { summarizeInfraFleet } from "./infra-fleet-state";
+import { applyKey } from "./infra-apply-status";
+import type { ServerContainerGroup, ServerContainerStatus } from "@/lib/api/system";
+
+function row(over: Partial = {}): ServerContainerStatus {
+ return {
+ id: "scs_1",
+ serverId: "srv_1",
+ component: "edge",
+ runningLabel: "edge:0.4.0",
+ pinnedLabel: "edge:0.5.0",
+ runningVersion: "0.4.0",
+ pinnedVersion: "0.5.0",
+ behind: false,
+ latestInProgress: false,
+ detail: null,
+ checkedAt: "2026-08-14T00:00:00.000Z",
+ ...over,
+ };
+}
+
+function group(
+ serverId: string,
+ components: ServerContainerStatus[],
+ projectCount = 1,
+): ServerContainerGroup {
+ return {
+ server: { id: serverId, name: serverId, sshHost: "10.0.0.1", isLocal: false, projectCount },
+ components: components.map((c) => ({ ...c, serverId })),
+ };
+}
+
+describe("summarizeInfraFleet", () => {
+ it("has nothing to say about an unread fleet", () => {
+ const { summaries, counts } = summarizeInfraFleet(null);
+ expect(summaries.size).toBe(0);
+ expect(counts).toEqual({
+ attention: 0,
+ updates: 0,
+ healthy: 0,
+ stopped: 0,
+ behind: 0,
+ applying: 0,
+ });
+ });
+
+ it("counts a behind component as offerable work", () => {
+ const { counts, summaries } = summarizeInfraFleet([group("srv_1", [row({ behind: true })])]);
+ expect(counts).toMatchObject({ updates: 1, behind: 1, healthy: 0, applying: 0 });
+ expect(summaries.get("srv_1")).toMatchObject({ bucket: "updates", updates: 1 });
+ });
+
+ it("ranks a down component above an update", () => {
+ const { counts, summaries } = summarizeInfraFleet([
+ group("srv_1", [row({ detail: { down: true } }), row({ component: "mail", behind: true })]),
+ ]);
+ expect(counts).toMatchObject({ attention: 1, updates: 0, stopped: 1, behind: 1 });
+ expect(summaries.get("srv_1")!.bucket).toBe("attention");
+ });
+
+ it("does not offer a gone container as restartable", () => {
+ const { counts, summaries } = summarizeInfraFleet([
+ group("srv_1", [row({ component: "mail", detail: { down: true, containerMissing: true } })]),
+ ]);
+ expect(counts).toMatchObject({ attention: 1, stopped: 0 });
+ expect(summaries.get("srv_1")).toMatchObject({ missing: ["mail"], down: [] });
+ });
+
+ it("treats an absent edge as an issue only where projects are deployed", () => {
+ expect(summarizeInfraFleet([group("srv_1", [], 2)]).counts).toMatchObject({ attention: 1 });
+ expect(summarizeInfraFleet([group("srv_1", [], 0)]).counts).toMatchObject({
+ attention: 0,
+ healthy: 1,
+ });
+ });
+});
+
+describe("work already underway", () => {
+ it("is never offered again — an in-flight update leaves `behind`", () => {
+ const { counts, summaries } = summarizeInfraFleet([
+ group("srv_1", [row({ behind: true, latestInProgress: true })]),
+ ]);
+ expect(counts).toMatchObject({ behind: 0, applying: 1 });
+ expect(summaries.get("srv_1")).toMatchObject({ updates: 0, applying: 1 });
+ });
+
+ it("is never offered again — an in-flight restart leaves `stopped`", () => {
+ const { counts, summaries } = summarizeInfraFleet([
+ group("srv_1", [row({ detail: { down: true }, latestInProgress: true })]),
+ ]);
+ expect(counts).toMatchObject({ stopped: 0, applying: 1 });
+ expect(summaries.get("srv_1")!.down).toEqual([]);
+ });
+
+ it("keeps the server in the lane it was in, so no result is claimed early", () => {
+ // Mid-update → still "has updates", not "healthy".
+ expect(
+ summarizeInfraFleet([group("srv_1", [row({ behind: true, latestInProgress: true })])]).counts,
+ ).toMatchObject({ updates: 1, healthy: 0 });
+ // Mid-restart → still "needs attention": it is stopped until the restart lands.
+ expect(
+ summarizeInfraFleet([
+ group("srv_1", [row({ detail: { down: true }, latestInProgress: true })]),
+ ]).counts,
+ ).toMatchObject({ attention: 1, healthy: 0 });
+ });
+
+ it("counts a run the progress read knows about but the row does not", () => {
+ // A cached row dropped mid-swap (or a flag that hasn't been re-read yet) must not
+ // read as idle while the API is plainly still applying it.
+ const live = new Set([applyKey("srv_1", "edge")]);
+ const { counts, summaries } = summarizeInfraFleet(
+ [group("srv_1", [row({ behind: true })])],
+ live,
+ );
+ expect(counts).toMatchObject({ behind: 0, applying: 1 });
+ expect(summaries.get("srv_1")!.applying).toBe(1);
+ });
+
+ it("still offers the components that are NOT underway", () => {
+ const { counts } = summarizeInfraFleet([
+ group("srv_1", [row({ behind: true, latestInProgress: true })]),
+ group("srv_2", [row({ behind: true })]),
+ ]);
+ expect(counts).toMatchObject({ updates: 2, behind: 1, applying: 1 });
+ });
+
+ it("leaves a healthy fleet clean once the work lands", () => {
+ const { counts } = summarizeInfraFleet([
+ group("srv_1", [row(), row({ component: "mail" })]),
+ group("srv_2", [row()]),
+ ]);
+ expect(counts).toMatchObject({ attention: 0, updates: 0, healthy: 2, applying: 0 });
+ });
+});
diff --git a/apps/dashboard/src/lib/infra-fleet-state.ts b/apps/dashboard/src/lib/infra-fleet-state.ts
new file mode 100644
index 000000000..bf0835dd2
--- /dev/null
+++ b/apps/dashboard/src/lib/infra-fleet-state.ts
@@ -0,0 +1,117 @@
+import type { ServerContainerGroup } from "@/lib/api/system";
+import { applyKey } from "@/lib/infra-apply-status";
+
+/**
+ * Fleet state, derived once — what every managed-container surface on the Servers tab
+ * renders from: the roll-up counts, the filter segments, and each row's chip.
+ *
+ * It lives here, as one function, because those three used to derive it separately and
+ * could therefore disagree: the card offering "Update all (1)" for a swap already
+ * running, next to a row chip still advertising the same update as available.
+ *
+ * The rule in one line: a component with an apply in flight is counted as in flight
+ * and nowhere else — but its SERVER keeps the lane it was in, because a stopped
+ * component is still stopped until its restart lands and an update is still due until
+ * its swap does.
+ */
+
+export type InfraBucket = "attention" | "updates" | "healthy";
+
+/** What one server's managed containers add up to, for chips and filtering. */
+export interface InfraServerSummary {
+ /** Down components that still exist (a one-click restart), by component. */
+ down: ("edge" | "mail")[];
+ /** Components running an older image than we pin. */
+ updates: number;
+ /** Down but gone — the fix is the setup path, so it's not bulk-restartable. */
+ missing: ("edge" | "mail")[];
+ /** No edge row at all AND projects deployed here → the edge needs installing. */
+ edgeAbsent: boolean;
+ /** Components mid-apply. Excluded from every other field here. */
+ applying: number;
+ /** Which lane this server is in. */
+ bucket: InfraBucket;
+}
+
+export interface InfraCounts {
+ /** Servers per lane. */
+ attention: number;
+ updates: number;
+ healthy: number;
+ /** Bulk-restartable components (stopped in place, not already being restarted). */
+ stopped: number;
+ /** Behind components not already being updated — what "Update all (N)" acts on. */
+ behind: number;
+ /** Components mid-apply, fleet-wide — what the live status renders. */
+ applying: number;
+}
+
+export interface InfraFleetState {
+ summaries: Map;
+ counts: InfraCounts;
+}
+
+/**
+ * @param groups the cached rows, per server
+ * @param live (server, component) keys the progress read says are in flight —
+ * union'd with the rows' own flag, so a run whose cached row was
+ * dropped mid-swap still counts as in flight
+ */
+export function summarizeInfraFleet(
+ groups: ServerContainerGroup[] | null,
+ live: ReadonlySet = new Set(),
+): InfraFleetState {
+ const summaries = new Map();
+ for (const g of groups ?? []) {
+ const down: ("edge" | "mail")[] = [];
+ const missing: ("edge" | "mail")[] = [];
+ let updates = 0;
+ /** In-flight work, split by the lane it came from. */
+ let applyingUpdate = 0;
+ let applyingDown = 0;
+ for (const r of g.components) {
+ const inFlight = r.latestInProgress || live.has(applyKey(g.server.id, r.component));
+ if (r.behind) {
+ if (inFlight) applyingUpdate++;
+ else updates++;
+ } else if (r.detail?.down) {
+ if (inFlight) applyingDown++;
+ else (r.detail.containerMissing ? missing : down).push(r.component);
+ }
+ }
+ const edgeAbsent =
+ g.server.projectCount > 0 && !g.components.some((r) => r.component === "edge");
+ const bucket: InfraBucket =
+ down.length + missing.length > 0 || edgeAbsent || applyingDown > 0
+ ? "attention"
+ : updates > 0 || applyingUpdate > 0
+ ? "updates"
+ : "healthy";
+ summaries.set(g.server.id, {
+ down,
+ updates,
+ missing,
+ edgeAbsent,
+ applying: applyingUpdate + applyingDown,
+ bucket,
+ });
+ }
+
+ const counts: InfraCounts = {
+ attention: 0,
+ updates: 0,
+ healthy: 0,
+ stopped: 0,
+ behind: 0,
+ applying: 0,
+ };
+ for (const s of summaries.values()) {
+ if (s.bucket === "attention") counts.attention++;
+ else if (s.bucket === "updates") counts.updates++;
+ else counts.healthy++;
+ counts.stopped += s.down.length;
+ counts.behind += s.updates;
+ counts.applying += s.applying;
+ }
+ return { summaries, counts };
+}
diff --git a/apps/dashboard/src/lib/sidebar-nav.test.ts b/apps/dashboard/src/lib/sidebar-nav.test.ts
index 1058bce21..fd1377392 100644
--- a/apps/dashboard/src/lib/sidebar-nav.test.ts
+++ b/apps/dashboard/src/lib/sidebar-nav.test.ts
@@ -17,7 +17,7 @@ import {
*
* One: the mail section is a function of mail state, in lockstep with
* `resolveMailView()` (`emails/_lib/view-gate.ts`). Drift either way is a rail of
- * links that all land somewhere else — ten tabs on a box with no mail server, or
+ * links that all land somewhere else — every tab on a box with no mail server, or
* a lone "Set up mail" on a working one.
*
* Two: every label key the rail emits actually exists in the English dictionary.
@@ -25,7 +25,7 @@ import {
* doesn't fail a build or a type check — it silently renders the raw key, and
* `?? key` means even the fallback looks intentional.
*
- * Three: all ten tabs are still reachable after the grouping. They're spread over
+ * Three: every tab is still reachable after the grouping. They're spread over
* three headings now, and a tab dropped from one group without being added to
* another would just quietly vanish from the rail.
*/
@@ -46,11 +46,12 @@ const mailAt = (input: Partial = {}): NavSection[] =>
...input,
});
-const TEN_TABS = [
+const ALL_TABS = [
"overview",
"domains",
"mailboxes",
"aliases",
+ "inbound",
"dns",
"health",
"test",
@@ -60,9 +61,9 @@ const TEN_TABS = [
];
describe("getNavSections (the platform rail)", () => {
- it("is unchanged by mail mode: main + settings + infrastructure", () => {
+ it("is unchanged by mail mode: main + infrastructure + settings", () => {
const s = getNavSections(false, true);
- expect(sectionsOf(s)).toEqual(["main", "settings", "infrastructure"]);
+ expect(sectionsOf(s)).toEqual(["main", "infrastructure", "settings"]);
expect(keysOf(find(s, "main"))).toEqual([
"home",
"projects",
@@ -70,8 +71,8 @@ describe("getNavSections (the platform rail)", () => {
"deployments",
"issues",
]);
- expect(keysOf(find(s, "settings"))).toEqual(["backups", "settings"]);
expect(keysOf(find(s, "infrastructure"))).toEqual(["servers", "emails", "jobs"]);
+ expect(keysOf(find(s, "settings"))).toEqual(["backups", "settings"]);
});
it("adds Billing on the SaaS and drops the infrastructure section there", () => {
@@ -81,6 +82,18 @@ describe("getNavSections (the platform rail)", () => {
expect(find(s, "infrastructure")).toBeUndefined();
});
+ it("keeps Billing at the very bottom, below Servers, on a cloud-linked self-hosted box", () => {
+ // isSaaS goes true the moment a self-hosted install links a cloud account, which
+ // is the case that put Billing above Servers. Nothing in the rail may follow it,
+ // and the host rows must all precede it.
+ const s = getNavSections(true, true);
+ const keys = s.flatMap((x) => keysOf(x));
+ expect(keys.at(-1)).toBe("billing");
+ for (const host of ["servers", "emails", "jobs"]) {
+ expect(keys.indexOf(host), host).toBeLessThan(keys.indexOf("billing"));
+ }
+ });
+
it("keeps /emails in the platform rail", () => {
// Mail mode promotes the tabs, it doesn't move the page: an operator on the
// full platform still reaches mail the way they always did.
@@ -117,13 +130,14 @@ describe("getMailNavSections (the Openship Mail rail)", () => {
expect(mail?.items[0]?.href).toBe("/emails?serverId=srv1");
});
- it("splits the ten tabs across three headings once the server is completed", () => {
+ it("splits every tab across three headings once the server is completed", () => {
const s = mailAt();
expect(keysOf(find(s, "mail"))).toEqual([
"overview",
"domains",
"mailboxes",
"aliases",
+ "inbound",
"webmail",
]);
// Sending leads — it's the decision the other three check. Same order as the
@@ -133,10 +147,10 @@ describe("getMailNavSections (the Openship Mail rail)", () => {
expect(keysOf(find(s, "infrastructure"))).toEqual(["servers", "jobs", "backup", "advanced"]);
});
- it("still reaches all ten tabs, and every one is a ?tab= link", () => {
+ it("still reaches every tab, and every one is a ?tab= link", () => {
const s = mailAt();
const tabItems = s.flatMap((x) => x.items).filter((i) => i.labelSource === "mailTab");
- expect(tabItems.map((i) => i.key).sort()).toEqual([...TEN_TABS].sort());
+ expect(tabItems.map((i) => i.key).sort()).toEqual([...ALL_TABS].sort());
for (const item of tabItems) {
expect(item.tab).toBe(item.key);
expect(item.href).toBe(`/emails?serverId=srv1&tab=${item.key}`);
@@ -178,7 +192,9 @@ describe("getMailNavSections (the Openship Mail rail)", () => {
// The point of the grouping: ten flat mail entries buried Servers at row 11.
const keys = mailAt().flatMap((x) => keysOf(x));
expect(keys.indexOf("servers")).toBeLessThan(keys.indexOf("backup"));
- expect(keys.indexOf("servers")).toBe(9);
+ // Index-pinned on purpose, and it moves whenever a mail tab is added ahead of the
+ // infrastructure group — Inbound (after Aliases) is what took it from 9 to 10.
+ expect(keys.indexOf("servers")).toBe(10);
});
describe("Webmail — the one mail entry that is a project, not a tab", () => {
@@ -298,7 +314,7 @@ describe("isNavItemActive", () => {
it("keeps the webmail route and the mail tabs from lighting each other", () => {
// Two entries share the /emails prefix but nothing else: Webmail is a path
- // item at /emails/webmail, the ten tabs are query items at /emails.
+ // item at /emails/webmail, the tabs are query items at /emails.
const webmail = pathItem("/emails/webmail?serverId=srv1");
expect(isNavItemActive(webmail, "/emails/webmail", null)).toBe(true);
expect(isNavItemActive(webmail, "/emails", null)).toBe(false);
diff --git a/apps/dashboard/src/lib/sidebar-nav.ts b/apps/dashboard/src/lib/sidebar-nav.ts
index 4880fadf9..ceed349a0 100644
--- a/apps/dashboard/src/lib/sidebar-nav.ts
+++ b/apps/dashboard/src/lib/sidebar-nav.ts
@@ -25,6 +25,7 @@ import {
FileText,
FolderKanban,
Forward,
+ Inbox,
Globe,
HeartPulse,
LayoutDashboard,
@@ -95,6 +96,12 @@ export function getNavSections(isSaaS: boolean, selfHosted: boolean): NavSection
{ key: "backups", href: "/backups", icon: DatabaseBackup },
{ key: "settings", href: "/settings", icon: Settings },
];
+ // LAST row of the LAST section, deliberately. `isSaaS` is true on a self-hosted box
+ // the moment it links a cloud account (`!selfHosted || cloudConnected` in
+ // sidebar.tsx), and Billing sitting mid-rail there read as "this install is
+ // metered" — above Servers, which is what an operator on their own machine
+ // actually came for. Cloud credits are real, so the entry stays; it just stops
+ // outranking the infrastructure.
if (isSaaS) {
settingsItems.push({ key: "billing", href: "/billing", icon: CreditCard });
}
@@ -110,10 +117,13 @@ export function getNavSections(isSaaS: boolean, selfHosted: boolean): NavSection
// { key: "domains", href: "/domains", icon: Globe },
// );
+ // Infrastructure ahead of settings: self-hosted, Servers is the second thing you
+ // reach for after Projects, and it used to sit below Backups/Settings/Billing.
+ // On the SaaS the group is empty and filters out, so the rail there is unchanged.
return [
{ section: "main", items: MAIN_ITEMS },
- { section: "settings", items: settingsItems },
{ section: "infrastructure", items: infraItems },
+ { section: "settings", items: settingsItems },
].filter((s) => s.items.length > 0);
}
@@ -146,6 +156,10 @@ const MAIL_TABS_PRIMARY: MailTab[] = [
{ key: "domains", icon: Globe },
{ key: "mailboxes", icon: UserRound },
{ key: "aliases", icon: Forward },
+ // Inbound rules sit with the address space rather than with Delivery: Delivery is
+ // about SENDING, and a rule is about what happens to mail arriving at one of the
+ // addresses above it.
+ { key: "inbound", icon: Inbox },
];
/**
diff --git a/apps/dashboard/src/utils/project-status.ts b/apps/dashboard/src/utils/project-status.ts
index 4da585acb..214c6aae4 100644
--- a/apps/dashboard/src/utils/project-status.ts
+++ b/apps/dashboard/src/utils/project-status.ts
@@ -55,53 +55,20 @@ export type ProjectStatusSource = {
// CSS-only presentation. The human-readable label is resolved from the
// active dictionary via `projectStatusLabel(status, t)` so badges localize.
-export const PROJECT_STATUS_META: Record<
- ProjectStatus,
- { badge: string; dot: string }
-> = {
- live: {
- badge: "bg-success-bg text-success",
- dot: "bg-success-solid",
- },
+export const PROJECT_STATUS_META: Record = {
+ live: { badge: "bg-success-bg text-success" },
// Muted, not amber: a paused project is a state the operator CHOSE, so it must
// not read as something demanding their attention.
- paused: {
- badge: "bg-muted text-muted-foreground",
- dot: "bg-muted-foreground",
- },
- attention: {
- badge: "bg-warning-bg text-warning",
- dot: "bg-warning-solid",
- },
- queued: {
- badge: "bg-info-bg text-info",
- dot: "bg-info-solid",
- },
- building: {
- badge: "bg-info-bg text-info",
- dot: "bg-info-solid",
- },
- deploying: {
- // primary = brand accent, intentionally not a status token.
- badge: "bg-primary/10 text-primary",
- dot: "bg-primary",
- },
- failed: {
- badge: "bg-danger-bg text-danger",
- dot: "bg-danger-solid",
- },
- cancelled: {
- badge: "bg-muted text-muted-foreground",
- dot: "bg-muted-foreground",
- },
- deleting: {
- badge: "bg-danger-bg text-danger",
- dot: "bg-danger-solid animate-pulse",
- },
- draft: {
- badge: "bg-warning-bg text-warning",
- dot: "bg-warning-solid",
- },
+ paused: { badge: "bg-muted text-muted-foreground" },
+ attention: { badge: "bg-warning-bg text-warning" },
+ queued: { badge: "bg-info-bg text-info" },
+ building: { badge: "bg-info-bg text-info" },
+ // primary = brand accent, intentionally not a status token.
+ deploying: { badge: "bg-primary/10 text-primary" },
+ failed: { badge: "bg-danger-bg text-danger" },
+ cancelled: { badge: "bg-muted text-muted-foreground" },
+ deleting: { badge: "bg-danger-bg text-danger" },
+ draft: { badge: "bg-warning-bg text-warning" },
};
/** Localized status label for a project/deployment status pill. */
diff --git a/apps/email/ARCHITECTURE.md b/apps/email/ARCHITECTURE.md
index 24eb69721..20045f3f0 100644
--- a/apps/email/ARCHITECTURE.md
+++ b/apps/email/ARCHITECTURE.md
@@ -236,7 +236,7 @@ That's it. No new TS generator. iRedMail keeps doing what it does well.
| **Dovecot** | IMAP / POP3 / LMTP / ManageSieve / LDA. Reads `vmail.mailbox`, `vmail.domain`. Writes `vmail.last_login`, `vmail.used_quota`, `vmail.share_folder`. | (uses `vmail`) |
| **Amavisd** | Mail filtering bridge. Invokes ClamAV + SpamAssassin. | `amavisd` |
| **iRedAPD** | Policy daemon (greylisting, throttling, SRS). | `iredapd` |
-| **ClamAV** | Antivirus. Stateless. | - |
+| **ClamAV** | Antivirus. Signature database on a bind mount, seeded from the image on first boot — clamd will not start without it. | - |
| **SpamAssassin** | Spam scoring. Stateless. | - |
| **fail2ban** | Brute-force protection on SMTP/IMAP/POP3 auth. | `fail2ban` |
diff --git a/apps/email/Dockerfile b/apps/email/Dockerfile
index d9802683f..400f8990e 100644
--- a/apps/email/Dockerfile
+++ b/apps/email/Dockerfile
@@ -95,9 +95,18 @@ RUN cp /opt/openship-mail/build-config /opt/iRedMail-engine/config \
# an image that passes this test is guaranteed to have something to put on :25.
# NB: Debian's amavisd-new package ships the daemon as /usr/sbin/amavisd (there is
# no `amavisd-new` executable), which is why supervisord invokes /usr/sbin/amavisd.
+#
+# `doveadm` is not a supervisord program but is gated with them anyway, because three
+# separate paths are dead without it: db-bootstrap.sh hashes the postmaster password,
+# and the control plane hashes on every mailbox create and postmaster rotation. It
+# arrives only as a transitive dependency of the dovecot packages iRedMail installs
+# (packages.sh names dovecot-imapd/pop3d/lmtpd/managesieved/sieve/pgsql, never
+# dovecot-core), so nothing guaranteed it was here. Its absence used to surface as a
+# postmaster row with an EMPTY password and a 500 on mailbox create (GH-562) — a
+# runtime mystery for a property the build can simply assert.
RUN set -eu; \
missing=""; \
- for b in /usr/sbin/postfix /usr/sbin/dovecot /usr/sbin/amavisd \
+ for b in /usr/sbin/postfix /usr/sbin/dovecot /usr/bin/doveadm /usr/sbin/amavisd \
/usr/sbin/clamd /usr/bin/freshclam /usr/sbin/spamd \
/usr/bin/fail2ban-server /opt/iredapd/iredapd.py; do \
[ -e "$b" ] || missing="$missing $b"; \
@@ -107,7 +116,12 @@ RUN set -eu; \
echo "The openship-mail image would ship with no mail stack (issue #493)." >&2; \
exit 1; \
fi; \
- echo "openship-mail: all mail daemons present ->$(echo ' postfix dovecot amavisd clamd freshclam spamd fail2ban iredapd')"
+ doveadm pw -s SSHA512 -p build-smoke-test | grep -q '^{SSHA512}' || { \
+ echo "FATAL: doveadm is present but cannot produce an SSHA512 hash." >&2; \
+ echo "Mailbox creation and the postmaster seed both depend on it (GH-562)." >&2; \
+ exit 1; \
+ }; \
+ echo "openship-mail: all mail daemons present ->$(echo ' postfix dovecot doveadm amavisd clamd freshclam spamd fail2ban iredapd')"
# Runtime prerequisites the installer does not leave in place under our stubbed-
# init build (its late service/DB steps abort — see the `|| true` above):
@@ -137,6 +151,49 @@ RUN mkdir -p /opt/openship-mail/seed \
&& cp -a /etc/amavis/conf.d /opt/openship-mail/seed/amavis-confd 2>/dev/null || true \
&& chmod +x /opt/openship-mail/entrypoint.sh
+# ClamAV signatures, fetched into the SEED dir (issue #565).
+#
+# /var/lib/clamav is a host bind mount at runtime, so a database written there during
+# the build is hidden the moment the container starts: clamd finds an empty database
+# directory and exits 1. That is not a degraded scanner — amavis's only scanner is
+# clamd and @av_scanners_backup is empty, so every inbound message fails its virus
+# check and defers. Seeding from here means a fresh install scans with no network and
+# no multi-hundred-MB wait during setup.
+#
+# Fetched here rather than moved out of /var/lib/clamav after the installer: layers are
+# additive, so a `mv` in a later layer keeps the originals in the installer's layer and
+# ships them twice. build-config turns the installer's own freshclam off, which is why
+# the image's /var/lib/clamav is now intentionally EMPTY — unmounted debugging
+# (`docker run --entrypoint sh`) gets a clamd with no database.
+#
+# The retry is not defensive coding: database.clamav.net rate-limits CI egress, and the
+# gate below is a hard build failure, so one 429 would block an entire release. Each
+# attempt resumes into the same datadir. freshclam folds mirror and "could not notify
+# clamd" warnings into its exit status, so the FILE CHECK is the real gate.
+RUN set -eu; \
+ getent passwd clamav >/dev/null || { \
+ echo "FATAL: no clamav user — clamav-base did not install (issue #565)." >&2; \
+ exit 1; \
+ }; \
+ install -d -o clamav -g clamav /var/log/clamav /opt/openship-mail/seed/clamav; \
+ for attempt in 1 2 3; do \
+ if freshclam --datadir=/opt/openship-mail/seed/clamav; then break; fi; \
+ echo "openship-mail: freshclam attempt ${attempt} failed, retrying in 30s" >&2; \
+ sleep 30; \
+ done; \
+ seeded=""; \
+ for db in main.cvd main.cld daily.cvd daily.cld; do \
+ [ -s "/opt/openship-mail/seed/clamav/$db" ] || continue; \
+ seeded="$seeded $db"; \
+ done; \
+ if [ -z "$seeded" ]; then \
+ echo "FATAL: no ClamAV signature database in /opt/openship-mail/seed/clamav." >&2; \
+ echo "freshclam fetched neither main nor daily, so the image would ship a clamd" >&2; \
+ echo "that cannot start and an amavis that defers all inbound mail (issue #565)." >&2; \
+ exit 1; \
+ fi; \
+ echo "openship-mail: ClamAV signature seed ->$seeded"
+
# Mail protocol ports (bound on the host via --network host at runtime):
# 25 SMTP 465 SMTPS 587 submission 143 IMAP 993 IMAPS 110/995 POP3 4190 sieve
EXPOSE 25 465 587 143 993 110 995 4190
diff --git a/apps/email/client/.env.development b/apps/email/client/.env.development
index e08dc3f13..f34719d5d 100644
--- a/apps/email/client/.env.development
+++ b/apps/email/client/.env.development
@@ -5,9 +5,14 @@
# auth + tRPC requests to the backend URL below. Without this, requests
# go same-origin and hit the React Router dev server, which returns 405.
#
+# There is deliberately no APP_URL here. The app's own origin is never a
+# build-time fact - every internal redirect is relative, resolved by
+# react-router against the live origin. Baking one broke every deployment
+# on a real hostname (GH-567); the build now refuses a bundle that carries
+# a dev origin (scripts/build-release.ts).
+#
# Override per-machine in `.env.development.local` (gitignored) or in
# `.env.local`. Production builds should set these via the deploy env,
# not by committing a `.env.production`.
VITE_PUBLIC_BACKEND_URL=http://localhost:3030
-VITE_PUBLIC_APP_URL=http://localhost:3000
diff --git a/apps/email/client/app/(auth)/login/login-client.tsx b/apps/email/client/app/(auth)/login/login-client.tsx
index 3f038be2b..639790130 100644
--- a/apps/email/client/app/(auth)/login/login-client.tsx
+++ b/apps/email/client/app/(auth)/login/login-client.tsx
@@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTRPC } from '@/providers/query-provider';
+import { runtimeBranding } from '@/lib/runtime-branding';
/** OpenShip mark - a hollow ring. Matches packages/dashboard's ``. */
function OpenshipLogo({ size = 44 }: { size?: number }) {
@@ -53,6 +54,11 @@ export function LoginClient() {
const heading = branding?.loginHeading ?? FALLBACK_BRANDING.loginHeading;
const subtext = branding?.loginSubtext ?? FALLBACK_BRANDING.loginSubtext;
const footer = branding?.loginFooter ?? FALLBACK_BRANDING.loginFooter;
+ // Seeded from the value the server embedded in this document, so the row is
+ // right on FIRST paint - the query result would arrive a beat later and flash
+ // the vendor row in or out. The live query still wins once it resolves, so a
+ // PATCH mid-session is picked up. Only an explicit `false` hides it.
+ const showPoweredBy = branding?.showPoweredBy ?? runtimeBranding().showPoweredBy;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@@ -207,58 +213,61 @@ export function LoginClient() {
{/* Footer - transparent, no border, no glass. Sits flush over the
- gradient. */}
-
-
-
-
Powered by
-
- OpenShip
-
+ gradient. Vendor chrome: hidden entirely when the operator has
+ branded this deployment (GH-568). */}
+ {showPoweredBy && (
+
-
+
+ )}
);
}
diff --git a/apps/email/client/app/(routes)/mail/[folder]/page.tsx b/apps/email/client/app/(routes)/mail/[folder]/page.tsx
index dc495355f..e0ed7f0d6 100644
--- a/apps/email/client/app/(routes)/mail/[folder]/page.tsx
+++ b/apps/email/client/app/(routes)/mail/[folder]/page.tsx
@@ -1,4 +1,4 @@
-import { useLoaderData, useNavigate } from 'react-router';
+import { useLoaderData, useNavigate, replace } from 'react-router';
import { MailLayout } from '@/components/mail/mail';
import { useLabels } from '@/hooks/use-labels';
@@ -20,10 +20,10 @@ const ALLOWED_FOLDERS = new Set([
]);
export async function clientLoader({ params, request }: Route.ClientLoaderArgs) {
- if (!params.folder) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`);
+ if (!params.folder) throw replace('/mail/inbox');
const session = await authProxy.api.getSession({ headers: request.headers });
- if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
+ if (!session) throw replace('/login');
return {
folder: params.folder,
diff --git a/apps/email/client/app/(routes)/mail/compose/page.tsx b/apps/email/client/app/(routes)/mail/compose/page.tsx
index 1f873d1e3..2d6efdfce 100644
--- a/apps/email/client/app/(routes)/mail/compose/page.tsx
+++ b/apps/email/client/app/(routes)/mail/compose/page.tsx
@@ -7,17 +7,19 @@ import {
} from '@/components/ui/dialog';
import { CreateEmail } from '@/components/create/create-email';
import { authProxy } from '@/lib/auth-proxy';
-import { useLoaderData } from 'react-router';
+import { useLoaderData, replace } from 'react-router';
import type { Route } from './+types/page';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const session = await authProxy.api.getSession({ headers: request.headers });
- if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
+ if (!session) throw replace('/login');
const url = new URL(request.url);
if (url.searchParams.get('to')?.startsWith('mailto:')) {
- return Response.redirect(
- `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`,
- );
+ // `/api/mailto-handler` is where routes.ts mounts mailto-handler.ts. The
+ // old target, `/mail/compose/handle-mailto`, matches no route at all - it
+ // fell through to the splat 404.
+ const mailto = new URLSearchParams({ mailto: url.searchParams.get('to') ?? '' });
+ throw replace(`/api/mailto-handler?${mailto}`);
}
return Object.fromEntries(url.searchParams.entries()) as {
diff --git a/apps/email/client/app/(routes)/mail/create/page.tsx b/apps/email/client/app/(routes)/mail/create/page.tsx
index 57ac8005c..3963477fd 100644
--- a/apps/email/client/app/(routes)/mail/create/page.tsx
+++ b/apps/email/client/app/(routes)/mail/create/page.tsx
@@ -1,9 +1,10 @@
import { authProxy } from '@/lib/auth-proxy';
+import { replace } from 'react-router';
import type { Route } from './+types/page';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const session = await authProxy.api.getSession({ headers: request.headers });
- if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
+ if (!session) throw replace('/login');
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries()) as {
@@ -11,10 +12,12 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {
subject?: string;
body?: string;
};
- const toParam = params.to || 'someone@someone.com';
- return Response.redirect(
- `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`,
- );
+ const search = new URLSearchParams({
+ isComposeOpen: 'true',
+ to: params.to || 'someone@someone.com',
+ });
+ if (params.subject) search.set('subject', params.subject);
+ throw replace(`/mail/inbox?${search}`);
}
// export async function generateMetadata({ searchParams }: any) {
diff --git a/apps/email/client/app/(routes)/mail/page.tsx b/apps/email/client/app/(routes)/mail/page.tsx
index aeb5f05ea..cb5611ed7 100644
--- a/apps/email/client/app/(routes)/mail/page.tsx
+++ b/apps/email/client/app/(routes)/mail/page.tsx
@@ -1,3 +1,5 @@
+import { replace } from 'react-router';
+
export function clientLoader() {
- return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`);
+ throw replace('/mail/inbox');
}
diff --git a/apps/email/client/app/(routes)/settings/layout.tsx b/apps/email/client/app/(routes)/settings/layout.tsx
index bbd33d713..45d60fe4d 100644
--- a/apps/email/client/app/(routes)/settings/layout.tsx
+++ b/apps/email/client/app/(routes)/settings/layout.tsx
@@ -1,5 +1,5 @@
import { SettingsLayoutContent } from '@/components/ui/settings-content';
-import { Outlet } from 'react-router';
+import { Outlet, replace } from 'react-router';
import { authProxy } from '@/lib/auth-proxy';
import type { Route } from './+types/layout';
@@ -7,10 +7,9 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const session = await authProxy.api.getSession({ headers: request.headers });
if (!session) {
- return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
+ throw replace('/login');
}
-
return null;
}
diff --git a/apps/email/client/app/(routes)/settings/page.tsx b/apps/email/client/app/(routes)/settings/page.tsx
index cc5933f28..1ba97da43 100644
--- a/apps/email/client/app/(routes)/settings/page.tsx
+++ b/apps/email/client/app/(routes)/settings/page.tsx
@@ -1,5 +1,5 @@
-import { redirect } from 'react-router';
+import { replace } from 'react-router';
export function clientLoader() {
- throw redirect(`/settings/general`);
+ throw replace('/settings/general');
}
diff --git a/apps/email/client/app/mailto-handler.ts b/apps/email/client/app/mailto-handler.ts
index fe151907e..bd6467fac 100644
--- a/apps/email/client/app/mailto-handler.ts
+++ b/apps/email/client/app/mailto-handler.ts
@@ -2,6 +2,7 @@ import { cleanEmailAddresses } from '../lib/email-utils';
import { trpcClient } from '@/providers/query-provider';
import type { Route } from './+types/mailto-handler';
import { authProxy } from '@/lib/auth-proxy';
+import { replace } from 'react-router';
// Function to parse mailto URLs
async function parseMailtoUrl(mailtoUrl: string) {
@@ -248,37 +249,36 @@ async function createDraftFromMailto(mailtoData: {
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const session = await authProxy.api.getSession({ headers: request.headers });
- if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
+ if (!session) throw replace('/login');
const url = new URL(request.url);
// Get the mailto parameter from the URL
const mailto = url.searchParams.get('mailto');
- if (!mailto) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
+ if (!mailto) throw replace('/mail/compose');
// Parse the mailto URL
const mailtoData = await parseMailtoUrl(mailto);
// If parsing failed, redirect to empty compose
- if (!mailtoData) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
+ if (!mailtoData) throw replace('/mail/compose');
// Create a draft from the mailto data
const draftId = await createDraftFromMailto(mailtoData);
// If draft creation failed, redirect to empty compose with the parsed data as a fallback
if (!draftId) {
- const fallbackUrl = new URL(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
- if (mailtoData.to) fallbackUrl.searchParams.append('to', mailtoData.to);
- if (mailtoData.subject) fallbackUrl.searchParams.append('subject', mailtoData.subject);
- if (mailtoData.body) fallbackUrl.searchParams.append('body', mailtoData.body);
- if (mailtoData.cc) fallbackUrl.searchParams.append('cc', mailtoData.cc);
- if (mailtoData.bcc) fallbackUrl.searchParams.append('bcc', mailtoData.bcc);
- return Response.redirect(fallbackUrl.toString());
+ const fallback = new URLSearchParams();
+ if (mailtoData.to) fallback.append('to', mailtoData.to);
+ if (mailtoData.subject) fallback.append('subject', mailtoData.subject);
+ if (mailtoData.body) fallback.append('body', mailtoData.body);
+ if (mailtoData.cc) fallback.append('cc', mailtoData.cc);
+ if (mailtoData.bcc) fallback.append('bcc', mailtoData.bcc);
+ const query = fallback.toString();
+ throw replace(query ? `/mail/compose?${query}` : '/mail/compose');
}
// Redirect to compose with the draft ID
- return Response.redirect(
- `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`,
- );
+ throw replace(`/mail/compose?draftId=${encodeURIComponent(draftId)}`);
}
diff --git a/apps/email/client/app/page.tsx b/apps/email/client/app/page.tsx
index da56190af..8dad4ff71 100644
--- a/apps/email/client/app/page.tsx
+++ b/apps/email/client/app/page.tsx
@@ -1,10 +1,10 @@
import { authProxy } from '@/lib/auth-proxy';
import type { Route } from './+types/page';
-import { redirect } from 'react-router';
+import { replace } from 'react-router';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const session = await authProxy.api.getSession({ headers: request.headers });
- throw redirect(session?.user.id ? '/mail/inbox' : '/login');
+ throw replace(session?.user.id ? '/mail/inbox' : '/login');
}
export default function Index() {
diff --git a/apps/email/client/app/root.tsx b/apps/email/client/app/root.tsx
index b6bb0ee03..865d58684 100644
--- a/apps/email/client/app/root.tsx
+++ b/apps/email/client/app/root.tsx
@@ -17,6 +17,7 @@ import type { AppRouter } from '@zero/server/trpc';
import { Button } from '@/components/ui/button';
import { getLocale } from '@/paraglide/runtime';
import { siteConfig } from '@/lib/site-config';
+import { runtimeBranding } from '@/lib/runtime-branding';
import { signOut } from '@/lib/auth-client';
import { TRPC_URL } from '@/lib/backend-url';
import type { Route } from './+types/root';
@@ -58,11 +59,15 @@ export const getServerTrpc = (req: Request) =>
});
export const meta: MetaFunction = () => {
+ // Read from the document, not from the build-time siteConfig: the server has
+ // already substituted the operator's branding into this , and
+ // re-rendering the constants here is what used to overwrite it (GH-568).
+ const { siteTitle, siteDescription } = runtimeBranding();
return [
- { title: siteConfig.title },
- { name: 'description', content: siteConfig.description },
- { property: 'og:title', content: siteConfig.title },
- { property: 'og:description', content: siteConfig.description },
+ { title: siteTitle },
+ { name: 'description', content: siteDescription },
+ { property: 'og:title', content: siteTitle },
+ { property: 'og:description', content: siteDescription },
{ property: 'og:image', content: siteConfig.openGraph.images[0].url },
// `og:url` is intentionally omitted - siteConfig URLs are relative now
// (one build deploys anywhere), and scrapers resolve relative og:image
diff --git a/apps/email/client/lib/runtime-branding.ts b/apps/email/client/lib/runtime-branding.ts
new file mode 100644
index 000000000..f34ab3bec
--- /dev/null
+++ b/apps/email/client/lib/runtime-branding.ts
@@ -0,0 +1,69 @@
+/**
+ * Branding as it was when the server rendered this document.
+ *
+ * GH-568: `siteConfig` is a BUILD-time constant, and root.tsx's `meta` export
+ * used it directly. Because the SPA hydrates the whole document, React
+ * re-rendered the from those constants and undid the values the server
+ * had just substituted - the tab reverted to "OpenShip Mail" moments after
+ * load, and duplicate description/og metas were appended.
+ *
+ * The server therefore embeds the branding it used in the document itself
+ * (apps/email/server/src/lib/index-html.ts writes
+ * `` would
+ * otherwise close the block and everything after it would be parsed as markup.
+ * Escaping `<` to its < unicode escape is valid JSON, so JSON.parse is
+ * unaffected, and it makes `): string {
+ const json = JSON.stringify(branding).replace(/${json}`;
+}
+
+/**
+ * Pure substitution: no filesystem, no env, no module state - so it is
+ * directly unit-testable, which is the whole reason it is separated from
+ * `renderIndexHtml` below.
+ *
+ * `missing` names any slot whose tag was not found in the template. A caller
+ * that ignores it degrades to the original silent no-op, so the server logs it
+ * once at boot.
+ */
+export function injectBranding(
+ html: string,
+ branding: Pick & Partial,
+): { html: string; missing: Slot['key'][] } {
+ const { siteTitle, siteDescription } = branding;
+
+ /** Each slot rewrites the document, or returns null if its tag is absent. */
+ const apply: Record string | null> = {
+ title: (h) =>
+ TITLE_PATTERN.test(h)
+ ? h.replace(TITLE_PATTERN, `$1${escapeHtml(siteTitle)}$2`)
+ : null,
+ description: (h) => replaceMetaContent(h, 'name', 'description', siteDescription),
+ 'og:title': (h) => replaceMetaContent(h, 'property', 'og:title', siteTitle),
+ 'og:description': (h) => replaceMetaContent(h, 'property', 'og:description', siteDescription),
+ embed: (h) =>
+ h.includes('')
+ ? h.replace('', `${brandingScriptTag(branding)}`)
+ : null,
+ };
+
+ const missing: Slot['key'][] = [];
+ let out = html;
+ for (const key of SLOTS) {
+ const next = apply[key](out);
+ if (next === null) missing.push(key);
+ else out = next;
+ }
+ return { html: out, missing };
+}
+
+let template: string | null = null;
+let warnedMissing = false;
+
+/**
+ * The branded shell. Throws only if index.html is absent, which means the
+ * image was assembled wrong - failing loudly is right there.
+ */
+export function renderIndexHtml(clientBuildDir: string): string {
+ if (template === null) {
+ template = readFileSync(join(clientBuildDir, 'index.html'), 'utf8');
+ }
+ const { html, missing } = injectBranding(template, getBranding());
+ if (missing.length > 0 && !warnedMissing) {
+ warnedMissing = true;
+ console.warn(
+ `[branding] index.html has no ${missing.join(', ')} tag - those values ` +
+ `cannot be applied. The client build's changed shape; see ` +
+ `apps/email/server/src/lib/index-html.ts (GH-568).`,
+ );
+ }
+ return html;
+}
diff --git a/apps/email/server/src/main.ts b/apps/email/server/src/main.ts
index 75cd89cb2..996fc2299 100644
--- a/apps/email/server/src/main.ts
+++ b/apps/email/server/src/main.ts
@@ -18,7 +18,7 @@
import { dirname, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { Hono } from 'hono';
+import { Hono, type Context } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { getCookie } from 'hono/cookie';
@@ -33,6 +33,7 @@ import { appRouter } from './trpc';
import { buildContext } from './ctx';
import { getSession } from './lib/session';
import { getBranding, assetsDir } from './lib/branding';
+import { renderIndexHtml } from './lib/index-html';
import { brandingAdminRoute } from './routes/branding-admin';
const app = new Hono();
@@ -150,6 +151,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const clientBuildDir =
process.env.CLIENT_BUILD_DIR ?? resolvePath(__dirname, '../../client/build/client');
+// The HTML document, branded (GH-568). Registered BEFORE serveStatic because
+// serveStatic resolves `/` and `/index.html` to the file on disk and would
+// answer with the build-time / - the very thing that made
+// siteTitle look write-only. Assets are untouched below and keep their
+// immutable caching; only the document is dynamic, so it must not be cached by
+// intermediaries or a rebrand would appear stuck.
+const sendIndexHtml = (c: Context) =>
+ c.html(renderIndexHtml(clientBuildDir), 200, {
+ 'Cache-Control': 'no-cache, must-revalidate',
+ });
+
+app.get('/', sendIndexHtml);
+app.get('/index.html', sendIndexHtml);
+
// Static files: assets, fonts, manifest, etc. serveStatic falls through to
// the next handler when a path doesn't resolve to a file on disk, which is
// what lets the SPA fallback below handle client-side routes.
@@ -157,8 +172,10 @@ app.use('/*', serveStatic({ root: clientBuildDir }));
// SPA fallback - any unmatched GET serves index.html so React Router can
// take over routing on the client. Registered last so it never shadows API
-// routes (those returned a response above and never fell through).
-app.get('*', serveStatic({ root: clientBuildDir, path: 'index.html' }));
+// routes (those returned a response above and never fell through). Deep links
+// are documents too, so they get the same branded shell - a link preview of
+// https://mail.example.com/mail/inbox must not say "OpenShip Mail" either.
+app.get('*', sendIndexHtml);
const port = env.PORT;
console.log(`[zero] listening on http://localhost:${port}`);
diff --git a/apps/email/server/src/routes/branding-admin.ts b/apps/email/server/src/routes/branding-admin.ts
index 38445f7ab..1c155f40c 100644
--- a/apps/email/server/src/routes/branding-admin.ts
+++ b/apps/email/server/src/routes/branding-admin.ts
@@ -18,7 +18,8 @@
* Wire format:
* PATCH /admin/branding
* X-Branding-Admin-Token:
- * { siteTitle?, siteDescription?, loginHeading?, loginSubtext?, loginFooter?, homeHtml? }
+ * { siteTitle?, siteDescription?, loginHeading?, loginSubtext?, loginFooter?,
+ * homeHtml?, showPoweredBy? }
* → 200 { branding: Branding } or 401 { error } or 400 { error }
*/
@@ -35,6 +36,7 @@ const patchSchema = z.object({
loginSubtext: z.string().max(240).optional(),
loginFooter: z.string().max(240).optional(),
homeHtml: z.string().max(50_000).nullable().optional(),
+ showPoweredBy: z.boolean().optional(),
});
/**
diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts
index 876942551..bd5ec9139 100644
--- a/packages/adapters/src/index.ts
+++ b/packages/adapters/src/index.ts
@@ -401,6 +401,7 @@ export {
hostControlDisabled,
setHostControlOverride,
hostChannelHealth,
+ invalidateHostChannelAuth,
containerBridgeCidr,
type HostChannelHealth,
type HostChannelCode,
diff --git a/packages/adapters/src/infra/mail-container.ts b/packages/adapters/src/infra/mail-container.ts
index c9ab298a0..bb4db2ad2 100644
--- a/packages/adapters/src/infra/mail-container.ts
+++ b/packages/adapters/src/infra/mail-container.ts
@@ -39,8 +39,15 @@ export interface MailMount {
/**
* The entrypoint must seed this mount from the image's baked defaults when it's
* empty on first boot, then never overwrite operator edits. True for config dirs
- * we bind-mount whole (so the baked config isn't hidden by an empty host dir);
- * false for pure data dirs (maildir, queue, keys, DB).
+ * we bind-mount whole (so the baked config isn't hidden by an empty host dir) AND
+ * for ClamAV's signature database — data, but clamd exits without it; false for
+ * pure data dirs (maildir, queue, keys, DB).
+ *
+ * DECLARATIVE: nothing here reads this flag. The copy lives in the image's
+ * entrypoint (`seed `), so this is only a claim
+ * about it — pinned by apps/api/test/lib/mail-image-seed-mounts.test.ts. A mount
+ * that needed seeding with nothing seeding it is how the engine shipped a clamd
+ * whose signature database was hidden by its own mount (issue #565).
*/
seed?: boolean;
}
@@ -66,8 +73,10 @@ export const MAIL_CONTAINER_MOUNTS: ReadonlyArray = [
{ host: `${MAIL_HOST_STATE_DIR}/config/dovecot`, container: "/etc/dovecot", seed: true },
{ host: `${MAIL_HOST_STATE_DIR}/config/amavis`, container: "/etc/amavis/conf.d", seed: true },
// ClamAV signatures — bind-mounted so a pull doesn't force a multi-hundred-MB
- // freshclam re-download and delay readiness.
- { host: `${MAIL_HOST_STATE_DIR}/clamav`, container: "/var/lib/clamav" },
+ // freshclam re-download and delay readiness. `seed: true` because that same mount
+ // HIDES the database baked into the image: with nothing copying it across, clamd
+ // finds an empty database directory and exits (issue #565).
+ { host: `${MAIL_HOST_STATE_DIR}/clamav`, container: "/var/lib/clamav", seed: true },
{ host: "/etc/letsencrypt", container: "/etc/letsencrypt", readonly: true },
];
diff --git a/packages/adapters/src/system/executor.ts b/packages/adapters/src/system/executor.ts
index 8c409141d..b1ae31721 100644
--- a/packages/adapters/src/system/executor.ts
+++ b/packages/adapters/src/system/executor.ts
@@ -3,13 +3,15 @@ import { networkInterfaces } from "node:os";
import {
explainHostChannelCause,
+ hostChannelAccount,
hostFirewallRule,
+ HOST_CHANNEL_AUTH_REJECTED,
HOST_CHANNEL_NOT_PROVISIONED,
HOST_CHANNEL_UNPROVISIONED,
} from "@repo/core";
import type { CommandExecutor, SshConfig } from "../types";
-import { HostChannelUnavailableError } from "./errors";
+import { HostChannelUnavailableError, isSshAuthError } from "./errors";
import { LocalExecutor } from "./local-executor";
import { probeTcpDetailed, type TcpProbeFailure, type TcpProbeResult } from "./reachability";
import { SshExecutor } from "./ssh-executor";
@@ -116,7 +118,7 @@ function hostChannelPort(): number {
}
function hostChannelUser(): string {
- return process.env.OPENSHIP_HOST_SSH_USER?.trim() || "root";
+ return hostChannelAccount(process.env);
}
/**
@@ -181,7 +183,14 @@ export type HostChannelCode =
| "not_configured"
| "key_unreadable"
/** Configured, but the TCP connection to the host SSH port never completed. */
- | "unreachable";
+ | "unreachable"
+ /**
+ * The port answered and sshd then REFUSED the key. Its own state rather than a
+ * refinement of `unreachable`, because the remedy shares nothing with a dropped
+ * packet: re-authorize the key, or permit the account to log in. Conflating the two
+ * is what sent #490's reporters to audit firewalls and #527's to audit key files.
+ */
+ | "auth_rejected";
export interface HostChannelHealth {
/** Host ("this machine") operations can be performed at all. */
@@ -245,10 +254,85 @@ function explainDialFailure(
return { hint, rule: hostFirewallRule("unknown", cidr ? [cidr] : [], port) };
}
+/**
+ * Auth verdicts are memoized: {@link hostChannelHealth} is called on every dashboard
+ * load, and an SSH handshake costs more than a TCP probe. A rejection is cached far
+ * shorter than a success, so a channel the operator has just re-authorized stops
+ * reporting broken within seconds rather than within a cache generation.
+ */
+const AUTH_MEMO_OK_MS = 30_000;
+const AUTH_MEMO_FAIL_MS = 5_000;
+let authMemo: { key: string; hint: string | null; expires: number } | null = null;
+
+/** Drop the memoized auth verdict. For a caller that just CHANGED the channel — a
+ * re-provision — and must not then read its own stale "refused". */
+export function invalidateHostChannelAuth(): void {
+ authMemo = null;
+}
+
+/**
+ * Does the host channel's key actually AUTHENTICATE?
+ *
+ * The gap this closes is #527: an open port is not a working channel. sshd answers the
+ * SYN and then refuses — the key was never authorized for the account we dial, or sshd
+ * permits that account no login at all — and every consumer keyed on the TCP probe
+ * called that healthy. The local row's badge read fine while every host operation
+ * failed, and the failure surfaced as "SSH credentials rejected" against credentials
+ * this channel does not read.
+ *
+ * Only an AUTH rejection becomes a verdict. A connect failure is left alone: the TCP
+ * probe already ran and `explainDialFailure` describes it better, so reporting it twice
+ * would overwrite a specific firewall diagnosis with a vaguer one (#490).
+ */
+async function verifyHostChannelAuth(config: {
+ host: string;
+ port: number;
+ username: string;
+ privateKey: string;
+ timeoutMs: number;
+}): Promise<{ hint: string } | null> {
+ // The key's LENGTH, not the key: enough to notice a re-provision swapped the material,
+ // without holding a credential in a module-level cache for 30 seconds.
+ const memoKey = `${config.username}@${config.host}:${config.port}#${config.privateKey.length}`;
+ if (authMemo?.key === memoKey && authMemo.expires > Date.now()) {
+ return authMemo.hint ? { hint: authMemo.hint } : null;
+ }
+
+ let hint: string | null = null;
+ try {
+ // Dynamic for the reason every SSH import on this path is dynamic: this function is
+ // reachable from the boot hook and from unauthenticated /health/env, and must not
+ // drag ssh2 in before something has actually asked it to dial.
+ const { connectSshClient } = await import("./ssh-client");
+ const client = await connectSshClient({
+ host: config.host,
+ port: config.port,
+ username: config.username,
+ privateKey: config.privateKey,
+ readyTimeoutMs: config.timeoutMs,
+ // Load-bearing: it selects describeSshAuthFailure's host-channel wording, so the
+ // hint an operator reads here is the same sentence the deploy log gives them.
+ hostChannel: true,
+ });
+ client.end();
+ } catch (err) {
+ if (isSshAuthError(err)) {
+ hint = err instanceof Error ? err.message : HOST_CHANNEL_AUTH_REJECTED;
+ }
+ }
+
+ authMemo = {
+ key: memoKey,
+ hint,
+ expires: Date.now() + (hint ? AUTH_MEMO_FAIL_MS : AUTH_MEMO_OK_MS),
+ };
+ return hint ? { hint } : null;
+}
+
/**
* Can this instance actually drive its host? Cheap enough to call on every page
- * load: one TCP handshake to the host SSH port, or no I/O at all when the answer
- * is decided by env.
+ * load: one TCP handshake to the host SSH port — plus, when that answers, one
+ * memoized auth handshake — or no I/O at all when the answer is decided by env.
*
* Exists because {@link createHostExecutor} has no "configured but unreachable"
* state — it returns an executor that has not dialed anything, so a filtered
@@ -284,34 +368,55 @@ export async function hostChannelHealth(timeoutMs = 2_500): Promise ({
probe: vi.fn(async (): Promise => ({ ok: true })),
+ /** The auth handshake behind `verifyHostChannelAuth`. Default: the key is accepted. */
+ connect: vi.fn(async () => ({ end: () => undefined })),
}));
vi.mock("./reachability", async (importOriginal) => ({
@@ -34,6 +36,8 @@ vi.mock("./reachability", async (importOriginal) => ({
probeTcpDetailed: net.probe,
}));
+vi.mock("./ssh-client", () => ({ connectSshClient: net.connect }));
+
const ENV_KEYS = [
"OPENSHIP_HOST_CONTROL",
"OPENSHIP_HOST_SSH_HOST",
@@ -322,6 +326,101 @@ describe("hostChannelHealth diagnosis", () => {
});
});
+/**
+ * #527: an open port is not a working channel.
+ *
+ * The reporter's box answered on :22 and then refused the key — authorized for the wrong
+ * account by a pre-0.6.2 install, or on a host whose sshd permits that account no login.
+ * Every layer keyed on the TCP probe, so the channel reported `ok`, the local row's badge
+ * read healthy, and the first thing the operator saw was a generic "SSH credentials
+ * rejected" card pointing at credentials this channel never reads.
+ */
+describe("hostChannelHealth — the auth half (#527)", () => {
+ /** A provisioned channel whose key exists on disk, so the auth probe has one to try. */
+ async function healthWithKey() {
+ const dir = scratch();
+ const keyPath = join(dir, "id_ed25519");
+ writeFileSync(keyPath, "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n");
+ channelWithKey(keyPath);
+ net.probe.mockResolvedValue({ ok: true });
+ const { hostChannelHealth } = await load();
+ return hostChannelHealth(100);
+ }
+
+ afterEach(() => {
+ net.connect.mockReset();
+ net.connect.mockResolvedValue({ end: () => undefined });
+ });
+
+ it("reports auth_rejected when the port answers and the key is refused", async () => {
+ net.connect.mockRejectedValue(
+ new Error("SSH key authentication failed (All configured authentication methods failed)"),
+ );
+
+ const h = await healthWithKey();
+
+ expect(h).toMatchObject({ ok: false, code: "auth_rejected" });
+ // Its own state, not `unreachable`: the remedy is a key, not a firewall, and offering
+ // a ufw rule here is the mistake #490 was fixed to stop making.
+ expect(h.rule).toBeUndefined();
+ expect(h.hint).toBeTruthy();
+ });
+
+ it("still reports ok when the key is accepted", async () => {
+ expect(await healthWithKey()).toMatchObject({ ok: true, code: "ok" });
+ });
+
+ it("does not downgrade a channel over a CONNECT failure the probe already described", async () => {
+ // Only an auth rejection is a verdict here. Re-reporting a transport fault would
+ // overwrite `explainDialFailure`'s specific diagnosis with a vaguer one.
+ net.connect.mockRejectedValue(new Error("Timed out while waiting for handshake"));
+ expect(await healthWithKey()).toMatchObject({ ok: true, code: "ok" });
+ });
+
+ it("memoizes the verdict, so a dashboard poll costs one handshake and not one per load", async () => {
+ const { hostChannelHealth } = await load();
+ const dir = scratch();
+ const keyPath = join(dir, "id_ed25519");
+ writeFileSync(keyPath, "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n");
+ channelWithKey(keyPath);
+ net.probe.mockResolvedValue({ ok: true });
+
+ await hostChannelHealth(100);
+ await hostChannelHealth(100);
+
+ expect(net.connect).toHaveBeenCalledTimes(1);
+ });
+
+ it("invalidateHostChannelAuth forces the next call to ask again", async () => {
+ const { hostChannelHealth, invalidateHostChannelAuth } = await load();
+ const dir = scratch();
+ const keyPath = join(dir, "id_ed25519");
+ writeFileSync(keyPath, "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n");
+ channelWithKey(keyPath);
+ net.probe.mockResolvedValue({ ok: true });
+
+ await hostChannelHealth(100);
+ invalidateHostChannelAuth();
+ await hostChannelHealth(100);
+
+ // The reason this exists: a live auth failure must beat a cached "it worked", or the
+ // diagnosis contradicts the error being held and #527's card comes back (see
+ // server-check.controller).
+ expect(net.connect).toHaveBeenCalledTimes(2);
+ });
+
+ it("asks nothing when there is no key to try", async () => {
+ clearEnv();
+ process.env.OPENSHIP_IN_CONTAINER = "true";
+ process.env.OPENSHIP_HOST_SSH_HOST = "host.docker.internal";
+ net.probe.mockResolvedValue({ ok: true });
+ const { hostChannelHealth } = await load();
+
+ expect(await hostChannelHealth(100)).toMatchObject({ ok: true, code: "ok" });
+ expect(net.connect).not.toHaveBeenCalled();
+ });
+});
+
describe("unavailableExecutor", () => {
const REASON = "Host control is disabled on this instance.";
diff --git a/packages/adapters/src/system/mail/ensure-container-mail.test.ts b/packages/adapters/src/system/mail/ensure-container-mail.test.ts
index 07bceffce..848741654 100644
--- a/packages/adapters/src/system/mail/ensure-container-mail.test.ts
+++ b/packages/adapters/src/system/mail/ensure-container-mail.test.ts
@@ -199,6 +199,33 @@ describe("ensureContainerMail swap", () => {
*
* So the writer must fail closed on anything that isn't a single clean record.
*/
+/**
+ * GH-564: on a redeploy over a RETAINED pgdata, the credential of record is the one the
+ * cluster was initialised with, not the one this deploy generated - `POSTGRES_PASSWORD`
+ * only takes effect during initdb. Handing the sidecar a fresh password fails auth,
+ * db-bootstrap cannot load the schema, and `vmail` never appears.
+ *
+ * `initialised` controls the PG_VERSION probe; `retainedEnv` is the db.env already on the
+ * host (null = the file is gone, the unrecoverable case).
+ */
+function retainedDbExecutor(opts: { initialised: boolean; retainedEnv: string | null }) {
+ const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
+ const exec = vi.fn(async (cmd: string) => {
+ if (cmd.includes(STATE_PROBE)) return "";
+ if (cmd.includes("docker version")) return "27.0.0\n";
+ if (cmd.includes("docker image inspect")) return "sha256:abc\n";
+ if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
+ if (cmd.includes("PG_VERSION")) return opts.initialised ? "yes\n" : "";
+ return "";
+ });
+ const writeFile = vi.fn(async (_path: string, _content: string) => {});
+ const readFile = vi.fn(async (path: string) => {
+ if (path.endsWith("db.env") && opts.retainedEnv !== null) return opts.retainedEnv;
+ throw new Error("ENOENT");
+ });
+ return { executor: { exec, streamExec, writeFile, readFile } as never, writeFile, readFile };
+}
+
function envWriteExecutor() {
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
const exec = vi.fn(async (cmd: string) => {
@@ -284,3 +311,73 @@ describe("engine env-file cannot be injected with extra records", () => {
expect(lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))).toBe(true);
});
});
+
+describe("mail database credential over a retained pgdata (GH-564)", () => {
+ const RETAINED = "POSTGRES_USER=postgres\nPOSTGRES_DB=vmail\nPOSTGRES_PASSWORD=old-cluster-pw\n";
+
+ it("reuses the password the existing cluster was initialised with", async () => {
+ setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
+ const { executor, writeFile } = retainedDbExecutor({
+ initialised: true,
+ retainedEnv: RETAINED,
+ });
+
+ await ensureContainerMail(executor, {
+ domain: "example.com",
+ // What a redeploy freshly generates - it must NOT win.
+ secrets: { PGSQL_ROOT_PASSWD: "newly-generated-pw" },
+ onLog: () => {},
+ }).catch(() => {}); // the stub cannot finish verify; the env writes already happened
+
+ const written = new Map(writeFile.mock.calls.map(([p, c]) => [String(p), String(c)]));
+ const dbEnv = [...written.entries()].find(([p]) => p.endsWith("db.env"))?.[1] ?? "";
+ const engineEnv = [...written.entries()].find(([p]) => p.endsWith("engine.env"))?.[1] ?? "";
+
+ expect(dbEnv).toContain("POSTGRES_PASSWORD=old-cluster-pw");
+ expect(dbEnv).not.toContain("newly-generated-pw");
+ // The engine's first-boot bootstrap connects as the superuser, so it needs the SAME
+ // credential - otherwise the schema load fails auth even though the sidecar is fine.
+ expect(engineEnv).toContain("PGSQL_ROOT_PASSWD=old-cluster-pw");
+ expect(engineEnv).not.toContain("newly-generated-pw");
+ });
+
+ it("uses the generated password on a genuine first install", async () => {
+ setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
+ const { executor, writeFile, readFile } = retainedDbExecutor({
+ initialised: false,
+ retainedEnv: null,
+ });
+
+ await ensureContainerMail(executor, {
+ domain: "example.com",
+ secrets: { PGSQL_ROOT_PASSWD: "newly-generated-pw" },
+ onLog: () => {},
+ }).catch(() => {});
+
+ const dbEnv =
+ writeFile.mock.calls.map(([p, c]) => [String(p), String(c)] as const).find(([p]) => p.endsWith("db.env"))?.[1] ??
+ "";
+ expect(dbEnv).toContain("POSTGRES_PASSWORD=newly-generated-pw");
+ // No cluster on disk, so no reason to read the old file at all.
+ expect(readFile).not.toHaveBeenCalled();
+ });
+
+ it("refuses to mint a password an existing cluster cannot have", async () => {
+ setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
+ const { executor, writeFile } = retainedDbExecutor({ initialised: true, retainedEnv: null });
+
+ // Silently generating here produces a sidecar that cannot authenticate and an error
+ // far from its cause, so this must abort and name the recoveries.
+ await expect(
+ ensureContainerMail(executor, {
+ domain: "example.com",
+ secrets: { PGSQL_ROOT_PASSWD: "newly-generated-pw" },
+ onLog: () => {},
+ }),
+ ).rejects.toThrow(/initialised Postgres cluster/i);
+
+ // And it must not have written a credential the cluster does not hold.
+ const wroteDbEnv = writeFile.mock.calls.some(([p]) => String(p).endsWith("db.env"));
+ expect(wroteDbEnv).toBe(false);
+ });
+});
diff --git a/packages/adapters/src/system/mail/ensure-container-mail.ts b/packages/adapters/src/system/mail/ensure-container-mail.ts
index d3455cb0c..23dc42d3b 100644
--- a/packages/adapters/src/system/mail/ensure-container-mail.ts
+++ b/packages/adapters/src/system/mail/ensure-container-mail.ts
@@ -16,7 +16,7 @@
* update must never leave the box without a mail engine.
*/
-import { buildMailImageRef, safeErrorMessage } from "@repo/core";
+import { buildMailImageRef, safeErrorMessage, mailHostname } from "@repo/core";
import type { CommandExecutor, LogEntry } from "../../types";
import type { SystemLog, SystemLogCallback } from "../types";
import { sq } from "../local-shell";
@@ -30,6 +30,7 @@ import {
swapManagedImage,
} from "../managed-image";
import { waitForPortListening } from "../port-listen";
+import { rootOrDegrade } from "../privilege";
import {
MAIL_CONTAINER,
MAIL_DB_CONTAINER,
@@ -208,6 +209,69 @@ async function writeEnvFile(
const ENGINE_ENV_FILE = `${MAIL_HOST_STATE_DIR}/engine.env`;
const DB_ENV_FILE = `${MAIL_HOST_STATE_DIR}/db.env`;
+/** One key back out of an env-file we wrote. Same trivial `K=V` shape as `writeEnvFile`. */
+async function readEnvFileValue(
+ executor: CommandExecutor,
+ path: string,
+ key: string,
+): Promise {
+ const body = await executor.readFile(path).catch(() => "");
+ for (const line of body.split("\n")) {
+ const eq = line.indexOf("=");
+ if (eq > 0 && line.slice(0, eq).trim() === key) return line.slice(eq + 1);
+ }
+ return null;
+}
+
+/**
+ * The superuser password an ALREADY-INITIALISED cluster is holding, or null if this is a
+ * first install.
+ *
+ * GH-564: `POSTGRES_PASSWORD` only takes effect during `initdb`. On a redeploy over a
+ * RETAINED pgdata the sidecar starts a cluster that already has its own superuser
+ * password, so handing it a freshly minted one means every connection fails auth —
+ * db-bootstrap then cannot load the schema and `vmail` never appears. The engine's
+ * early-return is keyed on the ENGINE container existing, so an engine that was removed
+ * (or never came up) while pgdata survived lands straight in the create path.
+ *
+ * So: if the data directory holds a cluster, the credential of record is the one on disk,
+ * not the one we just generated. PG_VERSION is the marker initdb writes — the same probe
+ * the compose path uses.
+ */
+async function retainedDbPassword(
+ executor: CommandExecutor,
+ onLog: SystemLogCallback,
+): Promise {
+ const initialised = await executor
+ .exec(`test -s ${sq(`${MAIL_DB_HOST_DATA_DIR}/pgdata/PG_VERSION`)} && echo yes || true`)
+ .then((out) => out.trim() === "yes")
+ .catch(() => false);
+ if (!initialised) return null;
+
+ const retained = await readEnvFileValue(executor, DB_ENV_FILE, "POSTGRES_PASSWORD");
+ if (retained) {
+ onLog(
+ log(
+ `Reusing the existing mail database credential — ${MAIL_DB_HOST_DATA_DIR} already ` +
+ `holds an initialised cluster, and its superuser password cannot be changed by ` +
+ `an env var.`,
+ ),
+ );
+ return retained;
+ }
+
+ // The cluster exists but we no longer hold its password. Minting one would produce a
+ // sidecar that cannot authenticate, a failed bootstrap, and a confusing error far from
+ // the cause — so stop here and name the two things that actually recover it.
+ throw new Error(
+ `The mail database directory ${MAIL_DB_HOST_DATA_DIR} holds an initialised Postgres ` +
+ `cluster, but its credential is missing from ${DB_ENV_FILE}. A new password cannot ` +
+ `be applied to an existing cluster. Either restore ${DB_ENV_FILE} with the original ` +
+ `POSTGRES_PASSWORD, or — if the mail data is expendable — remove ` +
+ `${MAIL_DB_HOST_DATA_DIR} to reinitialise the database from scratch.`,
+ );
+}
+
/** `docker run` argv for the Postgres sidecar (loopback-published, bind-mounted data). */
function buildDbRunCommand(container: string): string {
return [
@@ -316,7 +380,7 @@ function makeMailStart(
opts: ContainerMailOptions,
): (image: string) => Promise {
const { onLog } = opts;
- const hostname = `mail.${opts.domain}`;
+ const hostname = mailHostname(opts.domain);
return async (image: string) => {
if (!(await startEngine(executor, container, image, hostname, onLog))) return false;
return (await verifyMailEngine(executor, opts)).ok;
@@ -394,13 +458,39 @@ export async function ensureContainerMail(
if (pull.code !== 0) throw new Error(pullFailureMessage(image, output.join("\n")));
}
- // 2. Host state dirs (engine mounts + DB data dir), created over the same
- // executor that runs the containers — a missing host dir silently becomes an
- // empty bind and loses data.
+ // 2. Host state dirs (engine mounts + DB data dir). Through the privilege gate, and
+ // reported rather than swallowed: these are root-owned paths under
+ // /var/lib/openship/mail, so on a box we log into as a non-root sudo user the
+ // unelevated `mkdir` fails. The comment here already named the consequence — "a
+ // missing host dir silently becomes an empty bind and loses data" — and then
+ // `.catch(() => {})` made it silent, so the one outcome worth an operator's
+ // attention was the one nothing could observe. Degrades rather than throws, so an
+ // unmeasurable host keeps today's behaviour.
+ const hostState = await rootOrDegrade(executor, {
+ purpose: "Creating the mail engine's host state directories",
+ consequence: "A missing directory becomes an empty bind mount, which loses mail data.",
+ report: (message) => onLog(log(message, "warn")),
+ });
for (const mount of MAIL_CONTAINER_MOUNTS) {
- await executor.exec(`mkdir -p ${sq(mount.host)}`).catch(() => {});
+ await hostState.exec(`mkdir -p ${sq(mount.host)}`).catch((err: unknown) => {
+ onLog(
+ log(
+ `Could not create the mail state directory ${mount.host}: ${safeErrorMessage(err)}. ` +
+ `Docker will create it empty, which loses mail data.`,
+ "warn",
+ ),
+ );
+ });
}
- await executor.exec(`mkdir -p ${sq(MAIL_DB_HOST_DATA_DIR)}`).catch(() => {});
+ await hostState.exec(`mkdir -p ${sq(MAIL_DB_HOST_DATA_DIR)}`).catch((err: unknown) => {
+ onLog(
+ log(
+ `Could not create the mail database directory ${MAIL_DB_HOST_DATA_DIR}: ` +
+ `${safeErrorMessage(err)}. Postgres will start on an empty bind mount.`,
+ "warn",
+ ),
+ );
+ });
// 3. Secret env-files (root-only), consumed via --env-file so creds never hit a
// shell string. The engine's first-boot entrypoint reads these to init the
@@ -409,19 +499,28 @@ export async function ensureContainerMail(
// PGSQL_ROOT_PASSWD) with an empty `vmail` database; the engine's first-boot
// entrypoint then creates the vmail/vmailadmin/amavisd/iredapd/fail2ban roles
// (from the per-role passwords passed in the engine env) and loads the schema.
- await writeEnvFile(executor, DB_ENV_FILE, {
+ // A cluster already on disk owns its own superuser password (GH-564); a freshly
+ // generated one would only be applied by initdb, which will not run again.
+ const retainedRoot = await retainedDbPassword(hostState, onLog);
+ const dbRootPassword =
+ retainedRoot ?? opts.secrets.PGSQL_ROOT_PASSWD ?? opts.secrets.VMAIL_DB_ADMIN_PASSWD ?? "";
+
+ await writeEnvFile(hostState, DB_ENV_FILE, {
POSTGRES_USER: "postgres",
POSTGRES_DB: MAIL_DB_NAME,
- POSTGRES_PASSWORD:
- opts.secrets.PGSQL_ROOT_PASSWD ?? opts.secrets.VMAIL_DB_ADMIN_PASSWD ?? "",
+ POSTGRES_PASSWORD: dbRootPassword,
});
- await writeEnvFile(executor, ENGINE_ENV_FILE, {
+ await writeEnvFile(hostState, ENGINE_ENV_FILE, {
FIRST_DOMAIN: opts.domain,
OPENSHIP_MAIL_DB_HOST: MAIL_DB_HOST_BIND,
OPENSHIP_MAIL_DB_PORT: String(MAIL_DB_PORT),
OPENSHIP_MAIL_DB_NAME: MAIL_DB_NAME,
OPENSHIP_MAIL_DB_USER: MAIL_DB_USER,
...opts.secrets,
+ // Spread LAST so the retained value wins: the engine's first-boot bootstrap connects
+ // as the superuser, and it has to use the password the cluster actually has, not the
+ // one this deploy generated.
+ ...(retainedRoot ? { PGSQL_ROOT_PASSWD: retainedRoot } : {}),
});
try {
@@ -433,7 +532,7 @@ export async function ensureContainerMail(
// 5. Engine.
onLog(log("Starting the mail engine container..."));
- if (!(await startEngine(executor, container, image, `mail.${opts.domain}`, onLog))) {
+ if (!(await startEngine(executor, container, image, mailHostname(opts.domain), onLog))) {
throw new Error("the mail engine container failed to start");
}
diff --git a/packages/adapters/src/system/proxy/ensure-container-edge.ts b/packages/adapters/src/system/proxy/ensure-container-edge.ts
index 329347a86..715ab3a9b 100644
--- a/packages/adapters/src/system/proxy/ensure-container-edge.ts
+++ b/packages/adapters/src/system/proxy/ensure-container-edge.ts
@@ -462,8 +462,19 @@ export async function ensureContainerEdge(
// missing bind source itself, as an EMPTY root-owned dir, so the container comes up
// serving nothing and every later vhost write fails somewhere else entirely. Say it
// once, here, where the cause is still in hand.
+ // Through the gate, for the reason `edgeHostExecutor` goes through it: these are
+ // root-owned paths under /var/lib/openship, so on a box we log into as a non-root sudo
+ // user the unelevated `mkdir` fails — and the failure was swallowed into a warning
+ // nobody acts on, leaving the empty-bind-mount outcome the comment above describes.
+ // `installContainerEdge` already gates the same work; this reconcile entrypoint never
+ // did. Degrades rather than throws, so an unmeasurable host keeps today's behaviour.
+ const hostState = await rootOrDegrade(executor, {
+ purpose: "Creating the edge's state directories",
+ consequence: "Docker will create them empty, so vhosts and certificates may not persist.",
+ report: (message) => onLog(log(message, "warn")),
+ });
for (const mount of EDGE_CONTAINER_MOUNTS) {
- await executor.exec(`mkdir -p ${sq(mount.host)}`).catch((err: unknown) => {
+ await hostState.exec(`mkdir -p ${sq(mount.host)}`).catch((err: unknown) => {
onLog(
log(
`Could not create the edge state directory ${mount.host}: ${safeErrorMessage(err)}. ` +
diff --git a/packages/adapters/src/system/proxy/import/nginx.ts b/packages/adapters/src/system/proxy/import/nginx.ts
index e818aa855..4c171e9f6 100644
--- a/packages/adapters/src/system/proxy/import/nginx.ts
+++ b/packages/adapters/src/system/proxy/import/nginx.ts
@@ -45,6 +45,27 @@ async function dumpResolvedConfig(
return null;
}
+/**
+ * nginx's compiled `--prefix` — the base a prefix-relative `root` resolves against.
+ *
+ * `-T` inlines every `include` but does NOT rewrite directive VALUES, so a stock
+ * `root html;` survives the dump verbatim and only nginx's own prefix says where it
+ * points. `-V` prints the configure line to STDERR, hence the redirect.
+ *
+ * Null when no binary answers, or when the build passed no `--prefix`: nginx's
+ * compiled-in default (`/usr/local/nginx`) is a guess, and a wrong guess here
+ * publishes the wrong directory to the internet. Callers skip the site with that as
+ * the stated reason instead.
+ */
+async function nginxPrefix(executor: CommandExecutor, bins: string[]): Promise {
+ for (const bin of bins) {
+ const out = await tryExec(executor, `${bin} -V 2>&1`);
+ const prefix = out?.match(/--prefix=(\S+)/)?.[1];
+ if (prefix) return prefix;
+ }
+ return null;
+}
+
async function loadNginxConfig(executor: CommandExecutor): Promise {
const dumped = await dumpResolvedConfig(executor, ["nginx", "openresty"]);
if (dumped) return dumped;
@@ -275,15 +296,59 @@ function parseProxyDirectives(body: string): {
};
}
+/**
+ * Loopback `server_name`s, which are never migratable hostnames: they only match a
+ * request that ARRIVED with `Host: localhost` — an on-box curl — so a vhost claiming
+ * one cannot be served for anybody through a public edge.
+ *
+ * Filtering them alongside `_` and regex names is what keeps nginx's SHIPPED default
+ * vhost (`server_name localhost; root html;`) out of the migrate set: it is a
+ * placeholder welcome page, not a site, and carrying it over failed at APPLY time on
+ * its prefix-relative root — reported to the operator as "1 site not served" for a
+ * site that never existed. A block that has a loopback name AND a real one keeps the
+ * real one; a block left with nothing falls into the no-usable-name skip below.
+ */
+const LOOPBACK_SERVER_NAMES = new Set([
+ "localhost",
+ "localhost.localdomain",
+ "ip6-localhost",
+ "ip6-loopback",
+]);
+
+/**
+ * Absolutize a `root`. nginx treats a value not starting with `/` as relative to its
+ * compiled prefix, so a bare `html` is legal config meaning `/html`.
+ *
+ * Resolving HERE, where the prefix is known, is what keeps the raw token from
+ * reaching the vhost writer — which rejects it with "must be an absolute path", an
+ * accurate sentence about a config that is perfectly valid nginx, at the one moment
+ * (post-cutover) when the operator can least afford a misleading error.
+ */
+function resolveStaticRoot(
+ root: string,
+ prefix?: string,
+): { root: string } | { reason: string } {
+ if (root.startsWith("/")) return { root };
+ if (!prefix) {
+ return {
+ reason:
+ `static root "${root}" is relative to nginx's compiled prefix, ` +
+ `which this host didn't report`,
+ };
+ }
+ return { root: `${prefix.replace(/\/+$/, "")}/${root}` };
+}
+
function parseServer(
body: string,
source: string,
upstreams: Map,
+ prefix?: string,
): { site?: ImportedSite; warnings: string[] } {
const warnings: string[] = [];
const names = firstDirective(body, "server_name")
?.split(/\s+/)
- .filter((n) => n && n !== "_" && !n.startsWith("~"))
+ .filter((n) => n && n !== "_" && !n.startsWith("~") && !LOOPBACK_SERVER_NAMES.has(n.toLowerCase()))
?? [];
// ssl if any `listen ... ssl` or `listen 443` (443 as a whole token — not 8443)
@@ -294,9 +359,10 @@ function parseServer(
const certPath = firstDirective(body, "ssl_certificate");
const keyPath = firstDirective(body, "ssl_certificate_key");
- // No usable server_name = the default catch-all (`server_name _;` / omitted).
- // It can't become a vhost (there's no hostname to register) and every nginx has
- // one, so it's an expected skip, not a config item the operator lost.
+ // No usable server_name = the default catch-all (`server_name _;` / omitted), or a
+ // loopback-only block like nginx's shipped default vhost. It can't become a vhost
+ // (there's no routable hostname to register) and every nginx has one, so it's an
+ // expected skip, not a config item the operator lost.
if (names.length === 0) return { warnings: [] };
// All routes for this vhost. Locations are the real source; fall back to a
@@ -332,7 +398,12 @@ function parseServer(
// only on :80 serving an empty webroot.
return { warnings: [] };
} else if (root && !isAcmeWebrootOnly(body)) {
- target = { kind: "static", root: root.replace(/;$/, "") };
+ const resolved = resolveStaticRoot(root.replace(/;$/, ""), prefix);
+ if ("reason" in resolved) {
+ warnings.push(`nginx: ${names[0]} — ${resolved.reason} (skipped)`);
+ return { warnings };
+ }
+ target = { kind: "static", root: resolved.root };
} else if (root) {
// A root that exists ONLY to answer /.well-known/acme-challenge — certbot
// scaffolding, not a site. Our edge answers ACME itself (nginx.conf proxies
@@ -353,8 +424,11 @@ function parseServer(
}
/** Parse a raw nginx config string into normalized sites. Shared by `scanNginx`
- * (foreign `/etc/nginx`) and `scanOpenshipEdge` (our OpenResty sites tree). */
-function parseNginxConfig(raw: string): ProxyScanResult {
+ * (foreign `/etc/nginx`) and `scanOpenshipEdge` (our OpenResty sites tree).
+ *
+ * `prefix` absolutizes a prefix-relative `root`; our own edge always writes
+ * absolute roots, so the "ours" callers pass none. */
+function parseNginxConfig(raw: string, prefix?: string): ProxyScanResult {
const warnings: string[] = [];
const sites: ImportedSite[] = [];
@@ -372,7 +446,7 @@ function parseNginxConfig(raw: string): ProxyScanResult {
}
for (const body of blocks) {
- const { site, warnings: blockWarnings } = parseServer(body, "nginx", upstreams);
+ const { site, warnings: blockWarnings } = parseServer(body, "nginx", upstreams, prefix);
warnings.push(...blockWarnings);
if (site) sites.push(site);
}
@@ -396,7 +470,13 @@ function parseNginxConfig(raw: string): ProxyScanResult {
* answering only on port 80 with an empty webroot.
*/
export async function scanNginx(executor: CommandExecutor): Promise {
- return parseNginxConfig(await loadNginxConfig(executor));
+ const raw = await loadNginxConfig(executor);
+ // Only pay for the extra `-V` when a root that needs the prefix is actually
+ // present. A commented-out `root html;` false-positives the probe, which costs one
+ // cheap exec and nothing else — the prefix is unused if no site needs it.
+ const relativeRoot = /(?:^|[;{\s])root\s+[^/;\s]/.test(raw);
+ const prefix = relativeRoot ? await nginxPrefix(executor, ["nginx", "openresty"]) : null;
+ return parseNginxConfig(raw, prefix ?? undefined);
}
/**
diff --git a/packages/adapters/src/system/proxy/import/proxy-import.test.ts b/packages/adapters/src/system/proxy/import/proxy-import.test.ts
index abac706fc..5ac6c7d1d 100644
--- a/packages/adapters/src/system/proxy/import/proxy-import.test.ts
+++ b/packages/adapters/src/system/proxy/import/proxy-import.test.ts
@@ -397,6 +397,94 @@ describe("scanNginx", () => {
expect(res.sites[0]!.proxy).toEqual({ proxyBusyBuffersSize: "32k" });
expect(res.sites[0]!.proxyRaw).toEqual({ proxyBusyBuffersSize: "32k" });
});
+
+ test("skips nginx's shipped default vhost without warning about it", async () => {
+ // Stock upstream nginx.conf. `server_name localhost` + the prefix-relative
+ // `root html` is a placeholder welcome page, not a site: importing it used to
+ // reach the vhost writer and die on "must be an absolute path", surfacing as
+ // "1 site not served" for something the operator never hosted.
+ const conf = `
+ server {
+ listen 80 default_server;
+ server_name localhost;
+ root html;
+ index index.html;
+ }
+ server {
+ listen 80;
+ server_name real.example.com;
+ location / { proxy_pass http://127.0.0.1:3000; }
+ }
+ `;
+ const res = await scanNginx(makeExecutor([["nginx -T", conf]]));
+ expect(res.sites.map((s) => s.serverNames)).toEqual([["real.example.com"]]);
+ // An expected skip, so it must not be reported as a site the operator lost.
+ expect(res.warnings.join("\n")).not.toMatch(/localhost/);
+ });
+
+ test("keeps the real hostname on a vhost that also answers localhost", async () => {
+ const conf = `
+ server {
+ listen 80;
+ server_name localhost app.example.com;
+ location / { proxy_pass http://127.0.0.1:3000; }
+ }
+ `;
+ const res = await scanNginx(makeExecutor([["nginx -T", conf]]));
+ expect(res.sites).toHaveLength(1);
+ expect(res.sites[0]!.serverNames).toEqual(["app.example.com"]);
+ });
+
+ test("absolutizes a prefix-relative static root against nginx's --prefix", async () => {
+ const conf = `
+ server {
+ listen 80;
+ server_name docs.example.com;
+ root html;
+ }
+ `;
+ const res = await scanNginx(
+ makeExecutor([
+ ["nginx -T", conf],
+ ["nginx -V", "nginx version: nginx/1.24.0\nconfigure arguments: --prefix=/usr/share/nginx --with-http_v2_module"],
+ ]),
+ );
+ expect(res.sites[0]!.target).toEqual({ kind: "static", root: "/usr/share/nginx/html" });
+ });
+
+ test("skips a relative static root when no prefix is reported, with a reason", async () => {
+ // Guessing nginx's compiled default would publish the wrong directory.
+ const conf = `
+ server {
+ listen 80;
+ server_name docs.example.com;
+ root html;
+ }
+ `;
+ const res = await scanNginx(makeExecutor([["nginx -T", conf]]));
+ expect(res.sites).toHaveLength(0);
+ expect(res.warnings.join("\n")).toMatch(/docs\.example\.com.*relative to nginx's compiled prefix/);
+ });
+
+ test("does not run -V when every root is already absolute", async () => {
+ const conf = `
+ server {
+ listen 80;
+ server_name static.example.com;
+ root /var/www/site;
+ }
+ `;
+ const calls: string[] = [];
+ const executor = {
+ exec: async (cmd: string) => {
+ calls.push(cmd);
+ return cmd.includes("nginx -T") ? conf : "";
+ },
+ } as unknown as CommandExecutor;
+ const res = await scanNginx(executor);
+ expect(res.sites[0]!.target).toEqual({ kind: "static", root: "/var/www/site" });
+ expect(calls.some((c) => c.includes("-V"))).toBe(false);
+ });
});
describe("scanCaddy", () => {
diff --git a/packages/adapters/src/system/ssh-support.ts b/packages/adapters/src/system/ssh-support.ts
index 00d9a763f..cba8058c1 100644
--- a/packages/adapters/src/system/ssh-support.ts
+++ b/packages/adapters/src/system/ssh-support.ts
@@ -3,7 +3,12 @@ import { access } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
-import { hostFirewallRule } from "@repo/core";
+import {
+ HOST_CHANNEL_AUTH_REJECTED,
+ HOST_CHANNEL_NOT_PROVISIONED,
+ HOST_CHANNEL_ROW_CREDENTIALS_UNUSED,
+ hostFirewallRule,
+} from "@repo/core";
import type { SshConfig } from "../types";
import { systemDebug } from "./debug";
@@ -12,9 +17,31 @@ function formatSshTarget(config: SshConfig): string {
return `${config.username ?? "root"}@${config.host}:${config.port ?? 22}`;
}
+/**
+ * Describe a REJECTED credential — the auth half, as opposed to
+ * {@link describeSshConnectFailure}'s transport half.
+ *
+ * The `hostChannel` branch is this function's #490: that bug was operators auditing
+ * `authorized_keys` over what was really a packet filter, and the fix was to stop
+ * wording a connect failure like a credential one. #527 is the mirror image and went
+ * unfixed for six releases — a rejected host-channel key worded as a stored-credential
+ * problem ("check the username, private key, passphrase"), on a row whose stored
+ * credentials nothing dials with. The reporter moved key files between /tmp, /root and
+ * ~/.ssh for a dozen messages because this string told them to.
+ */
export function describeSshAuthFailure(config: SshConfig, originalMessage: string): string {
const target = formatSshTarget(config);
+ // Checked before password/privateKey: the host channel always carries a privateKey, so
+ // the generic key branch below would otherwise claim it first and win every time.
+ if (config.hostChannel) {
+ return (
+ `${HOST_CHANNEL_AUTH_REJECTED} Dialed ${target} from inside the Openship API ` +
+ `container. ${HOST_CHANNEL_ROW_CREDENTIALS_UNUSED} ${HOST_CHANNEL_NOT_PROVISIONED} ` +
+ `(${originalMessage})`
+ );
+ }
+
if (config.password) {
return `SSH password authentication failed for ${target}. Check the username/password, or verify that the server allows password login. (${originalMessage})`;
}
diff --git a/packages/core/src/app-templates.ts b/packages/core/src/app-templates.ts
index 1de8f9b7e..790da9793 100644
--- a/packages/core/src/app-templates.ts
+++ b/packages/core/src/app-templates.ts
@@ -104,8 +104,19 @@ export interface TemplateServiceSpec {
healthcheck?: ComposeHealthcheck;
/** Restart policy (compose syntax). */
restart?: "no" | "always" | "on-failure" | "unless-stopped";
- /** Override the container command. */
+ /** Override the container command (shell string; becomes ["sh","-c",cmd]). */
command?: string;
+ /**
+ * Exact container argv, with no `sh -c` wrap. Wins over `command`. Required for
+ * images whose entrypoint rewrites argv instead of `exec "$@"` (e.g. MinIO).
+ */
+ commandArgv?: readonly string[];
+ /**
+ * Seconds/duration Docker waits after SIGTERM before SIGKILL (maps to
+ * `service.advanced.stopGracePeriod`). Needed by apps whose clean shutdown
+ * does real work — Docker's 10s default kills them mid-checkpoint.
+ */
+ stopGracePeriod?: string;
}
export interface AppConfigField {
diff --git a/packages/core/src/apps/catalog.json b/packages/core/src/apps/catalog.json
index eb618496f..8b0582c32 100644
--- a/packages/core/src/apps/catalog.json
+++ b/packages/core/src/apps/catalog.json
@@ -556,9 +556,6 @@
"image": "mongo-express:1.0.2",
"exposedPort": 8081,
"exposed": true,
- "ports": [
- "8081:8081"
- ],
"routes": [
{
"port": 8081
@@ -582,7 +579,10 @@
"retries": 5,
"startPeriod": "20s"
},
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8216:8081"
+ ]
}
],
"configFields": [
@@ -641,19 +641,19 @@
{
"id": "dbUrl",
"label": "Database URL",
- "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@{{host}}:27017/",
- "sourceLabel": "Public",
+ "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@mongo:27017/",
+ "sourceLabel": "Internal",
"variants": [
{
- "id": "internal",
- "label": "Internal",
- "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@mongo:27017/"
+ "id": "host",
+ "label": "From this server",
+ "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@{{host}}:27017/"
}
],
"secret": true,
"envKey": "MONGODB_URI",
"recommended": true,
- "help": "Direct MongoDB connection (root user). Published on port 27017. Switch to Internal for apps on the same project network."
+ "help": "Direct MongoDB connection (root user) on the project’s private network — use it from another service, or bind this app into a project. Port 27017 publishes on 127.0.0.1 only, so the \"From this server\" form works from the box itself or through an SSH tunnel, not from the internet."
}
]
},
@@ -865,9 +865,6 @@
{
"name": "n8n",
"image": "n8nio/n8n:latest",
- "ports": [
- "5678:5678"
- ],
"exposedPort": 5678,
"exposed": true,
"environment": {
@@ -882,7 +879,10 @@
"volumes": [
"n8n_data:/home/node/.n8n"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8213:5678"
+ ]
}
],
"configFields": [
@@ -948,13 +948,43 @@
}
]
}
- ]
+ ],
+ "connection": {
+ "title": "Set up n8n",
+ "description": "n8n has no preset login. Open the editor and the first screen creates your owner account.",
+ "guide": {
+ "defaultMode": "public",
+ "intro": "Your project gets a private n8n instance for building workflows.",
+ "useHint": "Webhook nodes are published under the webhook base URL below — use that origin when registering callbacks with third parties."
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Editor",
+ "source": "publicUrl:n8n",
+ "kind": "url",
+ "recommended": true,
+ "help": "First visit asks you to create the owner account."
+ },
+ {
+ "id": "webhookUrl",
+ "label": "Webhook base URL",
+ "source": "env:n8n:WEBHOOK_URL",
+ "kind": "url",
+ "envKey": "N8N_WEBHOOK_URL",
+ "help": "Base origin n8n advertises for Webhook nodes."
+ }
+ ],
+ "firstLogin": {
+ "note": "Create the owner account as soon as it deploys — the setup screen is unauthenticated until you do. Never change the generated encryption key afterwards or every stored credential becomes unreadable. n8n issues https-only session cookies, so sign in over the domain rather than a plain-http address."
+ }
+ }
},
{
- "available": false,
+ "available": true,
"id": "ghost",
"name": "Ghost",
- "description": "Modern publishing platform for blogs, newsletters, and membership sites.",
+ "description": "Modern publishing platform for blogs, newsletters, and membership sites. Claim the owner account on your first visit.",
"kind": "template",
"logo": "ghost",
"category": "cms",
@@ -982,9 +1012,6 @@
{
"name": "ghost",
"image": "ghost:5-alpine",
- "ports": [
- "2368:2368"
- ],
"exposedPort": 2368,
"exposed": true,
"dependsOn": [
@@ -1004,7 +1031,10 @@
"volumes": [
"ghost_content:/var/lib/ghost/content"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8212:2368"
+ ]
}
],
"configFields": [
@@ -1024,13 +1054,43 @@
"generateGroup": "ghostdb",
"secret": true
}
- ]
+ ],
+ "connection": {
+ "title": "Set up Ghost",
+ "description": "Ghost has no preset login. Open Ghost Admin and the first screen creates the owner account.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "admin",
+ "label": "Ghost Admin",
+ "source": "template:{{env:ghost:url}}/ghost/",
+ "kind": "url",
+ "recommended": true,
+ "help": "Create the owner account here on first visit."
+ },
+ {
+ "id": "url",
+ "label": "Site",
+ "source": "publicUrl:ghost",
+ "kind": "url",
+ "help": "The public blog."
+ }
+ ],
+ "firstLogin": {
+ "note": "Claim the owner account at /ghost/ immediately — it is unauthenticated until you do, so the first visitor owns the blog. Ghost may restart a few times on first boot while MySQL initialises; that is expected."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
+ "verified": true,
"id": "directus",
"name": "Directus",
- "description": "Headless CMS with an instant REST + GraphQL API over your data. Create the admin on first visit.",
+ "description": "Headless CMS with an instant REST + GraphQL API over your data. Sign in with the admin email and generated password below.",
+ "repository": "https://github.com/directus/directus",
"kind": "template",
"logo": "directus",
"category": "cms",
@@ -1044,24 +1104,41 @@
{
"name": "directus",
"image": "directus/directus:latest",
- "ports": [
- "8055:8055"
- ],
"exposedPort": 8055,
"exposed": true,
+ "routes": [
+ {
+ "port": 8055
+ }
+ ],
"environment": {
"DB_CLIENT": "sqlite3",
"DB_FILENAME": "/directus/database/data.db",
- "PUBLIC_URL": "{{publicUrl:directus}}"
+ "PUBLIC_URL": "{{publicUrl:directus}}",
+ "ADMIN_EMAIL": "admin@example.com"
},
"secretEnv": [
- "SECRET"
+ "SECRET",
+ "ADMIN_PASSWORD"
],
"volumes": [
"directus_database:/directus/database",
"directus_uploads:/directus/uploads"
],
- "restart": "unless-stopped"
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget -q -O /dev/null http://127.0.0.1:8055/server/health || exit 1"
+ ],
+ "interval": "15s",
+ "timeout": "5s",
+ "retries": 8,
+ "startPeriod": "30s"
+ },
+ "restart": "unless-stopped",
+ "ports": [
+ "8211:8055"
+ ]
}
],
"configFields": [
@@ -1072,14 +1149,76 @@
"help": "Auto-generated. Signs access tokens.",
"generate": "secret",
"secret": true
+ },
+ {
+ "key": "ADMIN_PASSWORD",
+ "service": "directus",
+ "label": "Admin password",
+ "help": "Auto-generated. The password for the admin account created on first boot. Directus has no sign-up screen, so without this no account would exist at all.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "connection": {
+ "title": "Sign in to Directus",
+ "description": "The admin account below is created automatically on the first boot. Change the password after your first sign-in.",
+ "guide": {
+ "intro": "A headless CMS plus a REST and GraphQL API over whatever collections you create.",
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "admin",
+ "label": "Admin app",
+ "source": "template:{{env:directus:PUBLIC_URL}}/admin",
+ "kind": "url",
+ "recommended": true,
+ "help": "The Directus admin app. Sign in with the email and password below."
+ },
+ {
+ "id": "url",
+ "label": "API URL",
+ "source": "publicUrl:directus",
+ "kind": "url",
+ "envKey": "DIRECTUS_URL",
+ "help": "Base URL for the REST + GraphQL API."
+ },
+ {
+ "id": "email",
+ "label": "Admin email",
+ "source": "env:directus:ADMIN_EMAIL",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:directus:ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "The admin is created only on the FIRST boot against an empty database. If you reinstall over an existing directus_database volume no new admin is made — reset one with `npx directus users passwd` inside the container."
+ }
+ },
+ "endpoints": [
+ {
+ "service": "directus",
+ "port": 8055,
+ "label": "Directus",
+ "kind": "http",
+ "defaultMode": "domain"
}
]
},
{
- "available": false,
+ "available": true,
"id": "nocodb",
"name": "NocoDB",
- "description": "Airtable-style spreadsheet UI over an SQL database. The first sign-up becomes the admin.",
+ "description": "Airtable-style spreadsheet UI over an SQL database. Sign in with the admin account below.",
"kind": "template",
"logo": "nocodb",
"category": "database",
@@ -1093,23 +1232,73 @@
{
"name": "nocodb",
"image": "nocodb/nocodb:latest",
- "ports": [
- "8080:8080"
- ],
"exposedPort": 8080,
"exposed": true,
"volumes": [
"nocodb_data:/usr/app/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "environment": {
+ "NC_ADMIN_EMAIL": "admin@example.com"
+ },
+ "secretEnv": [
+ "NC_ADMIN_PASSWORD"
+ ],
+ "ports": [
+ "8208:8080"
+ ]
}
- ]
+ ],
+ "configFields": [
+ {
+ "key": "NC_ADMIN_PASSWORD",
+ "service": "nocodb",
+ "label": "Admin password",
+ "help": "Auto-generated. Password for the super-admin account created on first boot.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Sign in to NocoDB",
+ "description": "The super-admin account below is created on first boot. Change the password after signing in.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "NocoDB",
+ "source": "publicUrl:nocodb",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the email and password below."
+ },
+ {
+ "id": "email",
+ "label": "Admin email",
+ "source": "env:nocodb:NC_ADMIN_EMAIL",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:nocodb:NC_ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "Anyone who reaches this URL can still create their own account (they land in their own workspace and cannot see your bases). Turn sign-up off in Account Settings → Authentication right after your first sign-in — the NC_INVITE_ONLY_SIGNUP env var no longer works in this version."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
"id": "metabase",
"name": "Metabase",
- "description": "Open-source business intelligence — dashboards and questions over your data.",
+ "description": "Open-source business intelligence — dashboards and questions over your data. Create your admin account in the browser on first visit.",
"kind": "template",
"logo": "metabase",
"category": "analytics",
@@ -1123,20 +1312,55 @@
{
"name": "metabase",
"image": "metabase/metabase:latest",
- "ports": [
- "3000:3000"
- ],
"exposedPort": 3000,
"exposed": true,
"environment": {
- "MB_DB_FILE": "/metabase-data/metabase.db"
+ "MB_DB_FILE": "/metabase-data/metabase.db",
+ "MB_SITE_URL": "{{publicUrl:metabase}}"
},
"volumes": [
"metabase_data:/metabase-data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "secretEnv": [
+ "MB_ENCRYPTION_SECRET_KEY"
+ ],
+ "ports": [
+ "3010:3000"
+ ]
}
- ]
+ ],
+ "configFields": [
+ {
+ "key": "MB_ENCRYPTION_SECRET_KEY",
+ "service": "metabase",
+ "label": "Encryption key",
+ "help": "Auto-generated. Encrypts saved database credentials at rest. Never change it after setup.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Set up Metabase",
+ "description": "Metabase has no preset login. Open the link and the setup wizard will walk you through creating the admin account.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Metabase",
+ "source": "publicUrl:metabase",
+ "kind": "url",
+ "recommended": true,
+ "help": "First visit runs the setup wizard — the account you create there is the admin."
+ }
+ ],
+ "firstLogin": {
+ "note": "Complete the setup wizard immediately: it is unauthenticated, so whoever opens this URL first becomes the admin. Metabase can take a minute to finish migrations on first boot."
+ }
+ },
+ "verified": true
},
{
"available": true,
@@ -1189,10 +1413,10 @@
}
},
{
- "available": false,
+ "available": true,
"id": "gitea",
"name": "Gitea",
- "description": "Self-hosted Git with issues, pull requests, and a first-run setup wizard.",
+ "description": "Self-hosted Git with issues and pull requests. The admin account below is created for you.",
"kind": "template",
"logo": "gitea",
"category": "other",
@@ -1206,26 +1430,104 @@
{
"name": "gitea",
"image": "gitea/gitea:1",
- "ports": [
- "3000:3000"
- ],
"exposedPort": 3000,
"exposed": true,
"environment": {
- "GITEA__server__ROOT_URL": "{{publicUrl:gitea}}"
+ "GITEA__server__ROOT_URL": "{{publicUrl:gitea}}",
+ "GITEA__security__INSTALL_LOCK": "true",
+ "GITEA__security__SECRET_KEY": "{{config:GITEA_SECRET_KEY}}",
+ "GITEA__database__DB_TYPE": "sqlite3",
+ "GITEA__service__DISABLE_REGISTRATION": "true",
+ "GITEA_ADMIN_USERNAME": "admin",
+ "GITEA_ADMIN_EMAIL": "admin@example.com"
},
"volumes": [
"gitea_data:/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "secretEnv": [
+ "GITEA_ADMIN_PASSWORD"
+ ],
+ "ports": [
+ "3009:3000"
+ ]
}
- ]
+ ],
+ "configFields": [
+ {
+ "key": "GITEA_SECRET_KEY",
+ "service": "gitea",
+ "label": "Secret key",
+ "help": "Auto-generated. Signs Gitea's tokens and locks the installer.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "GITEA_ADMIN_PASSWORD",
+ "service": "gitea",
+ "label": "Admin password",
+ "help": "Auto-generated. Password for the seeded admin account.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "prepare": [
+ {
+ "service": "gitea",
+ "title": "Create the Gitea admin",
+ "description": "Seeds the administrator account so you can sign in immediately.",
+ "command": "su-exec git gitea admin user create --admin --username \"$GITEA_ADMIN_USERNAME\" --password \"$GITEA_ADMIN_PASSWORD\" --email \"$GITEA_ADMIN_EMAIL\" --must-change-password=false 2>&1 || true; echo done",
+ "capture": "gitea_admin",
+ "phase": "post-ready",
+ "readiness": {
+ "test": "su-exec git gitea admin user list >/dev/null 2>&1",
+ "interval": 3000,
+ "retries": 40
+ }
+ }
+ ],
+ "connection": {
+ "title": "Sign in to Gitea",
+ "description": "Your admin account is created during install. New self-registration is disabled — invite users from Site Administration.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Gitea",
+ "source": "publicUrl:gitea",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the username and password below."
+ },
+ {
+ "id": "username",
+ "label": "Admin username",
+ "source": "env:gitea:GITEA_ADMIN_USERNAME",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:gitea:GITEA_ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "INSTALL_LOCK is on, so the public setup wizard is disabled and cannot be used to hijack the instance. The password above works as-is."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
+ "verified": true,
"id": "code-server",
"name": "code-server",
- "description": "Run VS Code in your browser, on your server. Login uses an auto-generated password.",
+ "description": "Run VS Code in your browser, on your server. Sign in with the auto-generated password below.",
+ "repository": "https://github.com/coder/code-server",
"kind": "template",
"logo": "code-server",
"category": "other",
@@ -1238,21 +1540,34 @@
"services": [
{
"name": "code-server",
- "image": "codercom/code-server:latest",
- "ports": [
- "8080:8080"
- ],
+ "image": "codercom/code-server:4.132.0",
"exposedPort": 8080,
"exposed": true,
+ "routes": [
+ {
+ "port": 8080
+ }
+ ],
"secretEnv": [
"PASSWORD"
],
"volumes": [
- "code_server_config:/home/coder/.config",
- "code_server_local:/home/coder/.local",
- "code_server_project:/home/coder/project"
+ "code_server_home:/home/coder"
],
- "restart": "unless-stopped"
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "curl -fsS -o /dev/null http://127.0.0.1:8080/healthz || exit 1"
+ ],
+ "interval": "15s",
+ "timeout": "5s",
+ "retries": 6,
+ "startPeriod": "20s"
+ },
+ "restart": "unless-stopped",
+ "ports": [
+ "8207:8080"
+ ]
}
],
"configFields": [
@@ -1260,17 +1575,56 @@
"key": "PASSWORD",
"service": "code-server",
"label": "Login password",
- "help": "Auto-generated. Required to sign in.",
+ "help": "Auto-generated. Required to sign in. Uses PASSWORD (plaintext) rather than HASHED_PASSWORD, which expects an argon2 digest.",
"generate": "secret",
"secret": true
}
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "connection": {
+ "title": "Sign in to code-server",
+ "description": "VS Code in the browser. Sign in with the generated password below.",
+ "guide": {
+ "intro": "A full VS Code editor running on your server, reachable from any browser.",
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Editor",
+ "source": "publicUrl:code-server",
+ "kind": "url",
+ "recommended": true,
+ "help": "Opens the browser IDE. Sign in with the password below."
+ },
+ {
+ "id": "password",
+ "label": "Login password",
+ "source": "env:code-server:PASSWORD",
+ "secret": true
+ }
+ ],
+ "firstLogin": {
+ "note": "Your whole home directory is the persisted volume, so files, extensions and settings all survive a redeploy. The editor opens /home/coder by default."
+ }
+ },
+ "endpoints": [
+ {
+ "service": "code-server",
+ "port": 8080,
+ "label": "Editor",
+ "kind": "http",
+ "defaultMode": "domain"
+ }
]
},
{
- "available": false,
+ "available": true,
"id": "uptime-kuma",
"name": "Uptime Kuma",
- "description": "Self-hosted uptime monitoring with status pages and alerts.",
+ "description": "Self-hosted uptime monitoring with status pages and alerts. Create your admin account on the first visit.",
"kind": "template",
"logo": "uptime-kuma",
"category": "other",
@@ -1284,23 +1638,44 @@
{
"name": "uptime-kuma",
"image": "louislam/uptime-kuma:1",
- "ports": [
- "3001:3001"
- ],
"exposedPort": 3001,
"exposed": true,
"volumes": [
"uptime_kuma_data:/app/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "3008:3001"
+ ]
}
- ]
+ ],
+ "connection": {
+ "title": "Set up Uptime Kuma",
+ "description": "Uptime Kuma ships with no default login — the first page you see creates the admin account.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Uptime Kuma",
+ "source": "publicUrl:uptime-kuma",
+ "kind": "url",
+ "recommended": true,
+ "help": "First visit prompts you to create the administrator account."
+ }
+ ],
+ "firstLogin": {
+ "note": "Do this immediately after deploy: until you create the admin, anyone who opens this URL can claim the instance. Monitors and history live in the uptime_kuma_data volume."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
"id": "vaultwarden",
"name": "Vaultwarden",
- "description": "Lightweight self-hosted password manager (Bitwarden-compatible).",
+ "description": "Lightweight self-hosted password manager (Bitwarden-compatible). Open the admin panel with the token below to invite your first account.",
"kind": "template",
"logo": "vaultwarden",
"category": "other",
@@ -1314,26 +1689,75 @@
{
"name": "vaultwarden",
"image": "vaultwarden/server:latest",
- "ports": [
- "80:80"
- ],
"exposedPort": 80,
"exposed": true,
"environment": {
- "DOMAIN": "{{publicUrl:vaultwarden}}"
+ "DOMAIN": "{{publicUrl:vaultwarden}}",
+ "SIGNUPS_ALLOWED": "false"
},
"volumes": [
"vaultwarden_data:/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "secretEnv": [
+ "ADMIN_TOKEN"
+ ],
+ "ports": [
+ "8206:80"
+ ]
}
- ]
+ ],
+ "configFields": [
+ {
+ "key": "ADMIN_TOKEN",
+ "service": "vaultwarden",
+ "label": "Admin panel token",
+ "help": "Auto-generated. Unlocks /admin, where you invite your own account.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Set up Vaultwarden",
+ "description": "Public sign-up is disabled. Open the admin panel with the token below, invite your own email address, then register that address in any Bitwarden client.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "admin",
+ "label": "Admin panel",
+ "source": "template:{{env:vaultwarden:DOMAIN}}/admin",
+ "kind": "url",
+ "recommended": true,
+ "help": "Paste the admin token below to sign in, then use Invite User."
+ },
+ {
+ "id": "url",
+ "label": "Vault URL",
+ "source": "publicUrl:vaultwarden",
+ "kind": "url",
+ "help": "Point the Bitwarden app/extension at this as its self-hosted server URL."
+ },
+ {
+ "id": "adminToken",
+ "label": "Admin token",
+ "source": "env:vaultwarden:ADMIN_TOKEN",
+ "secret": true,
+ "help": "Full control of the server. Treat it like a root password."
+ }
+ ],
+ "firstLogin": {
+ "note": "Invitations work without SMTP: after inviting your address in /admin, register that same address from the vault URL to set your master password."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
"id": "freshrss",
"name": "FreshRSS",
- "description": "Self-hosted RSS and Atom feed reader with a first-run setup wizard.",
+ "description": "Self-hosted RSS and Atom feed reader. Your account is created during install.",
"kind": "template",
"logo": "freshrss",
"category": "other",
@@ -1347,23 +1771,88 @@
{
"name": "freshrss",
"image": "freshrss/freshrss:latest",
- "ports": [
- "80:80"
- ],
"exposedPort": 80,
"exposed": true,
"volumes": [
"freshrss_data:/var/www/FreshRSS/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "environment": {
+ "FRESHRSS_ADMIN_USERNAME": "admin"
+ },
+ "secretEnv": [
+ "FRESHRSS_ADMIN_PASSWORD"
+ ],
+ "ports": [
+ "8204:80"
+ ]
}
- ]
+ ],
+ "configFields": [
+ {
+ "key": "FRESHRSS_ADMIN_PASSWORD",
+ "service": "freshrss",
+ "label": "Admin password",
+ "help": "Auto-generated. Password for the account created during install.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "prepare": [
+ {
+ "service": "freshrss",
+ "title": "Install FreshRSS",
+ "description": "Runs the headless installer and creates your account.",
+ "command": "cd /var/www/FreshRSS && { [ -f ./data/config.php ] || php ./cli/do-install.php --default-user=\"$FRESHRSS_ADMIN_USERNAME\" --auth-type=form --db-type=sqlite --api-enabled; } && { php ./cli/list-users.php 2>/dev/null | grep -qx \"$FRESHRSS_ADMIN_USERNAME\" || php ./cli/create-user.php --user \"$FRESHRSS_ADMIN_USERNAME\" --password \"$FRESHRSS_ADMIN_PASSWORD\" --language en; } && ./cli/access-permissions.sh >/dev/null 2>&1; echo done",
+ "capture": "freshrss_install",
+ "phase": "post-ready",
+ "readiness": {
+ "test": "test -d /var/www/FreshRSS/cli",
+ "interval": 3000,
+ "retries": 30
+ }
+ }
+ ],
+ "connection": {
+ "title": "Sign in to FreshRSS",
+ "description": "FreshRSS is installed headlessly during setup — sign in with the credentials below.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Reader",
+ "source": "publicUrl:freshrss",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the username and password below."
+ },
+ {
+ "id": "username",
+ "label": "Username",
+ "source": "env:freshrss:FRESHRSS_ADMIN_USERNAME",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Password",
+ "source": "env:freshrss:FRESHRSS_ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "Deliberately NOT deployed with the public web installer, so nobody can claim your instance first. Feeds refresh on a schedule only if you set CRON_MIN (e.g. \"*/20\")."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
"id": "stirling-pdf",
"name": "Stirling PDF",
- "description": "Split, merge, convert, OCR and edit PDFs locally. Default login is admin / stirling — change it.",
+ "description": "Split, merge, convert, OCR and edit PDFs locally. Sign in with the admin account below.",
"kind": "template",
"logo": "stirling-pdf",
"category": "other",
@@ -1377,21 +1866,72 @@
{
"name": "stirling-pdf",
"image": "stirlingtools/stirling-pdf:latest",
- "ports": [
- "8080:8080"
- ],
"exposedPort": 8080,
"exposed": true,
"volumes": [
"stirling_config:/configs",
"stirling_tessdata:/usr/share/tessdata"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "environment": {
+ "SECURITY_ENABLELOGIN": "true",
+ "SECURITY_INITIALLOGIN_USERNAME": "admin"
+ },
+ "secretEnv": [
+ "SECURITY_INITIALLOGIN_PASSWORD"
+ ],
+ "ports": [
+ "8209:8080"
+ ]
}
- ]
+ ],
+ "configFields": [
+ {
+ "key": "SECURITY_INITIALLOGIN_PASSWORD",
+ "service": "stirling-pdf",
+ "label": "Admin password",
+ "help": "Auto-generated. Replaces Stirling's public default password.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Sign in to Stirling PDF",
+ "description": "Login is enabled and the admin account below is seeded on first boot, so the well-known default password is never used.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Stirling PDF",
+ "source": "publicUrl:stirling-pdf",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the username and password below."
+ },
+ {
+ "id": "username",
+ "label": "Admin username",
+ "source": "env:stirling-pdf:SECURITY_INITIALLOGIN_USERNAME",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:stirling-pdf:SECURITY_INITIALLOGIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "Seeding these two values is what prevents Stirling from creating its documented admin/stirling account. The password above works as-is."
+ }
+ },
+ "verified": true
},
{
- "available": false,
+ "available": true,
"id": "it-tools",
"name": "IT-Tools",
"description": "A handy collection of developer and sysadmin utilities. No login, no setup.",
@@ -1408,14 +1948,35 @@
{
"name": "it-tools",
"image": "corentinth/it-tools:latest",
- "ports": [
- "80:80"
- ],
"exposedPort": 80,
"exposed": true,
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8205:80"
+ ]
}
- ]
+ ],
+ "connection": {
+ "title": "Open IT-Tools",
+ "description": "A collection of developer and sysadmin utilities. No login, no setup, nothing stored.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "IT-Tools",
+ "source": "publicUrl:it-tools",
+ "kind": "url",
+ "recommended": true,
+ "help": "Opens the tool collection. No sign-in required."
+ }
+ ],
+ "firstLogin": {
+ "note": "Nothing to log into and nothing persisted — every tool runs in your browser. Anyone who can reach this URL can use it, so keep it internal if that matters."
+ }
+ },
+ "verified": true
},
{
"available": true,
@@ -1436,14 +1997,34 @@
{
"name": "excalidraw",
"image": "excalidraw/excalidraw:latest",
- "ports": [
- "8203:80"
- ],
"exposedPort": 80,
"exposed": true,
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8203:80"
+ ]
}
- ]
+ ],
+ "connection": {
+ "title": "Open Excalidraw",
+ "description": "No login and no server-side storage — open the link and start drawing.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Whiteboard",
+ "source": "publicUrl:excalidraw",
+ "kind": "url",
+ "recommended": true,
+ "help": "Opens the whiteboard. No sign-in required."
+ }
+ ],
+ "firstLogin": {
+ "note": "There is nothing to log into and nothing stored on the server: drawings live in your browser, so use Export to save anything you want to keep. Anyone who can reach this URL can use it — put it behind your own access control if that matters."
+ }
+ }
},
{
"available": false,
@@ -1481,10 +2062,11 @@
{
"name": "minio",
"image": "minio/minio:latest",
- "command": "server /data --console-address :9001",
- "ports": [
- "9000:9000",
- "9001:9001"
+ "commandArgv": [
+ "server",
+ "/data",
+ "--console-address",
+ ":9001"
],
"exposedPort": 9001,
"routes": [
@@ -1506,7 +2088,11 @@
"volumes": [
"minio_data:/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "9001:9001",
+ "9000:9000"
+ ]
}
],
"configFields": [
@@ -1547,8 +2133,7 @@
"test": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\"",
"interval": 1000,
"retries": 30
- },
- "once": true
+ }
}
],
"endpoints": [
@@ -1674,9 +2259,6 @@
"image": "ghcr.io/kafbat/kafka-ui:latest",
"exposedPort": 8080,
"exposed": true,
- "ports": [
- "8080:8080"
- ],
"routes": [
{
"port": 8080
@@ -1695,7 +2277,10 @@
"secretEnv": [
"SPRING_SECURITY_USER_PASSWORD"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8210:8080"
+ ]
}
],
"configFields": [
@@ -1772,9 +2357,6 @@
{
"name": "qdrant",
"image": "qdrant/qdrant:v1.18.3",
- "ports": [
- "6333:6333"
- ],
"exposedPort": 6333,
"exposed": true,
"routes": [
@@ -1788,7 +2370,10 @@
"volumes": [
"qdrant_storage:/qdrant/storage"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8215:6333"
+ ]
}
],
"configFields": [
@@ -1850,9 +2435,6 @@
{
"name": "meilisearch",
"image": "getmeili/meilisearch:v1.12",
- "ports": [
- "7700:7700"
- ],
"exposedPort": 7700,
"exposed": true,
"routes": [
@@ -1880,7 +2462,10 @@
"retries": 5,
"startPeriod": "10s"
},
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8214:7700"
+ ]
}
],
"configFields": [
@@ -1898,7 +2483,7 @@
},
"connection": {
"title": "Connect to Meilisearch",
- "description": "Point a Meilisearch client at the URL with the master key. Every request needs the key as a Bearer token.",
+ "description": "Point a Meilisearch client at the URL with the master key. Every request needs the key as a Bearer token. There is no web dashboard in production mode — drive it from a Meilisearch client or curl.",
"guide": {
"intro": "Your project gets a Meilisearch endpoint plus its master key.",
"useHint": "Read `process.env.MEILISEARCH_URL` and `process.env.MEILISEARCH_KEY` in your code — set on your next deploy.",
@@ -1919,7 +2504,8 @@
],
"envKey": "MEILISEARCH_URL",
"recommended": true,
- "help": "The Meilisearch HTTP endpoint. Switch to Internal for apps on the same project network."
+ "help": "HTTP API endpoint — opening it in a browser returns a JSON status, not a UI (the bundled dashboard is off in production mode). Switch to Internal for apps on the same project network.",
+ "kind": "url"
},
{
"id": "masterKey",
@@ -1953,15 +2539,16 @@
},
{
"available": true,
- "verified": false,
+ "verified": true,
"hosting": "experimental",
"minResources": {
- "memoryMb": 8192
+ "memoryMb": 4096,
+ "cpuCores": 2
},
"id": "neon",
"name": "Neon",
- "description": "Self-hosted Neon — the serverless-Postgres storage engine (pageserver, 3 safekeepers, storage broker) on S3-backed object storage, with a Neon compute node built from source. EXPERIMENTAL and heavy: ~9 containers, needs roughly 8 GB RAM, and is a test-grade topology, not a production HA cluster. Not for production data.",
- "repository": "https://github.com/neondatabase/neon",
+ "description": "Self-hosted Neon — serverless Postgres with database branching, a web console and per-branch connection strings, in a single container. Built on the community `neond` control plane (Apache-2.0), which bundles Neon's pageserver, safekeeper, storage broker and storage controller behind a management API and dashboard. Neon's own cloud console is proprietary and the upstream neon repo ships no web UI at all, so a community control plane is the only way to run self-hosted Neon with a dashboard. EXPERIMENTAL: one container and no HA, a ~1.2 GB image, and it publishes fixed host ports for branch endpoints. Not for critical data.",
+ "repository": "https://github.com/matisiekpl/neond",
"kind": "template",
"logo": "neon",
"category": "database",
@@ -1970,146 +2557,87 @@
"postgres",
"postgresql",
"serverless",
- "sql"
+ "sql",
+ "branching"
],
"framework": "docker-compose",
"services": [
{
- "name": "storage_broker",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "storage_broker --listen-addr=0.0.0.0:50051",
- "restart": "unless-stopped"
- },
- {
- "name": "minio",
- "image": "minio/minio:RELEASE.2025-04-22T22-12-26Z",
- "command": "server /data --address :9000 --console-address :9001",
- "volumes": [
- "neon_minio_data:/data"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "pageserver",
- "image": "ghcr.io/neondatabase/neon:latest",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "safekeeper1",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "safekeeper --listen-pg=safekeeper1:5454 --listen-http=0.0.0.0:7676 --id=1 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "safekeeper2",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "safekeeper --listen-pg=safekeeper2:5454 --listen-http=0.0.0.0:7676 --id=2 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "safekeeper3",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "safekeeper --listen-pg=safekeeper3:5454 --listen-http=0.0.0.0:7676 --id=3 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "compute",
- "build": {
- "dockerfile": "FROM ghcr.io/neondatabase/compute-node-v16:latest\n\nUSER root\nRUN echo 'Acquire::Retries \"5\";' > /etc/apt/apt.conf.d/80-retries && \\\n apt-get update && \\\n apt-get install -y curl jq netcat-openbsd && \\\n rm -rf /var/lib/apt/lists/*\n\nCOPY compute/compute.sh /shell/compute.sh\nRUN chmod +x /shell/compute.sh\n\nUSER postgres\nENTRYPOINT [\"/shell/compute.sh\"]\n",
- "files": [
- {
- "path": "compute.sh",
- "content": "#!/usr/bin/env bash\nset -eux\n\n# Generate a random tenant or timeline ID\n#\n# Takes a variable name as argument. The result is stored in that variable.\ngenerate_id() {\n local -n resvar=${1}\n printf -v resvar '%08x%08x%08x%08x' ${SRANDOM} ${SRANDOM} ${SRANDOM} ${SRANDOM}\n}\n\nPG_VERSION=${PG_VERSION:-16}\n\nreadonly CONFIG_FILE_ORG=/var/db/postgres/configs/config.json\nreadonly CONFIG_FILE=/tmp/config.json\n\necho \"Waiting pageserver become ready.\"\nwhile ! nc -z pageserver 6400; do\n sleep 1\ndone\necho \"Page server is ready.\"\n\ncp \"${CONFIG_FILE_ORG}\" \"${CONFIG_FILE}\"\n\nif [[ -n \"${TENANT_ID:-}\" && -n \"${TIMELINE_ID:-}\" ]]; then\n tenant_id=${TENANT_ID}\n timeline_id=${TIMELINE_ID}\nelse\n echo \"Check if a tenant present\"\n PARAMS=(\n -X GET\n -H \"Content-Type: application/json\"\n \"http://pageserver:9898/v1/tenant\"\n )\n tenant_id=$(curl \"${PARAMS[@]}\" | jq -r .[0].id)\n if [[ -z \"${tenant_id}\" || \"${tenant_id}\" = null ]]; then\n echo \"Create a tenant\"\n generate_id tenant_id\n PARAMS=(\n -X PUT\n -H \"Content-Type: application/json\"\n -d \"{\\\"mode\\\": \\\"AttachedSingle\\\", \\\"generation\\\": 1, \\\"tenant_conf\\\": {}}\"\n \"http://pageserver:9898/v1/tenant/${tenant_id}/location_config\"\n )\n result=$(curl \"${PARAMS[@]}\")\n printf '%s\\n' \"${result}\" | jq .\n fi\n\n echo \"Check if a timeline present\"\n PARAMS=(\n -X GET\n -H \"Content-Type: application/json\"\n \"http://pageserver:9898/v1/tenant/${tenant_id}/timeline\"\n )\n timeline_id=$(curl \"${PARAMS[@]}\" | jq -r .[0].timeline_id)\n if [[ -z \"${timeline_id}\" || \"${timeline_id}\" = null ]]; then\n generate_id timeline_id\n PARAMS=(\n -sbf\n -X POST\n -H \"Content-Type: application/json\"\n -d \"{\\\"new_timeline_id\\\": \\\"${timeline_id}\\\", \\\"pg_version\\\": ${PG_VERSION}}\"\n \"http://pageserver:9898/v1/tenant/${tenant_id}/timeline/\"\n )\n result=$(curl \"${PARAMS[@]}\")\n printf '%s\\n' \"${result}\" | jq .\n fi\nfi\n\necho \"Overwrite tenant id and timeline id in spec file\"\nsed -i \"s|TENANT_ID|${tenant_id}|\" ${CONFIG_FILE}\nsed -i \"s|TIMELINE_ID|${timeline_id}|\" ${CONFIG_FILE}\n\ncat ${CONFIG_FILE}\n\necho \"Start compute node\"\n/usr/local/bin/compute_ctl --pgdata /var/db/postgres/compute \\\n -C \"postgresql://cloud_admin@localhost:55433/postgres\" \\\n -b /usr/local/bin/postgres \\\n --compute-id \"compute-${RANDOM}\" \\\n --config \"${CONFIG_FILE}\"\n"
- }
- ]
- },
- "dependsOn": [
- "pageserver",
- "safekeeper1",
- "safekeeper2",
- "safekeeper3"
+ "name": "neond",
+ "image": "neond/neond:f04d396c133d81e28cf52560ea11ef7e9b814d71",
+ "exposed": true,
+ "exposedPort": 3000,
+ "routes": [
+ {
+ "port": 3000
+ }
],
"environment": {
- "PG_VERSION": "16"
+ "PORT": "3000",
+ "PORT_RANGE": "55432-55437",
+ "DO_NOT_TRACK": "1",
+ "TELEMETRY_DISABLED": "1",
+ "RUST_LOG": "info"
},
"ports": [
- "55433:55433"
+ "8220:3000",
+ "0.0.0.0:55432:55432",
+ "0.0.0.0:55433:55433",
+ "0.0.0.0:55434:55434",
+ "0.0.0.0:55435:55435",
+ "0.0.0.0:55436:55436",
+ "0.0.0.0:55437:55437"
],
"volumes": [
- "neon_compute_data:/var/db/postgres/compute"
+ "neond_data:/neond"
],
"healthcheck": {
"test": [
- "CMD-SHELL",
- "pg_isready -h 127.0.0.1 -p 55433 -U cloud_admin || exit 1"
+ "CMD",
+ "curl",
+ "-fsS",
+ "http://127.0.0.1:3000/api/auth/setup"
],
- "interval": "10s",
+ "interval": "30s",
"timeout": "5s",
- "retries": 10,
- "startPeriod": "90s"
+ "retries": 3,
+ "startPeriod": "5m"
},
+ "stopGracePeriod": "10m",
"restart": "unless-stopped"
}
],
"configFields": [
{
- "key": "MINIO_ROOT_USER",
- "service": "minio",
- "label": "Object-storage access key",
- "help": "The S3 access key the Neon storage layer uses against its bundled MinIO.",
+ "key": "SERVER_SECRET",
+ "service": "neond",
+ "label": "Server secret",
+ "help": "Auto-generated, and PERMANENT — it is also the password of the internal management Postgres role, so changing it after the first launch makes the control plane unable to open its own database.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "ADMIN_EMAIL",
+ "service": "neond",
+ "label": "Console admin email",
+ "help": "The first account is created for you and is the instance admin. Sign-up closes as soon as it exists.",
"type": "text",
- "default": "neon",
+ "default": "admin@openship.local",
"required": true
},
{
- "key": "MINIO_ROOT_PASSWORD",
- "service": "minio",
- "label": "Object-storage secret key",
- "help": "Auto-generated. The S3 secret key shared by the pageserver and safekeepers.",
+ "key": "ADMIN_PASSWORD",
+ "service": "neond",
+ "label": "Console admin password",
+ "help": "Auto-generated. Use it with the admin email to sign in to the console.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "PG_PASSWORD",
+ "service": "neond",
+ "label": "Database password",
+ "help": "Auto-generated. Set as the password of the `postgres` role on the first branch so the connection string below is usable immediately.",
"generate": "secret",
"secret": true
}
@@ -2119,78 +2647,73 @@
},
"prepare": [
{
- "service": "minio",
- "command": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" > /dev/null && mc mb --ignore-existing --region eu-north-1 local/neon > /dev/null && printf %s neon",
- "capture": "bucket",
+ "service": "neond",
+ "title": "Create the console account and first branch",
+ "description": "Signs in (registering the admin on a first install), then ensures an organization, project and branch exist and an endpoint is running, and reports its port. Every run reports a REAL port, so a first attempt that raced the endpoint cannot leave the connection string permanently blank.",
+ "command": "set -e; API=http://127.0.0.1:3000/api; J='Content-Type: application/json'; id1() { sed -n 's/.*\"id\":\"\\([^\"]*\\)\".*/\\1/p' | head -1; }; port1() { sed -n -e 's|.*\"connection_string\":\"[^\"]*@[^:\"]*:\\([0-9][0-9]*\\)/.*|\\1|p' -e 's/.*\"port\":\\([0-9][0-9]*\\).*/\\1/p' | head -1; }; get() { curl -fsS -H \"$A\" \"$1\" 2>/dev/null || printf ''; }; post() { n=0; while [ $n -lt 20 ]; do r=$(curl -fsS -X POST \"$1\" -H \"$J\" -H \"$A\" -d \"$2\" 2>/dev/null || printf ''); [ -n \"$r\" ] && { printf %s \"$r\"; return 0; }; n=$((n+1)); sleep 2; done; printf ''; }; SETUP=$(curl -fsS \"$API/auth/setup\" || printf ''); case \"$SETUP\" in *'\"registration_open\":true'*) TOKEN=$(curl -fsS -X POST \"$API/auth/register\" -H \"$J\" -d \"{\\\"name\\\":\\\"Admin\\\",\\\"email\\\":\\\"$ADMIN_EMAIL\\\",\\\"password\\\":\\\"$ADMIN_PASSWORD\\\"}\" 2>/dev/null | sed -n 's/.*\"token\":\"\\([^\"]*\\)\".*/\\1/p') ;; *) TOKEN=$(curl -fsS -X POST \"$API/auth/login\" -H \"$J\" -d \"{\\\"email\\\":\\\"$ADMIN_EMAIL\\\",\\\"password\\\":\\\"$ADMIN_PASSWORD\\\"}\" 2>/dev/null | sed -n 's/.*\"token\":\"\\([^\"]*\\)\".*/\\1/p') ;; esac; [ -n \"$TOKEN\" ] || { echo \"pg_port=no-token\"; exit 0; }; A=\"Authorization: Bearer $TOKEN\"; ORG=$(get \"$API/organizations\" | id1); [ -n \"$ORG\" ] || ORG=$(post \"$API/organizations\" '{\"name\":\"Default\"}' | id1); [ -n \"$ORG\" ] || { echo \"pg_port=no-org\"; exit 0; }; P=\"$API/organizations/$ORG/projects\"; PROJ=$(get \"$P\" | id1); [ -n \"$PROJ\" ] || PROJ=$(post \"$P\" '{\"name\":\"main\"}' | id1); [ -n \"$PROJ\" ] || { echo \"pg_port=no-project\"; exit 0; }; B=\"$P/$PROJ/branches\"; BRJSON=$(get \"$B\"); BR=$(printf %s \"$BRJSON\" | id1); if [ -z \"$BR\" ]; then BR=$(post \"$B\" '{\"name\":\"production\"}' | id1); [ -n \"$BR\" ] || { echo \"pg_port=branch-failed\"; exit 0; }; curl -fsS -X PUT \"$B/$BR/password\" -H \"$J\" -H \"$A\" -d \"{\\\"password\\\":\\\"$PG_PASSWORD\\\"}\" >/dev/null 2>&1 || true; fi; PORT=$(printf %s \"$BRJSON\" | port1); [ -n \"$PORT\" ] || PORT=$(post \"$B/$BR/endpoint\" '' | port1); echo \"pg_port=${PORT:-unstarted}\"",
+ "capture": "pgPort",
+ "capturePattern": "pg_port=([0-9]+)",
+ "persistAs": {
+ "key": "NEOND_PG_PORT"
+ },
+ "once": true,
"phase": "post-ready",
"readiness": {
- "test": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\"",
- "interval": 1000,
- "retries": 30
- },
- "once": true
+ "test": "curl -fsS http://127.0.0.1:3000/api/auth/setup",
+ "interval": 5000,
+ "retries": 60
+ }
}
],
"connection": {
- "title": "Connect to Neon",
- "description": "Point a Postgres driver at the compute node. The compute serves the Postgres wire protocol on port 55433 as user cloud_admin.",
+ "title": "Open the Neon console",
+ "description": "Sign in to the console to manage projects and branches. The install pre-creates the admin account plus a `production` branch with a running endpoint, so the Postgres URL below works immediately.",
"guide": {
- "intro": "Your project gets a Postgres connection served by the Neon compute node.",
- "useHint": "Read process.env.DATABASE_URL in your code — it's set the next time your project deploys.",
- "defaultMode": "internal"
+ "intro": "You get a Neon console for branching plus a normal Postgres connection string for the first branch.",
+ "useHint": "Each branch gets its own endpoint on its own port. Create a branch in the console, press Start endpoint, and copy that branch's connection string — six host ports (55432-55437) are published for endpoints, which is three concurrent branches.",
+ "defaultMode": "public"
},
"outputs": [
+ {
+ "id": "console",
+ "label": "Neon console",
+ "source": "publicUrl:neond",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the admin email and password below."
+ },
+ {
+ "id": "adminEmail",
+ "label": "Admin email",
+ "source": "env:neond:ADMIN_EMAIL",
+ "help": "The instance admin. Sign-up is closed once this account exists."
+ },
+ {
+ "id": "adminPassword",
+ "label": "Admin password",
+ "source": "env:neond:ADMIN_PASSWORD",
+ "secret": true
+ },
{
"id": "dbUrl",
"label": "Database URL",
- "source": "template:postgresql://cloud_admin:cloud_admin@{{host}}:55433/postgres",
- "sourceLabel": "Public",
- "variants": [
- {
- "id": "internal",
- "label": "Internal",
- "source": "template:postgresql://cloud_admin:cloud_admin@compute:55433/postgres"
- }
- ],
+ "source": "template:postgresql://postgres:{{env:neond:PG_PASSWORD}}@{{host}}:{{env:neond:NEOND_PG_PORT}}/postgres?sslmode=require",
"secret": true,
"envKey": "DATABASE_URL",
- "recommended": true,
- "help": "Postgres connection (cloud_admin). Published on port 55433. Switch to Internal for apps on the same project network."
+ "help": "The `production` branch endpoint. Neon assigns each endpoint its own port, so if this shows no port the endpoint has not started yet — open the console and press Start endpoint, then read the connection string there."
}
- ]
+ ],
+ "firstLogin": {
+ "note": "The admin account is created during install — use the email and password above. First boot initialises two embedded Postgres instances and can take a couple of minutes after a ~1.2 GB image pull.\n\nTwo things worth knowing, both upstream behaviour: if the console shows a branch as running but connections fail, stop and start that endpoint from the console — restarting the container can leave a stale compute lock behind while the API still reports it healthy. And if the container is killed hard (out of memory, power loss) it can refuse to boot with \"lease already held\"; delete neon_daemon_data/.lock inside the app's volume while nothing is running. An ordinary redeploy is safe — it shuts down gracefully and releases the lock."
+ }
},
"endpoints": [
{
- "service": "compute",
- "port": 55433,
- "label": "Postgres",
- "kind": "tcp"
- }
- ],
- "files": [
- {
- "service": "pageserver",
- "path": "/data/.neon/pageserver.toml",
- "content": "broker_endpoint='http://storage_broker:50051'\npg_distrib_dir='/usr/local/'\nlisten_pg_addr='0.0.0.0:6400'\nlisten_http_addr='0.0.0.0:9898'\nremote_storage={ endpoint='http://minio:9000', bucket_name='neon', bucket_region='eu-north-1', prefix_in_bucket='/pageserver' }\ncontrol_plane_api='http://0.0.0.0:6666'\ncontrol_plane_emergency_mode=true\nvirtual_file_io_mode=\"buffered\"\n"
- },
- {
- "service": "pageserver",
- "path": "/data/.neon/identity.toml",
- "content": "id=1234\n"
- },
- {
- "service": "compute",
- "path": "/var/db/postgres/configs/config.json",
- "content": "{\n \"spec\": {\n \"format_version\": 1.0,\n\n \"timestamp\": \"2022-10-12T18:00:00.000Z\",\n \"operation_uuid\": \"0f657b36-4b0f-4a2d-9c2e-1dcd615e7d8c\",\n \"suspend_timeout_seconds\": -1,\n\n \"cluster\": {\n \"cluster_id\": \"docker_compose\",\n \"name\": \"docker_compose_test\",\n \"state\": \"restarted\",\n \"roles\": [\n {\n \"name\": \"cloud_admin\",\n \"encrypted_password\": \"b093c0d3b281ba6da1eacc608620abd8\",\n \"options\": null\n }\n ],\n \"databases\": [\n ],\n \"settings\": [\n {\n \"name\": \"fsync\",\n \"value\": \"off\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"wal_level\",\n \"value\": \"logical\",\n \"vartype\": \"enum\"\n },\n {\n \"name\": \"wal_log_hints\",\n \"value\": \"on\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"log_connections\",\n \"value\": \"on\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"port\",\n \"value\": \"55433\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"shared_buffers\",\n \"value\": \"1MB\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_connections\",\n \"value\": \"100\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"listen_addresses\",\n \"value\": \"0.0.0.0\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_wal_senders\",\n \"value\": \"10\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"max_replication_slots\",\n \"value\": \"10\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"wal_sender_timeout\",\n \"value\": \"5s\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"wal_keep_size\",\n \"value\": \"0\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"password_encryption\",\n \"value\": \"md5\",\n \"vartype\": \"enum\"\n },\n {\n \"name\": \"restart_after_crash\",\n \"value\": \"off\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"synchronous_standby_names\",\n \"value\": \"walproposer\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"shared_preload_libraries\",\n \"value\": \"neon,pg_cron,timescaledb,pg_stat_statements\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.safekeepers\",\n \"value\": \"safekeeper1:5454,safekeeper2:5454,safekeeper3:5454\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.timeline_id\",\n \"value\": \"TIMELINE_ID\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.tenant_id\",\n \"value\": \"TENANT_ID\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.pageserver_connstring\",\n \"value\": \"host=pageserver port=6400\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_replication_write_lag\",\n \"value\": \"500MB\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_replication_flush_lag\",\n \"value\": \"10GB\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"cron.database\",\n \"value\": \"postgres\",\n \"vartype\": \"string\"\n }\n ]\n },\n\n \"delta_operations\": [\n ]\n },\n \"compute_ctl_config\": {\n \"jwks\": {\n \"keys\": [\n {\n \"use\": \"sig\",\n \"key_ops\": [\n \"verify\"\n ],\n \"alg\": \"EdDSA\",\n \"kid\": \"ZGIxMzAzOGY0YWQwODk2ODU1MTk1NzMxMDFkYmUyOWU2NzZkOWNjNjMyMGRkZGJjOWY0MjdjYWVmNzE1MjUyOAo=\",\n \"kty\": \"OKP\",\n \"crv\": \"Ed25519\",\n \"x\": \"MGQ4ZDFhOTdmNTM0NmUwMDc3ZmJkN2Q0MWE0ZmI3M2NhNWE3YjFjOTNkM2IyYzRkZTQzOGM3MjBkZTk3N2E5ZAo=\"\n }\n ]\n }\n }\n}\n"
- }
- ],
- "provides": [
- {
- "id": "postgres",
- "outputRefs": [
- "dbUrl"
- ],
- "category": "database"
+ "service": "neond",
+ "port": 3000,
+ "label": "Console",
+ "kind": "http",
+ "defaultMode": "domain"
}
]
},
@@ -2199,12 +2722,12 @@
"verified": false,
"hosting": "experimental",
"minResources": {
- "memoryMb": 8192,
+ "memoryMb": 16384,
"cpuCores": 4
},
"id": "posthog",
"name": "PostHog",
- "description": "Self-hosted PostHog — product analytics, session replay, and feature flags. EXPERIMENTAL and heavy: the hobby stack runs the PostHog app plus Postgres, Redis, ClickHouse, Zookeeper, a Kafka-compatible broker (Redpanda), and MinIO (~9 containers) and wants roughly 4 vCPU / 8–16 GB RAM. Not for production.",
+ "description": "Self-hosted PostHog — product analytics, session replay, and feature flags, with real event ingestion. This mirrors PostHog's own hobby topology: a Caddy path router in front of the Django app, the Rust capture/flags/hypercache/persons services, the Node ingestion consumers, ClickHouse + Kafka + Postgres + Redis + Valkey + S3. EXPERIMENTAL and heavy: 21 containers, wants roughly 4 vCPU / 16 GB RAM and 30+ GB disk. PostHog ships no tagged releases for self-hosting and rebuilds `latest` hourly, so this tracks a moving upstream. Not for production.",
"repository": "https://github.com/PostHog/posthog",
"kind": "template",
"logo": "posthog",
@@ -2218,6 +2741,37 @@
],
"framework": "docker-compose",
"services": [
+ {
+ "name": "proxy",
+ "image": "caddy:2.10-alpine",
+ "exposed": true,
+ "exposedPort": 80,
+ "routes": [
+ {
+ "port": 80
+ }
+ ],
+ "dependsOn": [
+ "web",
+ "capture",
+ "replay-capture",
+ "feature-flags",
+ "hypercache-server",
+ "plugins",
+ "objectstorage"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget --no-verbose --tries=1 --spider http://127.0.0.1:80/openship-health || exit 1"
+ ],
+ "interval": "10s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "60s"
+ },
+ "restart": "unless-stopped"
+ },
{
"name": "db",
"image": "postgres:15.12-alpine",
@@ -2237,16 +2791,22 @@
"pg_isready -U posthog"
],
"interval": "5s",
- "timeout": "5s",
- "retries": 10,
+ "timeout": "30s",
+ "retries": 30,
"startPeriod": "10s"
},
"restart": "unless-stopped"
},
{
- "name": "redis",
+ "name": "redis7",
"image": "redis:7.2-alpine",
- "command": "redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb",
+ "commandArgv": [
+ "redis-server",
+ "--maxmemory-policy",
+ "allkeys-lru",
+ "--maxmemory",
+ "200mb"
+ ],
"volumes": [
"posthog_redis:/data"
],
@@ -2256,8 +2816,30 @@
"redis-cli",
"ping"
],
- "interval": "5s",
- "timeout": "5s",
+ "interval": "3s",
+ "timeout": "10s",
+ "retries": 10
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "valkey",
+ "image": "valkey/valkey:8.1-alpine",
+ "commandArgv": [
+ "valkey-server",
+ "--maxmemory-policy",
+ "allkeys-lru",
+ "--maxmemory",
+ "200mb"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD",
+ "valkey-cli",
+ "ping"
+ ],
+ "interval": "3s",
+ "timeout": "10s",
"retries": 10
},
"restart": "unless-stopped"
@@ -2271,41 +2853,90 @@
},
"volumes": [
"posthog_zk_data:/data",
- "posthog_zk_datalog:/datalog"
+ "posthog_zk_datalog:/datalog",
+ "posthog_zk_logs:/logs"
],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "echo ruok | nc -w 2 localhost 2181 | grep -q imok"
+ ],
+ "interval": "5s",
+ "timeout": "10s",
+ "retries": 20,
+ "startPeriod": "10s"
+ },
"restart": "unless-stopped"
},
{
"name": "kafka",
- "image": "docker.io/redpandadata/redpanda:v25.1.9",
- "command": "redpanda start --kafka-addr internal://0.0.0.0:9092 --advertise-kafka-addr internal://kafka:9092 --mode dev-container --smp 1 --memory 1G",
+ "image": "apache/kafka:4.1.0",
+ "environment": {
+ "KAFKA_NODE_ID": "1",
+ "KAFKA_PROCESS_ROLES": "broker,controller",
+ "KAFKA_LISTENERS": "PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093",
+ "KAFKA_ADVERTISED_LISTENERS": "PLAINTEXT://kafka:9092",
+ "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP": "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT",
+ "KAFKA_CONTROLLER_LISTENER_NAMES": "CONTROLLER",
+ "KAFKA_INTER_BROKER_LISTENER_NAME": "PLAINTEXT",
+ "KAFKA_CONTROLLER_QUORUM_VOTERS": "1@kafka:9093",
+ "KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR": "1",
+ "KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR": "1",
+ "KAFKA_TRANSACTION_STATE_LOG_MIN_ISR": "1",
+ "KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS": "0",
+ "KAFKA_AUTO_CREATE_TOPICS_ENABLE": "true",
+ "KAFKA_NUM_PARTITIONS": "1",
+ "KAFKA_DEFAULT_REPLICATION_FACTOR": "1",
+ "KAFKA_LOG_RETENTION_HOURS": "1",
+ "KAFKA_LOG_DIRS": "/var/lib/kafka/data"
+ },
"volumes": [
- "posthog_kafka:/var/lib/redpanda/data"
+ "posthog_kafka:/var/lib/kafka/data"
],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1 || exit 1"
+ ],
+ "interval": "5s",
+ "timeout": "10s",
+ "retries": 30,
+ "startPeriod": "20s"
+ },
"restart": "unless-stopped"
},
{
"name": "clickhouse",
- "image": "clickhouse/clickhouse-server:24.12",
+ "image": "clickhouse/clickhouse-server:26.6.2.158",
"dependsOn": [
- "kafka",
- "zookeeper"
+ "zookeeper",
+ "kafka"
],
"environment": {
- "CLICKHOUSE_SKIP_USER_SETUP": "1"
+ "CLICKHOUSE_SKIP_USER_SETUP": "1",
+ "KAFKA_HOSTS": "kafka:9092"
},
"volumes": [
"posthog_clickhouse:/var/lib/clickhouse"
],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"
+ ],
+ "interval": "3s",
+ "timeout": "10s",
+ "retries": 30,
+ "startPeriod": "20s"
+ },
"restart": "unless-stopped"
},
{
"name": "objectstorage",
- "image": "minio/minio:RELEASE.2025-04-22T22-12-26Z",
- "command": "server /data --console-address :9001",
- "secretEnv": [
- "MINIO_ROOT_PASSWORD"
- ],
+ "image": "chrislusf/seaweedfs:4.29",
+ "environment": {
+ "S3_BUCKET": "posthog,ducklake-dev,ai-blobs"
+ },
"volumes": [
"posthog_objectstorage:/data"
],
@@ -2314,116 +2945,468 @@
{
"name": "web",
"image": "posthog/posthog:latest",
- "command": "./bin/docker-server",
+ "commandArgv": [
+ "sh",
+ "-c",
+ "./bin/migrate && exec ./bin/docker-server"
+ ],
"dependsOn": [
"db",
- "redis",
+ "redis7",
"clickhouse",
"kafka",
- "objectstorage"
- ],
- "exposedPort": 8000,
- "exposed": true,
- "routes": [
- {
- "port": 8000
- }
+ "objectstorage",
+ "personhog-router"
],
"environment": {
"DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_WRITER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_READER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PGHOST": "db",
+ "PGUSER": "posthog",
+ "PGPASSWORD": "{{config:POSTGRES_PASSWORD}}",
"CLICKHOUSE_HOST": "clickhouse",
"CLICKHOUSE_DATABASE": "posthog",
"CLICKHOUSE_SECURE": "false",
"CLICKHOUSE_VERIFY": "false",
- "KAFKA_HOSTS": "kafka:9092",
- "KAFKA_URL": "kafka://kafka:9092",
- "REDIS_URL": "redis://redis:6379/",
+ "CLICKHOUSE_API_USER": "api",
+ "CLICKHOUSE_API_PASSWORD": "apipass",
+ "CLICKHOUSE_APP_USER": "app",
+ "CLICKHOUSE_APP_PASSWORD": "apppass",
+ "CLICKHOUSE_BILLING_USER": "billing",
+ "CLICKHOUSE_BILLING_PASSWORD": "billingpass",
+ "CLICKHOUSE_DICT_READER_USER": "dict_reader",
+ "CLICKHOUSE_DICT_READER_PASSWORD": "dictreaderpass",
+ "CLICKHOUSE_LOGS_CLUSTER_HOST": "clickhouse",
+ "CLICKHOUSE_LOGS_CLUSTER_SECURE": "false",
+ "REDIS_URL": "redis://redis7:6379/",
+ "KAFKA_HOSTS": "kafka",
+ "DEPLOYMENT": "hobby",
"SECRET_KEY": "{{config:POSTHOG_SECRET}}",
- "OBJECT_STORAGE_ENABLED": "True",
- "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:9000",
- "OBJECT_STORAGE_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}",
- "OBJECT_STORAGE_BUCKET": "posthog",
- "SITE_URL": "{{publicUrl:web}}",
- "DISABLE_SECURE_SSL_REDIRECT": "true",
+ "SITE_URL": "{{publicUrl:proxy}}",
"IS_BEHIND_PROXY": "true",
- "TRUST_ALL_PROXIES": "true"
+ "DISABLE_SECURE_SSL_REDIRECT": "true",
+ "OTEL_SDK_DISABLED": "true",
+ "OPT_OUT_CAPTURE": "false",
+ "FLAGS_REDIS_ENABLED": "false",
+ "FEATURE_FLAGS_SERVICE_URL": "http://feature-flags:3001",
+ "CDP_API_URL": "http://plugins:6738",
+ "RECORDING_API_URL": "http://recording-api:6738",
+ "PERSONHOG_ADDR": "personhog-router:50052",
+ "PERSONHOG_ENABLED": "true",
+ "OBJECT_STORAGE_ENABLED": "true",
+ "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:8333",
+ "OBJECT_STORAGE_PUBLIC_ENDPOINT": "{{publicUrl:proxy}}",
+ "OBJECT_STORAGE_FORCE_PATH_STYLE": "true",
+ "OBJECT_STORAGE_BUCKET": "posthog",
+ "OBJECT_STORAGE_ACCESS_KEY_ID": "posthog",
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY": "posthog",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any"
},
"secretEnv": [
"DATABASE_URL",
- "SECRET_KEY",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY"
+ "PERSONS_DB_WRITER_URL",
+ "PERSONS_DB_READER_URL",
+ "PGPASSWORD",
+ "SECRET_KEY"
],
"restart": "unless-stopped"
},
{
"name": "worker",
"image": "posthog/posthog:latest",
- "command": "./bin/docker-worker-celery --with-scheduler",
+ "commandArgv": [
+ "./bin/docker-worker-celery",
+ "--with-scheduler"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "clickhouse",
+ "kafka",
+ "objectstorage",
+ "web",
+ "personhog-router"
+ ],
+ "environment": {
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_WRITER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_READER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PGHOST": "db",
+ "PGUSER": "posthog",
+ "PGPASSWORD": "{{config:POSTGRES_PASSWORD}}",
+ "CLICKHOUSE_HOST": "clickhouse",
+ "CLICKHOUSE_DATABASE": "posthog",
+ "CLICKHOUSE_SECURE": "false",
+ "CLICKHOUSE_VERIFY": "false",
+ "CLICKHOUSE_API_USER": "api",
+ "CLICKHOUSE_API_PASSWORD": "apipass",
+ "CLICKHOUSE_APP_USER": "app",
+ "CLICKHOUSE_APP_PASSWORD": "apppass",
+ "CLICKHOUSE_BILLING_USER": "billing",
+ "CLICKHOUSE_BILLING_PASSWORD": "billingpass",
+ "CLICKHOUSE_DICT_READER_USER": "dict_reader",
+ "CLICKHOUSE_DICT_READER_PASSWORD": "dictreaderpass",
+ "CLICKHOUSE_LOGS_CLUSTER_HOST": "clickhouse",
+ "CLICKHOUSE_LOGS_CLUSTER_SECURE": "false",
+ "REDIS_URL": "redis://redis7:6379/",
+ "KAFKA_HOSTS": "kafka",
+ "DEPLOYMENT": "hobby",
+ "POSTHOG_SKIP_MIGRATION_CHECKS": "1",
+ "SECRET_KEY": "{{config:POSTHOG_SECRET}}",
+ "SITE_URL": "{{publicUrl:proxy}}",
+ "IS_BEHIND_PROXY": "true",
+ "DISABLE_SECURE_SSL_REDIRECT": "true",
+ "OTEL_SDK_DISABLED": "true",
+ "FLAGS_REDIS_ENABLED": "false",
+ "FEATURE_FLAGS_SERVICE_URL": "http://feature-flags:3001",
+ "CDP_API_URL": "http://plugins:6738",
+ "RECORDING_API_URL": "http://recording-api:6738",
+ "PERSONHOG_ADDR": "personhog-router:50052",
+ "PERSONHOG_ENABLED": "true",
+ "OBJECT_STORAGE_ENABLED": "true",
+ "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:8333",
+ "OBJECT_STORAGE_PUBLIC_ENDPOINT": "{{publicUrl:proxy}}",
+ "OBJECT_STORAGE_FORCE_PATH_STYLE": "true",
+ "OBJECT_STORAGE_BUCKET": "posthog",
+ "OBJECT_STORAGE_ACCESS_KEY_ID": "posthog",
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY": "posthog",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any"
+ },
+ "secretEnv": [
+ "DATABASE_URL",
+ "PERSONS_DB_WRITER_URL",
+ "PERSONS_DB_READER_URL",
+ "PGPASSWORD",
+ "SECRET_KEY"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "capture",
+ "image": "ghcr.io/posthog/posthog/capture:master",
+ "dependsOn": [
+ "kafka",
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3000",
+ "CAPTURE_MODE": "events",
+ "KAFKA_TOPIC": "events_plugin_ingestion",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "RUST_LOG": "info,rdkafka=warn",
+ "CAPTURE_V1_SINKS": "msk",
+ "CAPTURE_V1_SINK_MSK_KAFKA_HOSTS": "kafka:9092",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_MAIN": "events_plugin_ingestion",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_HISTORICAL": "events_plugin_ingestion_historical",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_OVERFLOW": "events_plugin_ingestion_overflow",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_DLQ": "events_plugin_ingestion_dlq",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_EXCEPTION": "ingestion-errortracking-main",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_HEATMAP": "heatmaps_ingestion",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_CLIENT_INGESTION_WARNING": "ingestion-clientwarnings-main-1"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "replay-capture",
+ "image": "ghcr.io/posthog/posthog/capture:master",
+ "dependsOn": [
+ "kafka",
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3000",
+ "CAPTURE_MODE": "recordings",
+ "KAFKA_TOPIC": "session_recording_snapshot_item_events",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "RUST_LOG": "info,rdkafka=warn"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "ingestion-general",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "clickhouse",
+ "kafka",
+ "objectstorage",
+ "personhog-router"
+ ],
+ "environment": {
+ "PLUGIN_SERVER_MODE": "ingestion-v2-combined",
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "BEHAVIORAL_COHORTS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "CLICKHOUSE_HOST": "clickhouse",
+ "CLICKHOUSE_DATABASE": "posthog",
+ "CLICKHOUSE_SECURE": "false",
+ "CLICKHOUSE_VERIFY": "false",
+ "COOKIELESS_REDIS_HOST": "redis7",
+ "COOKIELESS_REDIS_PORT": "6379",
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379",
+ "PERSONHOG_ADDR": "personhog-router:50052",
+ "PERSONHOG_ENABLED": "true",
+ "AI_BLOB_S3_BUCKET": "ai-blobs",
+ "AI_BLOB_S3_PREFIX": "aio/",
+ "AI_BLOB_S3_ENDPOINT": "http://objectstorage:8333",
+ "AI_BLOB_S3_REGION": "us-east-1",
+ "AI_BLOB_S3_ACCESS_KEY_ID": "any",
+ "AI_BLOB_S3_SECRET_ACCESS_KEY": "any",
+ "AI_BLOB_OFFLOAD_TEAMS": "*"
+ },
+ "secretEnv": [
+ "DATABASE_URL",
+ "PERSONS_DATABASE_URL",
+ "BEHAVIORAL_COHORTS_DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "ingestion-sessionreplay",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "kafka",
+ "objectstorage"
+ ],
+ "environment": {
+ "PLUGIN_SERVER_MODE": "recordings-blob-ingestion-v2",
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any",
+ "SESSION_RECORDING_V2_S3_TIMEOUT_MS": "120000",
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379"
+ },
+ "secretEnv": [
+ "DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "recording-api",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "clickhouse"
+ ],
+ "environment": {
+ "PLUGIN_SERVER_MODE": "recording-api",
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "REDIS_URL": "redis://redis7:6379/",
+ "CLICKHOUSE_HOST": "clickhouse",
+ "CLICKHOUSE_DATABASE": "posthog",
+ "CLICKHOUSE_SECURE": "false",
+ "CLICKHOUSE_VERIFY": "false",
+ "SESSION_RECORDING_API_REDIS_HOST": "redis7",
+ "SESSION_RECORDING_API_REDIS_PORT": "6379",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any",
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379"
+ },
+ "secretEnv": [
+ "DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "feature-flags",
+ "image": "ghcr.io/posthog/posthog/feature-flags:master",
+ "dependsOn": [
+ "db",
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3001",
+ "WRITE_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "READ_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_WRITE_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_READ_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "REDIS_URL": "redis://redis7:6379/",
+ "COOKIELESS_REDIS_HOST": "redis7",
+ "COOKIELESS_REDIS_PORT": "6379",
+ "MAXMIND_DB_PATH": "/app/share/GeoLite2-City.mmdb",
+ "RUST_LOG": "info"
+ },
+ "secretEnv": [
+ "WRITE_DATABASE_URL",
+ "READ_DATABASE_URL",
+ "PERSONS_WRITE_DATABASE_URL",
+ "PERSONS_READ_DATABASE_URL"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD",
+ "curl",
+ "-f",
+ "http://localhost:3001/_readiness"
+ ],
+ "interval": "5s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "10s"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "hypercache-server",
+ "image": "ghcr.io/posthog/posthog/hypercache-server:master",
+ "dependsOn": [
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3002",
+ "REDIS_URL": "redis://redis7:6379/",
+ "RUST_LOG": "info"
+ },
+ "healthcheck": {
+ "test": [
+ "CMD",
+ "curl",
+ "-f",
+ "http://localhost:3002/_readiness"
+ ],
+ "interval": "5s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "10s"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "personhog-replica",
+ "image": "ghcr.io/posthog/posthog/personhog-replica:master",
+ "dependsOn": [
+ "db"
+ ],
+ "environment": {
+ "GRPC_ADDRESS": "0.0.0.0:50051",
+ "PRIMARY_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "METRICS_PORT": "9100",
+ "RUST_LOG": "info"
+ },
+ "secretEnv": [
+ "PRIMARY_DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "personhog-router",
+ "image": "ghcr.io/posthog/posthog/personhog-router:master",
+ "dependsOn": [
+ "personhog-replica"
+ ],
+ "environment": {
+ "GRPC_ADDRESS": "0.0.0.0:50052",
+ "REPLICA_URL": "http://personhog-replica:50051",
+ "BACKEND_TIMEOUT_MS": "5000",
+ "METRICS_PORT": "9101",
+ "RUST_LOG": "info"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "property-defs-rs",
+ "image": "ghcr.io/posthog/posthog/property-defs-rs:master",
"dependsOn": [
"db",
- "redis",
- "clickhouse",
- "kafka",
- "objectstorage",
- "web"
+ "kafka"
],
"environment": {
"DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
- "CLICKHOUSE_HOST": "clickhouse",
- "CLICKHOUSE_DATABASE": "posthog",
- "CLICKHOUSE_SECURE": "false",
- "CLICKHOUSE_VERIFY": "false",
"KAFKA_HOSTS": "kafka:9092",
- "KAFKA_URL": "kafka://kafka:9092",
- "REDIS_URL": "redis://redis:6379/",
- "SECRET_KEY": "{{config:POSTHOG_SECRET}}",
- "OBJECT_STORAGE_ENABLED": "True",
- "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:9000",
- "OBJECT_STORAGE_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}",
- "OBJECT_STORAGE_BUCKET": "posthog",
- "SITE_URL": "{{publicUrl:web}}"
+ "SKIP_WRITES": "false",
+ "SKIP_READS": "false",
+ "FILTER_MODE": "opt-out",
+ "RUST_LOG": "info"
},
"secretEnv": [
- "DATABASE_URL",
- "SECRET_KEY",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY"
+ "DATABASE_URL"
],
"restart": "unless-stopped"
},
{
"name": "plugins",
- "image": "posthog/posthog:latest",
- "command": "./bin/plugin-server --no-restart-loop",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
"dependsOn": [
"db",
- "redis",
+ "redis7",
+ "valkey",
"clickhouse",
"kafka",
- "objectstorage",
- "web"
+ "objectstorage"
],
"environment": {
"DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "BEHAVIORAL_COHORTS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "CYCLOTRON_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
"CLICKHOUSE_HOST": "clickhouse",
"CLICKHOUSE_DATABASE": "posthog",
"CLICKHOUSE_SECURE": "false",
"CLICKHOUSE_VERIFY": "false",
- "KAFKA_HOSTS": "kafka:9092",
- "KAFKA_URL": "kafka://kafka:9092",
- "REDIS_URL": "redis://redis:6379/",
+ "SITE_URL": "{{publicUrl:proxy}}",
"SECRET_KEY": "{{config:POSTHOG_SECRET}}",
- "OBJECT_STORAGE_ENABLED": "True",
- "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:9000",
- "OBJECT_STORAGE_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}",
- "OBJECT_STORAGE_BUCKET": "posthog"
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379",
+ "OBJECT_STORAGE_ENABLED": "true",
+ "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:8333",
+ "OBJECT_STORAGE_PUBLIC_ENDPOINT": "{{publicUrl:proxy}}",
+ "OBJECT_STORAGE_FORCE_PATH_STYLE": "true",
+ "OBJECT_STORAGE_BUCKET": "posthog",
+ "OBJECT_STORAGE_ACCESS_KEY_ID": "any",
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY": "any"
},
"secretEnv": [
"DATABASE_URL",
- "SECRET_KEY",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY"
+ "PERSONS_DATABASE_URL",
+ "BEHAVIORAL_COHORTS_DATABASE_URL",
+ "CYCLOTRON_DATABASE_URL",
+ "SECRET_KEY"
],
"restart": "unless-stopped"
}
@@ -2433,7 +3416,7 @@
"key": "POSTGRES_PASSWORD",
"service": "db",
"label": "Database password",
- "help": "Auto-generated. The Postgres password PostHog uses.",
+ "help": "Auto-generated. The Postgres password every PostHog service uses.",
"generate": "secret",
"secret": true
},
@@ -2444,68 +3427,72 @@
"help": "Auto-generated. Signs sessions and cookies (SECRET_KEY).",
"generate": "secret",
"secret": true
+ }
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "files": [
+ {
+ "service": "proxy",
+ "path": "/etc/caddy/Caddyfile",
+ "content": "{\n\tservers {\n\t\ttrusted_proxies static 127.0.0.1/32 ::1/128 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16\n\t}\n}\n\n# Host-less site address on purpose. PostHog's own default is\n# `http://localhost:8000`, which compiles to a Host matcher — behind Openship's\n# edge a foreign Host then returns an empty HTTP 200 instead of a 404, so every\n# ingestion POST would look successful while the events were silently dropped.\n# `:80` also emits no automatic_https and no tls app, so Caddy never tries ACME.\n:80 {\n\t# Health target for the container healthcheck. Deliberately answered by\n\t# Caddy itself: proxying /_health through to Django would report unhealthy\n\t# for the whole first-boot migration window and raise a false incident.\n\t@openship-health {\n\t\tpath /openship-health\n\t}\n\n\thandle @openship-health {\n\t\trespond \"ok\" 200\n\t}\n\n\t@replay-capture {\n\t\tpath /s\n\t\tpath /s/\n\t\tpath /s/*\n\t}\n\n\t@capture {\n\t\tpath /e\n\t\tpath /e/\n\t\tpath /e/*\n\t\tpath /i/v0\n\t\tpath /i/v0/\n\t\tpath /i/v0/*\n\t\tpath /i/v1/analytics/events\n\t\tpath /i/v1/analytics/events/\n\t\tpath /batch\n\t\tpath /batch/\n\t\tpath /batch/*\n\t\tpath /capture\n\t\tpath /capture/\n\t\tpath /capture/*\n\t}\n\n\t@flags {\n\t\tpath /flags\n\t\tpath /flags/\n\t\tpath /flags/*\n\t\tpath /api/feature_flag/local_evaluation\n\t\tpath /api/feature_flag/local_evaluation/\n\t\tpath /api/feature_flag/local_evaluation/*\n\t}\n\n\t@surveys {\n\t\tpath /surveys\n\t\tpath /surveys/\n\t\tpath /api/surveys\n\t\tpath /api/surveys/\n\t}\n\n\t@remote-config {\n\t\tpath /array/*\n\t}\n\n\t@webhooks {\n\t\tpath /public/webhooks\n\t\tpath /public/webhooks/\n\t\tpath /public/webhooks/*\n\t\tpath /public/m/\n\t\tpath /public/m/*\n\t}\n\n\t@objectstorage {\n\t\tpath /posthog\n\t\tpath /posthog/\n\t\tpath /posthog/*\n\t}\n\n\thandle @capture {\n\t\treverse_proxy capture:3000\n\t}\n\n\thandle @replay-capture {\n\t\treverse_proxy replay-capture:3000\n\t}\n\n\thandle @flags {\n\t\treverse_proxy feature-flags:3001\n\t}\n\n\thandle @surveys {\n\t\treverse_proxy hypercache-server:3002\n\t}\n\n\thandle @remote-config {\n\t\treverse_proxy hypercache-server:3002\n\t}\n\n\thandle @webhooks {\n\t\treverse_proxy plugins:6738\n\t}\n\n\thandle @objectstorage {\n\t\treverse_proxy objectstorage:8333\n\t}\n\n\thandle {\n\t\treverse_proxy web:8000\n\t}\n}\n"
},
{
- "key": "MINIO_ROOT_USER",
- "service": "objectstorage",
- "label": "Object-storage access key",
- "help": "The S3 access key PostHog uses against its bundled MinIO (session recordings).",
- "type": "text",
- "default": "posthog",
- "required": true
+ "service": "clickhouse",
+ "path": "/etc/clickhouse-server/config.d/openship-posthog.xml",
+ "content": "\n \n \n \n zookeeper \n 2181 \n \n \n \n 01 \n ch1 \n \n \n /clickhouse/task_queue/ddl \n \n \n 256 \n /var/lib/clickhouse/format_schemas/ \n \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n \n \n \n \n \n \n \n \n \n \n \n \n \n"
},
{
- "key": "MINIO_ROOT_PASSWORD",
- "service": "objectstorage",
- "label": "Object-storage secret key",
- "help": "Auto-generated. The S3 secret key for the bundled MinIO.",
- "generate": "secret",
- "secret": true
+ "service": "clickhouse",
+ "path": "/etc/clickhouse-server/users.d/openship-posthog.xml",
+ "content": "\n \n \n \n 10000000000 \n 0 \n random \n \n \n \n \n \n ::/0 \n default \n default \n 1 \n \n \n apipass \n ::/0 \n default \n default \n \n \n apppass \n ::/0 \n default \n default \n \n \n billingpass \n ::/0 \n default \n default \n \n \n dictreaderpass \n ::/0 \n default \n default \n \n \n \n 3600 \n \n \n"
}
],
- "management": {
- "kind": "schema"
- },
"prepare": [
{
- "service": "objectstorage",
- "command": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" > /dev/null && mc mb --ignore-existing local/posthog > /dev/null && printf %s posthog",
- "capture": "bucket",
+ "service": "kafka",
+ "title": "Create Kafka topics",
+ "description": "Pre-creates the ingestion topics the Node consumers verify at startup.",
+ "command": "for t in events_plugin_ingestion events_plugin_ingestion_historical events_plugin_ingestion_overflow events_plugin_ingestion_dlq events_plugin_ingestion_ai events_plugin_ingestion_async session_recording_snapshot_item_events clickhouse_events_json clickhouse_ai_events_json clickhouse_heatmap_events clickhouse_flag_evaluations clickhouse_ingestion_warnings clickhouse_groups clickhouse_person clickhouse_person_distinct_id clickhouse_person_distinct_id2 clickhouse_person_overrides clickhouse_app_metrics2 clickhouse_session_replay_events clickhouse_session_recording_events clickhouse_tophog heatmaps_ingestion ingestion-clientwarnings-main-1 ingestion-errortracking-main log_entries plugin_log_entries events_dead_letter_queue; do /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --if-not-exists --topic \"$t\" --partitions 1 --replication-factor 1 >/dev/null 2>&1 || true; done; echo done",
+ "capture": "topics",
"phase": "post-ready",
"readiness": {
- "test": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\"",
- "interval": 1000,
+ "test": "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092",
+ "interval": 3000,
"retries": 30
- },
- "once": true
+ }
}
],
"connection": {
"title": "Open PostHog",
- "description": "Open the PostHog UI and create your admin account on first load.",
+ "description": "Open the PostHog UI and create your admin account on first load. The same hostname serves the UI and the event-ingestion endpoints, so an SDK pointed at this URL works with no extra configuration.",
"guide": {
- "intro": "PostHog runs its own analytics UI — create the admin account when you first open it.",
+ "intro": "PostHog runs its own analytics UI — create the admin account when you first open it, then use the project API key it gives you in your SDK.",
+ "useHint": "Point your SDK's host at this URL. Caddy routes /e, /capture, /batch and /i/* to the capture service, /s/* to session replay, /flags to feature flags, and everything else to the app.",
"defaultMode": "public"
},
"outputs": [
{
"id": "ui",
"label": "PostHog",
- "source": "publicUrl:web",
+ "source": "publicUrl:proxy",
"kind": "url",
- "help": "PostHog has no default login — the first visitor creates the admin account."
+ "recommended": true,
+ "help": "PostHog has no default login — the first visitor creates the admin account. This host also receives your events."
}
],
"firstLogin": {
- "note": "PostHog ships no default credentials — open the URL above and sign up to create the first (admin) account."
+ "note": "PostHog ships no default credentials — open the URL above and sign up to create the first (admin) account. First boot runs database migrations and can take several minutes before the UI answers."
}
},
"endpoints": [
{
- "service": "web",
- "port": 8000,
+ "service": "proxy",
+ "port": 80,
"label": "PostHog",
- "kind": "http"
+ "kind": "http",
+ "defaultMode": "domain"
}
]
},
@@ -2514,7 +3501,8 @@
"verified": true,
"id": "redis",
"name": "Valkey (Redis)",
- "description": "In-memory data store for caching, sessions, rate limits, and queues — Valkey, the open-source Redis fork. Drop-in Redis-compatible: point any redis client at it.",
+ "description": "In-memory data store for caching, sessions, rate limits, and queues — Valkey, the open-source Redis fork. Drop-in Redis-compatible: point any redis client at it. Ships with RedisInsight, a browser UI that arrives already connected to this instance.",
+ "repository": "https://github.com/valkey-io/valkey",
"kind": "template",
"logo": "valkey",
"category": "database",
@@ -2523,16 +3511,22 @@
"redis",
"valkey",
"key-value",
- "queue"
+ "queue",
+ "gui"
],
"framework": "docker-compose",
"services": [
{
"name": "valkey",
"image": "valkey/valkey:8.1-alpine",
- "command": "valkey-server /etc/valkey/valkey.conf",
- "ports": [
- "6379:6379"
+ "commandArgv": [
+ "valkey-server"
+ ],
+ "environment": {
+ "VALKEY_EXTRA_FLAGS": "--requirepass {{config:VALKEY_PASSWORD}} --appendonly yes --appendfsync everysec --save 300 100"
+ },
+ "secretEnv": [
+ "VALKEY_EXTRA_FLAGS"
],
"volumes": [
"valkey_data:/data"
@@ -2540,7 +3534,7 @@
"healthcheck": {
"test": [
"CMD-SHELL",
- "valkey-cli --no-auth-warning -a \"$VALKEY_PASSWORD\" ping | grep -q PONG"
+ "valkey-cli ping 2>&1 | grep -q NOAUTH && valkey-cli --no-auth-warning -a \"$VALKEY_PASSWORD\" ping | grep -q PONG"
],
"interval": "10s",
"timeout": "5s",
@@ -2548,6 +3542,38 @@
"startPeriod": "10s"
},
"restart": "unless-stopped"
+ },
+ {
+ "name": "redisinsight",
+ "image": "redis/redisinsight:3.8.0",
+ "exposedPort": 5540,
+ "exposed": true,
+ "routes": [
+ {
+ "port": 5540
+ }
+ ],
+ "dependsOn": [
+ "valkey"
+ ],
+ "environment": {
+ "RI_ACCEPT_TERMS_AND_CONDITIONS": "true",
+ "RI_REDIS_HOST": "valkey",
+ "RI_REDIS_PORT": "6379",
+ "RI_REDIS_DB": "0",
+ "RI_REDIS_ALIAS": "Valkey (this app)",
+ "RI_REDIS_PASSWORD": "{{config:VALKEY_PASSWORD}}"
+ },
+ "secretEnv": [
+ "RI_REDIS_PASSWORD"
+ ],
+ "volumes": [
+ "redisinsight_data:/data"
+ ],
+ "restart": "unless-stopped",
+ "ports": [
+ "8219:5540"
+ ]
}
],
"configFields": [
@@ -2555,46 +3581,58 @@
"key": "VALKEY_PASSWORD",
"service": "valkey",
"label": "Password",
- "help": "Auto-generated. Required by every client (Valkey's `requirepass` auth).",
+ "help": "Auto-generated. Required by every client (Valkey's `requirepass` auth). The bundled browser UI is pre-loaded with it.",
"generate": "secret",
"secret": true
}
],
- "files": [
- {
- "service": "valkey",
- "path": "/etc/valkey/valkey.conf",
- "content": "requirepass {{config:VALKEY_PASSWORD}}\nappendonly yes\nappendfsync everysec\nsave 300 100\n"
- }
- ],
"management": {
"kind": "schema"
},
"connection": {
"title": "Connect to Valkey",
- "description": "A Redis-compatible endpoint. Point any redis client at the URL below — it already carries the password.",
+ "description": "A Redis-compatible endpoint on your project's private network. Point any redis client at the URL below — it already carries the password.",
"guide": {
- "intro": "Your project gets a Redis-compatible cache, ready to use.",
+ "intro": "Your project gets a Redis-compatible cache plus a browser UI that is already connected to it.",
"useHint": "Read `process.env.REDIS_URL` in your code — it's set the next time your project deploys.",
"defaultMode": "internal"
},
"outputs": [
+ {
+ "id": "ui",
+ "label": "Browser UI",
+ "source": "publicUrl:redisinsight",
+ "kind": "url",
+ "recommended": true,
+ "help": "RedisInsight, already pointed at this instance — no connection details to enter. It has NO login of its own, so anyone who can reach this address can read and write your data: keep it on the published port (reachable through an SSH tunnel) unless you deliberately put it on a domain you protect.",
+ "width": "full"
+ },
{
"id": "url",
"label": "Connection URL",
- "source": "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@{{host}}:6379",
- "sourceLabel": "Public",
- "variants": [
- {
- "id": "internal",
- "label": "Internal",
- "source": "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@valkey:6379"
- }
- ],
+ "source": "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@valkey:6379",
+ "sourceLabel": "Internal",
+ "service": "valkey",
"secret": true,
"envKey": "REDIS_URL",
"recommended": true,
- "help": "Redis-protocol URL with the password embedded. Published on port 6379. Switch to Internal for apps on the same project network."
+ "help": "Redis-protocol URL with the password embedded, on the project's private network. Port 6379 is not published to the internet — bind this app into another project to use it, or reach it from the server with `docker exec`."
+ },
+ {
+ "id": "host",
+ "label": "Host",
+ "source": "template:valkey",
+ "service": "valkey",
+ "envKey": "REDIS_HOST",
+ "width": "half"
+ },
+ {
+ "id": "port",
+ "label": "Port",
+ "source": "template:6379",
+ "service": "valkey",
+ "envKey": "REDIS_PORT",
+ "width": "half"
},
{
"id": "password",
@@ -2616,11 +3654,23 @@
}
],
"endpoints": [
+ {
+ "service": "redisinsight",
+ "port": 5540,
+ "label": "Browser UI",
+ "kind": "http",
+ "scope": "public"
+ },
{
"service": "valkey",
"port": 6379,
- "label": "Redis / Valkey",
- "kind": "tcp"
+ "label": "Redis / Valkey (private)",
+ "kind": "tcp",
+ "scope": "internal",
+ "defaultMode": "internal",
+ "allowedModes": [
+ "internal"
+ ]
}
]
},
@@ -2646,9 +3696,6 @@
"image": "ghcr.io/umami-software/umami:postgresql-v2.19.0",
"exposedPort": 3000,
"exposed": true,
- "ports": [
- "3000:3000"
- ],
"routes": [
{
"port": 3000
@@ -2674,7 +3721,10 @@
"retries": 10,
"startPeriod": "30s"
},
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "3011:3000"
+ ]
},
{
"name": "umami-db",
@@ -2923,6 +3973,276 @@
]
}
]
+ },
+ {
+ "available": true,
+ "verified": true,
+ "id": "clickhouse",
+ "name": "ClickHouse",
+ "description": "The columnar SQL database for analytics — billions of rows scanned per second. Ships with the CH-UI console, so you get a SQL editor, schema browser and dashboards the moment it installs: sign in with the ClickHouse user and password below, no extra setup.",
+ "repository": "https://github.com/ClickHouse/ClickHouse",
+ "kind": "template",
+ "logo": "clickhouse",
+ "category": "database",
+ "tags": [
+ "analytics",
+ "olap",
+ "sql",
+ "columnar",
+ "warehouse",
+ "timeseries"
+ ],
+ "framework": "docker-compose",
+ "minResources": {
+ "memoryMb": 2048
+ },
+ "services": [
+ {
+ "name": "clickhouse",
+ "image": "clickhouse/clickhouse-server:26.3.17.110",
+ "exposedPort": 8123,
+ "exposed": true,
+ "routes": [
+ {
+ "port": 8123
+ }
+ ],
+ "environment": {
+ "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1"
+ },
+ "secretEnv": [
+ "CLICKHOUSE_PASSWORD"
+ ],
+ "volumes": [
+ "clickhouse_data:/var/lib/clickhouse"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget -q -O /dev/null http://127.0.0.1:8123/ping || exit 1"
+ ],
+ "interval": "10s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "30s"
+ },
+ "restart": "unless-stopped",
+ "stopGracePeriod": "60s",
+ "ports": [
+ "8218:8123"
+ ]
+ },
+ {
+ "name": "ch-ui",
+ "image": "ghcr.io/caioricciuti/ch-ui:v2.6.1",
+ "exposedPort": 3488,
+ "exposed": true,
+ "routes": [
+ {
+ "port": 3488
+ }
+ ],
+ "dependsOn": [
+ "clickhouse"
+ ],
+ "environment": {
+ "CLICKHOUSE_URL": "http://clickhouse:8123",
+ "CONNECTION_NAME": "ClickHouse",
+ "APP_URL": "{{publicUrl:ch-ui}}",
+ "DATABASE_PATH": "/app/data/ch-ui.db"
+ },
+ "secretEnv": [
+ "APP_SECRET_KEY"
+ ],
+ "volumes": [
+ "ch_ui_data:/app/data"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget -q -O /dev/null http://127.0.0.1:3488/health || exit 1"
+ ],
+ "interval": "10s",
+ "timeout": "5s",
+ "retries": 6,
+ "startPeriod": "15s"
+ },
+ "restart": "unless-stopped",
+ "ports": [
+ "8217:3488"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "CLICKHOUSE_USER",
+ "service": "clickhouse",
+ "label": "Database user",
+ "help": "The account the server creates on its first boot. It owns the data and is the sign-in for the console.",
+ "type": "text",
+ "default": "default",
+ "required": true
+ },
+ {
+ "key": "CLICKHOUSE_PASSWORD",
+ "service": "clickhouse",
+ "label": "Password",
+ "help": "Auto-generated. Set on the account at first boot — it is the console sign-in and the credential every SQL client needs. Without it the server would refuse all non-localhost connections.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "CLICKHOUSE_DB",
+ "service": "clickhouse",
+ "label": "First database",
+ "help": "Created on the first boot so you can write a table immediately. More can be added from the console.",
+ "type": "text",
+ "default": "analytics",
+ "required": true
+ },
+ {
+ "key": "APP_SECRET_KEY",
+ "service": "ch-ui",
+ "label": "Console session key",
+ "help": "Auto-generated. Encrypts the ClickHouse credentials the console holds for a signed-in session. Rotating it signs everyone out.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "connection": {
+ "title": "Connect to ClickHouse",
+ "description": "Open the console and sign in with the user and password below — the console talks to ClickHouse over the private project network, so the database itself never has to be public. For your own code, use the DSN.",
+ "guide": {
+ "intro": "Your project gets a ClickHouse server plus a web console for querying it.",
+ "useHint": "Read `process.env.CLICKHOUSE_URL` with `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` (or the single `CLICKHOUSE_DSN`) — set on your next deploy.",
+ "defaultMode": "internal"
+ },
+ "firstLogin": {
+ "username": "default",
+ "note": "The console has no account of its own: sign in with the ClickHouse user above and the generated password from this page. Three wrong tries locks that user out for 15 minutes, so paste the password rather than typing it."
+ },
+ "outputs": [
+ {
+ "id": "console",
+ "label": "Console",
+ "source": "publicUrl:ch-ui",
+ "kind": "url",
+ "recommended": true,
+ "help": "The CH-UI web console — SQL editor, schema browser, saved queries and dashboards. Sign in with the user and password below."
+ },
+ {
+ "id": "httpUrl",
+ "label": "HTTP endpoint",
+ "source": "publicUrl:clickhouse",
+ "sourceLabel": "Public",
+ "variants": [
+ {
+ "id": "internal",
+ "label": "Internal",
+ "source": "template:http://clickhouse:8123"
+ }
+ ],
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_URL",
+ "recommended": true,
+ "help": "ClickHouse's HTTP interface (port 8123) — what clickhouse-connect, clickhouse-js, the JDBC/ODBC drivers and plain curl talk to. Send the user and password as basic auth. Switch to Internal for apps on the same project network; Public only resolves if you gave this port a domain."
+ },
+ {
+ "id": "httpDsn",
+ "label": "HTTP DSN",
+ "source": "template:http://{{env:clickhouse:CLICKHOUSE_USER}}:{{env:clickhouse:CLICKHOUSE_PASSWORD}}@clickhouse:8123/{{env:clickhouse:CLICKHOUSE_DB}}",
+ "sourceLabel": "Internal",
+ "service": "clickhouse",
+ "secret": true,
+ "envKey": "CLICKHOUSE_HTTP_DSN",
+ "recommended": true,
+ "help": "One string with the credentials baked in, over the private project network. For a client outside this server, use the public HTTP endpoint above plus the user and password."
+ },
+ {
+ "id": "nativeDsn",
+ "label": "Native TCP DSN",
+ "source": "template:clickhouse://{{env:clickhouse:CLICKHOUSE_USER}}:{{env:clickhouse:CLICKHOUSE_PASSWORD}}@clickhouse:9000/{{env:clickhouse:CLICKHOUSE_DB}}",
+ "sourceLabel": "Internal",
+ "service": "clickhouse",
+ "secret": true,
+ "envKey": "CLICKHOUSE_DSN",
+ "help": "ClickHouse's native protocol (port 9000) — faster and what clickhouse-client, clickhouse-go and clickhouse-driver prefer. Reachable from inside the project only; the port is never published to the host."
+ },
+ {
+ "id": "user",
+ "label": "User",
+ "source": "env:clickhouse:CLICKHOUSE_USER",
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_USER",
+ "recommended": true,
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Password",
+ "source": "env:clickhouse:CLICKHOUSE_PASSWORD",
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_PASSWORD",
+ "secret": true,
+ "recommended": true,
+ "width": "half"
+ },
+ {
+ "id": "database",
+ "label": "Database",
+ "source": "env:clickhouse:CLICKHOUSE_DB",
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_DATABASE",
+ "width": "half"
+ }
+ ]
+ },
+ "provides": [
+ {
+ "id": "clickhouse",
+ "outputRefs": [
+ "httpUrl",
+ "httpDsn",
+ "user",
+ "password",
+ "database"
+ ],
+ "category": "database"
+ }
+ ],
+ "endpoints": [
+ {
+ "service": "ch-ui",
+ "port": 3488,
+ "label": "Console",
+ "kind": "http",
+ "required": true,
+ "scope": "public",
+ "defaultMode": "domain"
+ },
+ {
+ "service": "clickhouse",
+ "port": 8123,
+ "label": "HTTP API",
+ "kind": "http",
+ "scope": "public",
+ "defaultMode": "port"
+ },
+ {
+ "service": "clickhouse",
+ "port": 9000,
+ "label": "Native protocol",
+ "kind": "tcp",
+ "scope": "internal",
+ "defaultMode": "internal",
+ "allowedModes": [
+ "internal"
+ ]
+ }
+ ]
}
]
}
diff --git a/packages/core/src/apps/catalog/clickhouse.json b/packages/core/src/apps/catalog/clickhouse.json
new file mode 100644
index 000000000..e0b15e449
--- /dev/null
+++ b/packages/core/src/apps/catalog/clickhouse.json
@@ -0,0 +1,270 @@
+{
+ "available": true,
+ "verified": true,
+ "id": "clickhouse",
+ "name": "ClickHouse",
+ "description": "The columnar SQL database for analytics — billions of rows scanned per second. Ships with the CH-UI console, so you get a SQL editor, schema browser and dashboards the moment it installs: sign in with the ClickHouse user and password below, no extra setup.",
+ "repository": "https://github.com/ClickHouse/ClickHouse",
+ "kind": "template",
+ "logo": "clickhouse",
+ "category": "database",
+ "tags": [
+ "analytics",
+ "olap",
+ "sql",
+ "columnar",
+ "warehouse",
+ "timeseries"
+ ],
+ "framework": "docker-compose",
+ "minResources": {
+ "memoryMb": 2048
+ },
+ "services": [
+ {
+ "name": "clickhouse",
+ "image": "clickhouse/clickhouse-server:26.3.17.110",
+ "exposedPort": 8123,
+ "exposed": true,
+ "routes": [
+ {
+ "port": 8123
+ }
+ ],
+ "environment": {
+ "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1"
+ },
+ "secretEnv": [
+ "CLICKHOUSE_PASSWORD"
+ ],
+ "volumes": [
+ "clickhouse_data:/var/lib/clickhouse"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget -q -O /dev/null http://127.0.0.1:8123/ping || exit 1"
+ ],
+ "interval": "10s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "30s"
+ },
+ "restart": "unless-stopped",
+ "stopGracePeriod": "60s",
+ "ports": [
+ "8218:8123"
+ ]
+ },
+ {
+ "name": "ch-ui",
+ "image": "ghcr.io/caioricciuti/ch-ui:v2.6.1",
+ "exposedPort": 3488,
+ "exposed": true,
+ "routes": [
+ {
+ "port": 3488
+ }
+ ],
+ "dependsOn": [
+ "clickhouse"
+ ],
+ "environment": {
+ "CLICKHOUSE_URL": "http://clickhouse:8123",
+ "CONNECTION_NAME": "ClickHouse",
+ "APP_URL": "{{publicUrl:ch-ui}}",
+ "DATABASE_PATH": "/app/data/ch-ui.db"
+ },
+ "secretEnv": [
+ "APP_SECRET_KEY"
+ ],
+ "volumes": [
+ "ch_ui_data:/app/data"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget -q -O /dev/null http://127.0.0.1:3488/health || exit 1"
+ ],
+ "interval": "10s",
+ "timeout": "5s",
+ "retries": 6,
+ "startPeriod": "15s"
+ },
+ "restart": "unless-stopped",
+ "ports": [
+ "8217:3488"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "CLICKHOUSE_USER",
+ "service": "clickhouse",
+ "label": "Database user",
+ "help": "The account the server creates on its first boot. It owns the data and is the sign-in for the console.",
+ "type": "text",
+ "default": "default",
+ "required": true
+ },
+ {
+ "key": "CLICKHOUSE_PASSWORD",
+ "service": "clickhouse",
+ "label": "Password",
+ "help": "Auto-generated. Set on the account at first boot — it is the console sign-in and the credential every SQL client needs. Without it the server would refuse all non-localhost connections.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "CLICKHOUSE_DB",
+ "service": "clickhouse",
+ "label": "First database",
+ "help": "Created on the first boot so you can write a table immediately. More can be added from the console.",
+ "type": "text",
+ "default": "analytics",
+ "required": true
+ },
+ {
+ "key": "APP_SECRET_KEY",
+ "service": "ch-ui",
+ "label": "Console session key",
+ "help": "Auto-generated. Encrypts the ClickHouse credentials the console holds for a signed-in session. Rotating it signs everyone out.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "connection": {
+ "title": "Connect to ClickHouse",
+ "description": "Open the console and sign in with the user and password below — the console talks to ClickHouse over the private project network, so the database itself never has to be public. For your own code, use the DSN.",
+ "guide": {
+ "intro": "Your project gets a ClickHouse server plus a web console for querying it.",
+ "useHint": "Read `process.env.CLICKHOUSE_URL` with `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` (or the single `CLICKHOUSE_DSN`) — set on your next deploy.",
+ "defaultMode": "internal"
+ },
+ "firstLogin": {
+ "username": "default",
+ "note": "The console has no account of its own: sign in with the ClickHouse user above and the generated password from this page. Three wrong tries locks that user out for 15 minutes, so paste the password rather than typing it."
+ },
+ "outputs": [
+ {
+ "id": "console",
+ "label": "Console",
+ "source": "publicUrl:ch-ui",
+ "kind": "url",
+ "recommended": true,
+ "help": "The CH-UI web console — SQL editor, schema browser, saved queries and dashboards. Sign in with the user and password below."
+ },
+ {
+ "id": "httpUrl",
+ "label": "HTTP endpoint",
+ "source": "publicUrl:clickhouse",
+ "sourceLabel": "Public",
+ "variants": [
+ {
+ "id": "internal",
+ "label": "Internal",
+ "source": "template:http://clickhouse:8123"
+ }
+ ],
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_URL",
+ "recommended": true,
+ "help": "ClickHouse's HTTP interface (port 8123) — what clickhouse-connect, clickhouse-js, the JDBC/ODBC drivers and plain curl talk to. Send the user and password as basic auth. Switch to Internal for apps on the same project network; Public only resolves if you gave this port a domain."
+ },
+ {
+ "id": "httpDsn",
+ "label": "HTTP DSN",
+ "source": "template:http://{{env:clickhouse:CLICKHOUSE_USER}}:{{env:clickhouse:CLICKHOUSE_PASSWORD}}@clickhouse:8123/{{env:clickhouse:CLICKHOUSE_DB}}",
+ "sourceLabel": "Internal",
+ "service": "clickhouse",
+ "secret": true,
+ "envKey": "CLICKHOUSE_HTTP_DSN",
+ "recommended": true,
+ "help": "One string with the credentials baked in, over the private project network. For a client outside this server, use the public HTTP endpoint above plus the user and password."
+ },
+ {
+ "id": "nativeDsn",
+ "label": "Native TCP DSN",
+ "source": "template:clickhouse://{{env:clickhouse:CLICKHOUSE_USER}}:{{env:clickhouse:CLICKHOUSE_PASSWORD}}@clickhouse:9000/{{env:clickhouse:CLICKHOUSE_DB}}",
+ "sourceLabel": "Internal",
+ "service": "clickhouse",
+ "secret": true,
+ "envKey": "CLICKHOUSE_DSN",
+ "help": "ClickHouse's native protocol (port 9000) — faster and what clickhouse-client, clickhouse-go and clickhouse-driver prefer. Reachable from inside the project only; the port is never published to the host."
+ },
+ {
+ "id": "user",
+ "label": "User",
+ "source": "env:clickhouse:CLICKHOUSE_USER",
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_USER",
+ "recommended": true,
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Password",
+ "source": "env:clickhouse:CLICKHOUSE_PASSWORD",
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_PASSWORD",
+ "secret": true,
+ "recommended": true,
+ "width": "half"
+ },
+ {
+ "id": "database",
+ "label": "Database",
+ "source": "env:clickhouse:CLICKHOUSE_DB",
+ "service": "clickhouse",
+ "envKey": "CLICKHOUSE_DATABASE",
+ "width": "half"
+ }
+ ]
+ },
+ "provides": [
+ {
+ "id": "clickhouse",
+ "outputRefs": [
+ "httpUrl",
+ "httpDsn",
+ "user",
+ "password",
+ "database"
+ ],
+ "category": "database"
+ }
+ ],
+ "endpoints": [
+ {
+ "service": "ch-ui",
+ "port": 3488,
+ "label": "Console",
+ "kind": "http",
+ "required": true,
+ "scope": "public",
+ "defaultMode": "domain"
+ },
+ {
+ "service": "clickhouse",
+ "port": 8123,
+ "label": "HTTP API",
+ "kind": "http",
+ "scope": "public",
+ "defaultMode": "port"
+ },
+ {
+ "service": "clickhouse",
+ "port": 9000,
+ "label": "Native protocol",
+ "kind": "tcp",
+ "scope": "internal",
+ "defaultMode": "internal",
+ "allowedModes": [
+ "internal"
+ ]
+ }
+ ]
+}
diff --git a/packages/core/src/apps/catalog/code-server.json b/packages/core/src/apps/catalog/code-server.json
index 0218e714c..f63e1c927 100644
--- a/packages/core/src/apps/catalog/code-server.json
+++ b/packages/core/src/apps/catalog/code-server.json
@@ -1,8 +1,10 @@
{
- "available": false,
+ "available": true,
+ "verified": true,
"id": "code-server",
"name": "code-server",
- "description": "Run VS Code in your browser, on your server. Login uses an auto-generated password.",
+ "description": "Run VS Code in your browser, on your server. Sign in with the auto-generated password below.",
+ "repository": "https://github.com/coder/code-server",
"kind": "template",
"logo": "code-server",
"category": "other",
@@ -15,21 +17,34 @@
"services": [
{
"name": "code-server",
- "image": "codercom/code-server:latest",
- "ports": [
- "8080:8080"
- ],
+ "image": "codercom/code-server:4.132.0",
"exposedPort": 8080,
"exposed": true,
+ "routes": [
+ {
+ "port": 8080
+ }
+ ],
"secretEnv": [
"PASSWORD"
],
"volumes": [
- "code_server_config:/home/coder/.config",
- "code_server_local:/home/coder/.local",
- "code_server_project:/home/coder/project"
+ "code_server_home:/home/coder"
],
- "restart": "unless-stopped"
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "curl -fsS -o /dev/null http://127.0.0.1:8080/healthz || exit 1"
+ ],
+ "interval": "15s",
+ "timeout": "5s",
+ "retries": 6,
+ "startPeriod": "20s"
+ },
+ "restart": "unless-stopped",
+ "ports": [
+ "8207:8080"
+ ]
}
],
"configFields": [
@@ -37,9 +52,48 @@
"key": "PASSWORD",
"service": "code-server",
"label": "Login password",
- "help": "Auto-generated. Required to sign in.",
+ "help": "Auto-generated. Required to sign in. Uses PASSWORD (plaintext) rather than HASHED_PASSWORD, which expects an argon2 digest.",
"generate": "secret",
"secret": true
}
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "connection": {
+ "title": "Sign in to code-server",
+ "description": "VS Code in the browser. Sign in with the generated password below.",
+ "guide": {
+ "intro": "A full VS Code editor running on your server, reachable from any browser.",
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Editor",
+ "source": "publicUrl:code-server",
+ "kind": "url",
+ "recommended": true,
+ "help": "Opens the browser IDE. Sign in with the password below."
+ },
+ {
+ "id": "password",
+ "label": "Login password",
+ "source": "env:code-server:PASSWORD",
+ "secret": true
+ }
+ ],
+ "firstLogin": {
+ "note": "Your whole home directory is the persisted volume, so files, extensions and settings all survive a redeploy. The editor opens /home/coder by default."
+ }
+ },
+ "endpoints": [
+ {
+ "service": "code-server",
+ "port": 8080,
+ "label": "Editor",
+ "kind": "http",
+ "defaultMode": "domain"
+ }
]
}
diff --git a/packages/core/src/apps/catalog/directus.json b/packages/core/src/apps/catalog/directus.json
index 7bb895f4c..708840866 100644
--- a/packages/core/src/apps/catalog/directus.json
+++ b/packages/core/src/apps/catalog/directus.json
@@ -1,8 +1,10 @@
{
- "available": false,
+ "available": true,
+ "verified": true,
"id": "directus",
"name": "Directus",
- "description": "Headless CMS with an instant REST + GraphQL API over your data. Create the admin on first visit.",
+ "description": "Headless CMS with an instant REST + GraphQL API over your data. Sign in with the admin email and generated password below.",
+ "repository": "https://github.com/directus/directus",
"kind": "template",
"logo": "directus",
"category": "cms",
@@ -16,24 +18,41 @@
{
"name": "directus",
"image": "directus/directus:latest",
- "ports": [
- "8055:8055"
- ],
"exposedPort": 8055,
"exposed": true,
+ "routes": [
+ {
+ "port": 8055
+ }
+ ],
"environment": {
"DB_CLIENT": "sqlite3",
"DB_FILENAME": "/directus/database/data.db",
- "PUBLIC_URL": "{{publicUrl:directus}}"
+ "PUBLIC_URL": "{{publicUrl:directus}}",
+ "ADMIN_EMAIL": "admin@example.com"
},
"secretEnv": [
- "SECRET"
+ "SECRET",
+ "ADMIN_PASSWORD"
],
"volumes": [
"directus_database:/directus/database",
"directus_uploads:/directus/uploads"
],
- "restart": "unless-stopped"
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget -q -O /dev/null http://127.0.0.1:8055/server/health || exit 1"
+ ],
+ "interval": "15s",
+ "timeout": "5s",
+ "retries": 8,
+ "startPeriod": "30s"
+ },
+ "restart": "unless-stopped",
+ "ports": [
+ "8211:8055"
+ ]
}
],
"configFields": [
@@ -44,6 +63,68 @@
"help": "Auto-generated. Signs access tokens.",
"generate": "secret",
"secret": true
+ },
+ {
+ "key": "ADMIN_PASSWORD",
+ "service": "directus",
+ "label": "Admin password",
+ "help": "Auto-generated. The password for the admin account created on first boot. Directus has no sign-up screen, so without this no account would exist at all.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "connection": {
+ "title": "Sign in to Directus",
+ "description": "The admin account below is created automatically on the first boot. Change the password after your first sign-in.",
+ "guide": {
+ "intro": "A headless CMS plus a REST and GraphQL API over whatever collections you create.",
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "admin",
+ "label": "Admin app",
+ "source": "template:{{env:directus:PUBLIC_URL}}/admin",
+ "kind": "url",
+ "recommended": true,
+ "help": "The Directus admin app. Sign in with the email and password below."
+ },
+ {
+ "id": "url",
+ "label": "API URL",
+ "source": "publicUrl:directus",
+ "kind": "url",
+ "envKey": "DIRECTUS_URL",
+ "help": "Base URL for the REST + GraphQL API."
+ },
+ {
+ "id": "email",
+ "label": "Admin email",
+ "source": "env:directus:ADMIN_EMAIL",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:directus:ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "The admin is created only on the FIRST boot against an empty database. If you reinstall over an existing directus_database volume no new admin is made — reset one with `npx directus users passwd` inside the container."
+ }
+ },
+ "endpoints": [
+ {
+ "service": "directus",
+ "port": 8055,
+ "label": "Directus",
+ "kind": "http",
+ "defaultMode": "domain"
}
]
}
diff --git a/packages/core/src/apps/catalog/excalidraw.json b/packages/core/src/apps/catalog/excalidraw.json
index 067a5a9a5..c4325f11c 100644
--- a/packages/core/src/apps/catalog/excalidraw.json
+++ b/packages/core/src/apps/catalog/excalidraw.json
@@ -17,12 +17,32 @@
{
"name": "excalidraw",
"image": "excalidraw/excalidraw:latest",
- "ports": [
- "8203:80"
- ],
"exposedPort": 80,
"exposed": true,
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8203:80"
+ ]
+ }
+ ],
+ "connection": {
+ "title": "Open Excalidraw",
+ "description": "No login and no server-side storage — open the link and start drawing.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Whiteboard",
+ "source": "publicUrl:excalidraw",
+ "kind": "url",
+ "recommended": true,
+ "help": "Opens the whiteboard. No sign-in required."
+ }
+ ],
+ "firstLogin": {
+ "note": "There is nothing to log into and nothing stored on the server: drawings live in your browser, so use Export to save anything you want to keep. Anyone who can reach this URL can use it — put it behind your own access control if that matters."
}
- ]
+ }
}
diff --git a/packages/core/src/apps/catalog/freshrss.json b/packages/core/src/apps/catalog/freshrss.json
index 5993f5057..964c863c5 100644
--- a/packages/core/src/apps/catalog/freshrss.json
+++ b/packages/core/src/apps/catalog/freshrss.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "freshrss",
"name": "FreshRSS",
- "description": "Self-hosted RSS and Atom feed reader with a first-run setup wizard.",
+ "description": "Self-hosted RSS and Atom feed reader. Your account is created during install.",
"kind": "template",
"logo": "freshrss",
"category": "other",
@@ -16,15 +16,80 @@
{
"name": "freshrss",
"image": "freshrss/freshrss:latest",
- "ports": [
- "80:80"
- ],
"exposedPort": 80,
"exposed": true,
"volumes": [
"freshrss_data:/var/www/FreshRSS/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "environment": {
+ "FRESHRSS_ADMIN_USERNAME": "admin"
+ },
+ "secretEnv": [
+ "FRESHRSS_ADMIN_PASSWORD"
+ ],
+ "ports": [
+ "8204:80"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "FRESHRSS_ADMIN_PASSWORD",
+ "service": "freshrss",
+ "label": "Admin password",
+ "help": "Auto-generated. Password for the account created during install.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "prepare": [
+ {
+ "service": "freshrss",
+ "title": "Install FreshRSS",
+ "description": "Runs the headless installer and creates your account.",
+ "command": "cd /var/www/FreshRSS && { [ -f ./data/config.php ] || php ./cli/do-install.php --default-user=\"$FRESHRSS_ADMIN_USERNAME\" --auth-type=form --db-type=sqlite --api-enabled; } && { php ./cli/list-users.php 2>/dev/null | grep -qx \"$FRESHRSS_ADMIN_USERNAME\" || php ./cli/create-user.php --user \"$FRESHRSS_ADMIN_USERNAME\" --password \"$FRESHRSS_ADMIN_PASSWORD\" --language en; } && ./cli/access-permissions.sh >/dev/null 2>&1; echo done",
+ "capture": "freshrss_install",
+ "phase": "post-ready",
+ "readiness": {
+ "test": "test -d /var/www/FreshRSS/cli",
+ "interval": 3000,
+ "retries": 30
+ }
+ }
+ ],
+ "connection": {
+ "title": "Sign in to FreshRSS",
+ "description": "FreshRSS is installed headlessly during setup — sign in with the credentials below.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Reader",
+ "source": "publicUrl:freshrss",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the username and password below."
+ },
+ {
+ "id": "username",
+ "label": "Username",
+ "source": "env:freshrss:FRESHRSS_ADMIN_USERNAME",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Password",
+ "source": "env:freshrss:FRESHRSS_ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "Deliberately NOT deployed with the public web installer, so nobody can claim your instance first. Feeds refresh on a schedule only if you set CRON_MIN (e.g. \"*/20\")."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/ghost.json b/packages/core/src/apps/catalog/ghost.json
index 8f92215f8..b67c16ce2 100644
--- a/packages/core/src/apps/catalog/ghost.json
+++ b/packages/core/src/apps/catalog/ghost.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "ghost",
"name": "Ghost",
- "description": "Modern publishing platform for blogs, newsletters, and membership sites.",
+ "description": "Modern publishing platform for blogs, newsletters, and membership sites. Claim the owner account on your first visit.",
"kind": "template",
"logo": "ghost",
"category": "cms",
@@ -30,9 +30,6 @@
{
"name": "ghost",
"image": "ghost:5-alpine",
- "ports": [
- "2368:2368"
- ],
"exposedPort": 2368,
"exposed": true,
"dependsOn": [
@@ -52,7 +49,10 @@
"volumes": [
"ghost_content:/var/lib/ghost/content"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8212:2368"
+ ]
}
],
"configFields": [
@@ -72,5 +72,33 @@
"generateGroup": "ghostdb",
"secret": true
}
- ]
+ ],
+ "connection": {
+ "title": "Set up Ghost",
+ "description": "Ghost has no preset login. Open Ghost Admin and the first screen creates the owner account.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "admin",
+ "label": "Ghost Admin",
+ "source": "template:{{env:ghost:url}}/ghost/",
+ "kind": "url",
+ "recommended": true,
+ "help": "Create the owner account here on first visit."
+ },
+ {
+ "id": "url",
+ "label": "Site",
+ "source": "publicUrl:ghost",
+ "kind": "url",
+ "help": "The public blog."
+ }
+ ],
+ "firstLogin": {
+ "note": "Claim the owner account at /ghost/ immediately — it is unauthenticated until you do, so the first visitor owns the blog. Ghost may restart a few times on first boot while MySQL initialises; that is expected."
+ }
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/gitea.json b/packages/core/src/apps/catalog/gitea.json
index 7544c11f7..6992f24ff 100644
--- a/packages/core/src/apps/catalog/gitea.json
+++ b/packages/core/src/apps/catalog/gitea.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "gitea",
"name": "Gitea",
- "description": "Self-hosted Git with issues, pull requests, and a first-run setup wizard.",
+ "description": "Self-hosted Git with issues and pull requests. The admin account below is created for you.",
"kind": "template",
"logo": "gitea",
"category": "other",
@@ -16,18 +16,94 @@
{
"name": "gitea",
"image": "gitea/gitea:1",
- "ports": [
- "3000:3000"
- ],
"exposedPort": 3000,
"exposed": true,
"environment": {
- "GITEA__server__ROOT_URL": "{{publicUrl:gitea}}"
+ "GITEA__server__ROOT_URL": "{{publicUrl:gitea}}",
+ "GITEA__security__INSTALL_LOCK": "true",
+ "GITEA__security__SECRET_KEY": "{{config:GITEA_SECRET_KEY}}",
+ "GITEA__database__DB_TYPE": "sqlite3",
+ "GITEA__service__DISABLE_REGISTRATION": "true",
+ "GITEA_ADMIN_USERNAME": "admin",
+ "GITEA_ADMIN_EMAIL": "admin@example.com"
},
"volumes": [
"gitea_data:/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "secretEnv": [
+ "GITEA_ADMIN_PASSWORD"
+ ],
+ "ports": [
+ "3009:3000"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "GITEA_SECRET_KEY",
+ "service": "gitea",
+ "label": "Secret key",
+ "help": "Auto-generated. Signs Gitea's tokens and locks the installer.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "GITEA_ADMIN_PASSWORD",
+ "service": "gitea",
+ "label": "Admin password",
+ "help": "Auto-generated. Password for the seeded admin account.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "prepare": [
+ {
+ "service": "gitea",
+ "title": "Create the Gitea admin",
+ "description": "Seeds the administrator account so you can sign in immediately.",
+ "command": "su-exec git gitea admin user create --admin --username \"$GITEA_ADMIN_USERNAME\" --password \"$GITEA_ADMIN_PASSWORD\" --email \"$GITEA_ADMIN_EMAIL\" --must-change-password=false 2>&1 || true; echo done",
+ "capture": "gitea_admin",
+ "phase": "post-ready",
+ "readiness": {
+ "test": "su-exec git gitea admin user list >/dev/null 2>&1",
+ "interval": 3000,
+ "retries": 40
+ }
+ }
+ ],
+ "connection": {
+ "title": "Sign in to Gitea",
+ "description": "Your admin account is created during install. New self-registration is disabled — invite users from Site Administration.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Gitea",
+ "source": "publicUrl:gitea",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the username and password below."
+ },
+ {
+ "id": "username",
+ "label": "Admin username",
+ "source": "env:gitea:GITEA_ADMIN_USERNAME",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:gitea:GITEA_ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "INSTALL_LOCK is on, so the public setup wizard is disabled and cannot be used to hijack the instance. The password above works as-is."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/it-tools.json b/packages/core/src/apps/catalog/it-tools.json
index b2aca61e6..31e68b248 100644
--- a/packages/core/src/apps/catalog/it-tools.json
+++ b/packages/core/src/apps/catalog/it-tools.json
@@ -1,5 +1,5 @@
{
- "available": false,
+ "available": true,
"id": "it-tools",
"name": "IT-Tools",
"description": "A handy collection of developer and sysadmin utilities. No login, no setup.",
@@ -16,12 +16,33 @@
{
"name": "it-tools",
"image": "corentinth/it-tools:latest",
- "ports": [
- "80:80"
- ],
"exposedPort": 80,
"exposed": true,
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8205:80"
+ ]
+ }
+ ],
+ "connection": {
+ "title": "Open IT-Tools",
+ "description": "A collection of developer and sysadmin utilities. No login, no setup, nothing stored.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "IT-Tools",
+ "source": "publicUrl:it-tools",
+ "kind": "url",
+ "recommended": true,
+ "help": "Opens the tool collection. No sign-in required."
+ }
+ ],
+ "firstLogin": {
+ "note": "Nothing to log into and nothing persisted — every tool runs in your browser. Anyone who can reach this URL can use it, so keep it internal if that matters."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/kafka.json b/packages/core/src/apps/catalog/kafka.json
index 9733cda7d..2f96bf403 100644
--- a/packages/core/src/apps/catalog/kafka.json
+++ b/packages/core/src/apps/catalog/kafka.json
@@ -45,9 +45,6 @@
"image": "ghcr.io/kafbat/kafka-ui:latest",
"exposedPort": 8080,
"exposed": true,
- "ports": [
- "8080:8080"
- ],
"routes": [
{
"port": 8080
@@ -66,7 +63,10 @@
"secretEnv": [
"SPRING_SECURITY_USER_PASSWORD"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8210:8080"
+ ]
}
],
"configFields": [
diff --git a/packages/core/src/apps/catalog/meilisearch.json b/packages/core/src/apps/catalog/meilisearch.json
index f1f75ff3d..33f05dfb0 100644
--- a/packages/core/src/apps/catalog/meilisearch.json
+++ b/packages/core/src/apps/catalog/meilisearch.json
@@ -18,9 +18,6 @@
{
"name": "meilisearch",
"image": "getmeili/meilisearch:v1.12",
- "ports": [
- "7700:7700"
- ],
"exposedPort": 7700,
"exposed": true,
"routes": [
@@ -48,7 +45,10 @@
"retries": 5,
"startPeriod": "10s"
},
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8214:7700"
+ ]
}
],
"configFields": [
@@ -66,7 +66,7 @@
},
"connection": {
"title": "Connect to Meilisearch",
- "description": "Point a Meilisearch client at the URL with the master key. Every request needs the key as a Bearer token.",
+ "description": "Point a Meilisearch client at the URL with the master key. Every request needs the key as a Bearer token. There is no web dashboard in production mode — drive it from a Meilisearch client or curl.",
"guide": {
"intro": "Your project gets a Meilisearch endpoint plus its master key.",
"useHint": "Read `process.env.MEILISEARCH_URL` and `process.env.MEILISEARCH_KEY` in your code — set on your next deploy.",
@@ -87,7 +87,8 @@
],
"envKey": "MEILISEARCH_URL",
"recommended": true,
- "help": "The Meilisearch HTTP endpoint. Switch to Internal for apps on the same project network."
+ "help": "HTTP API endpoint — opening it in a browser returns a JSON status, not a UI (the bundled dashboard is off in production mode). Switch to Internal for apps on the same project network.",
+ "kind": "url"
},
{
"id": "masterKey",
diff --git a/packages/core/src/apps/catalog/metabase.json b/packages/core/src/apps/catalog/metabase.json
index b08782a85..bc43cdbb9 100644
--- a/packages/core/src/apps/catalog/metabase.json
+++ b/packages/core/src/apps/catalog/metabase.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "metabase",
"name": "Metabase",
- "description": "Open-source business intelligence — dashboards and questions over your data.",
+ "description": "Open-source business intelligence — dashboards and questions over your data. Create your admin account in the browser on first visit.",
"kind": "template",
"logo": "metabase",
"category": "analytics",
@@ -16,18 +16,53 @@
{
"name": "metabase",
"image": "metabase/metabase:latest",
- "ports": [
- "3000:3000"
- ],
"exposedPort": 3000,
"exposed": true,
"environment": {
- "MB_DB_FILE": "/metabase-data/metabase.db"
+ "MB_DB_FILE": "/metabase-data/metabase.db",
+ "MB_SITE_URL": "{{publicUrl:metabase}}"
},
"volumes": [
"metabase_data:/metabase-data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "secretEnv": [
+ "MB_ENCRYPTION_SECRET_KEY"
+ ],
+ "ports": [
+ "3010:3000"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "MB_ENCRYPTION_SECRET_KEY",
+ "service": "metabase",
+ "label": "Encryption key",
+ "help": "Auto-generated. Encrypts saved database credentials at rest. Never change it after setup.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Set up Metabase",
+ "description": "Metabase has no preset login. Open the link and the setup wizard will walk you through creating the admin account.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Metabase",
+ "source": "publicUrl:metabase",
+ "kind": "url",
+ "recommended": true,
+ "help": "First visit runs the setup wizard — the account you create there is the admin."
+ }
+ ],
+ "firstLogin": {
+ "note": "Complete the setup wizard immediately: it is unauthenticated, so whoever opens this URL first becomes the admin. Metabase can take a minute to finish migrations on first boot."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/minio.json b/packages/core/src/apps/catalog/minio.json
index 27e9367b3..715373fc5 100644
--- a/packages/core/src/apps/catalog/minio.json
+++ b/packages/core/src/apps/catalog/minio.json
@@ -17,15 +17,21 @@
{
"name": "minio",
"image": "minio/minio:latest",
- "command": "server /data --console-address :9001",
- "ports": [
- "9000:9000",
- "9001:9001"
+ "commandArgv": [
+ "server",
+ "/data",
+ "--console-address",
+ ":9001"
],
"exposedPort": 9001,
"routes": [
- { "port": 9001 },
- { "port": 9000, "slugSuffix": "s3" }
+ {
+ "port": 9001
+ },
+ {
+ "port": 9000,
+ "slugSuffix": "s3"
+ }
],
"exposed": true,
"environment": {
@@ -37,7 +43,11 @@
"volumes": [
"minio_data:/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "9001:9001",
+ "9000:9000"
+ ]
}
],
"configFields": [
@@ -78,19 +88,33 @@
"test": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\"",
"interval": 1000,
"retries": 30
- },
- "once": true
+ }
}
],
"endpoints": [
- { "service": "minio", "port": 9001, "label": "Console", "kind": "http" },
- { "service": "minio", "port": 9000, "label": "S3 API", "kind": "http" }
+ {
+ "service": "minio",
+ "port": 9001,
+ "label": "Console",
+ "kind": "http"
+ },
+ {
+ "service": "minio",
+ "port": 9000,
+ "label": "S3 API",
+ "kind": "http"
+ }
],
"connection": {
"title": "S3 connection",
"description": "Use these with any S3 client — the endpoint is the S3 API URL, the keys are the root user / password.",
"outputs": [
- { "id": "console", "label": "Console", "source": "publicUrl:minio:9001", "kind": "url" },
+ {
+ "id": "console",
+ "label": "Console",
+ "source": "publicUrl:minio:9001",
+ "kind": "url"
+ },
{
"id": "endpoint",
"label": "S3 endpoint",
@@ -133,7 +157,12 @@
"provides": [
{
"id": "s3",
- "outputRefs": ["endpoint", "accessKey", "secretKey", "bucket"],
+ "outputRefs": [
+ "endpoint",
+ "accessKey",
+ "secretKey",
+ "bucket"
+ ],
"category": "database"
}
]
diff --git a/packages/core/src/apps/catalog/mongodb.json b/packages/core/src/apps/catalog/mongodb.json
index 773df3f00..9d5bcbf2e 100644
--- a/packages/core/src/apps/catalog/mongodb.json
+++ b/packages/core/src/apps/catalog/mongodb.json
@@ -47,9 +47,6 @@
"image": "mongo-express:1.0.2",
"exposedPort": 8081,
"exposed": true,
- "ports": [
- "8081:8081"
- ],
"routes": [
{
"port": 8081
@@ -73,7 +70,10 @@
"retries": 5,
"startPeriod": "20s"
},
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8216:8081"
+ ]
}
],
"configFields": [
@@ -132,19 +132,19 @@
{
"id": "dbUrl",
"label": "Database URL",
- "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@{{host}}:27017/",
- "sourceLabel": "Public",
+ "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@mongo:27017/",
+ "sourceLabel": "Internal",
"variants": [
{
- "id": "internal",
- "label": "Internal",
- "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@mongo:27017/"
+ "id": "host",
+ "label": "From this server",
+ "source": "template:mongodb://root:{{env:mongo:MONGO_INITDB_ROOT_PASSWORD}}@{{host}}:27017/"
}
],
"secret": true,
"envKey": "MONGODB_URI",
"recommended": true,
- "help": "Direct MongoDB connection (root user). Published on port 27017. Switch to Internal for apps on the same project network."
+ "help": "Direct MongoDB connection (root user) on the project’s private network — use it from another service, or bind this app into a project. Port 27017 publishes on 127.0.0.1 only, so the \"From this server\" form works from the box itself or through an SSH tunnel, not from the internet."
}
]
},
diff --git a/packages/core/src/apps/catalog/n8n.json b/packages/core/src/apps/catalog/n8n.json
index 654fd16ad..c5e7f70a2 100644
--- a/packages/core/src/apps/catalog/n8n.json
+++ b/packages/core/src/apps/catalog/n8n.json
@@ -17,9 +17,6 @@
{
"name": "n8n",
"image": "n8nio/n8n:latest",
- "ports": [
- "5678:5678"
- ],
"exposedPort": 5678,
"exposed": true,
"environment": {
@@ -34,7 +31,10 @@
"volumes": [
"n8n_data:/home/node/.n8n"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8213:5678"
+ ]
}
],
"configFields": [
@@ -100,5 +100,35 @@
}
]
}
- ]
+ ],
+ "connection": {
+ "title": "Set up n8n",
+ "description": "n8n has no preset login. Open the editor and the first screen creates your owner account.",
+ "guide": {
+ "defaultMode": "public",
+ "intro": "Your project gets a private n8n instance for building workflows.",
+ "useHint": "Webhook nodes are published under the webhook base URL below — use that origin when registering callbacks with third parties."
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Editor",
+ "source": "publicUrl:n8n",
+ "kind": "url",
+ "recommended": true,
+ "help": "First visit asks you to create the owner account."
+ },
+ {
+ "id": "webhookUrl",
+ "label": "Webhook base URL",
+ "source": "env:n8n:WEBHOOK_URL",
+ "kind": "url",
+ "envKey": "N8N_WEBHOOK_URL",
+ "help": "Base origin n8n advertises for Webhook nodes."
+ }
+ ],
+ "firstLogin": {
+ "note": "Create the owner account as soon as it deploys — the setup screen is unauthenticated until you do. Never change the generated encryption key afterwards or every stored credential becomes unreadable. n8n issues https-only session cookies, so sign in over the domain rather than a plain-http address."
+ }
+ }
}
diff --git a/packages/core/src/apps/catalog/neon.json b/packages/core/src/apps/catalog/neon.json
index abd5b09a1..6efc78e99 100644
--- a/packages/core/src/apps/catalog/neon.json
+++ b/packages/core/src/apps/catalog/neon.json
@@ -1,14 +1,15 @@
{
"available": true,
- "verified": false,
+ "verified": true,
"hosting": "experimental",
"minResources": {
- "memoryMb": 8192
+ "memoryMb": 4096,
+ "cpuCores": 2
},
"id": "neon",
"name": "Neon",
- "description": "Self-hosted Neon — the serverless-Postgres storage engine (pageserver, 3 safekeepers, storage broker) on S3-backed object storage, with a Neon compute node built from source. EXPERIMENTAL and heavy: ~9 containers, needs roughly 8 GB RAM, and is a test-grade topology, not a production HA cluster. Not for production data.",
- "repository": "https://github.com/neondatabase/neon",
+ "description": "Self-hosted Neon — serverless Postgres with database branching, a web console and per-branch connection strings, in a single container. Built on the community `neond` control plane (Apache-2.0), which bundles Neon's pageserver, safekeeper, storage broker and storage controller behind a management API and dashboard. Neon's own cloud console is proprietary and the upstream neon repo ships no web UI at all, so a community control plane is the only way to run self-hosted Neon with a dashboard. EXPERIMENTAL: one container and no HA, a ~1.2 GB image, and it publishes fixed host ports for branch endpoints. Not for critical data.",
+ "repository": "https://github.com/matisiekpl/neond",
"kind": "template",
"logo": "neon",
"category": "database",
@@ -17,146 +18,87 @@
"postgres",
"postgresql",
"serverless",
- "sql"
+ "sql",
+ "branching"
],
"framework": "docker-compose",
"services": [
{
- "name": "storage_broker",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "storage_broker --listen-addr=0.0.0.0:50051",
- "restart": "unless-stopped"
- },
- {
- "name": "minio",
- "image": "minio/minio:RELEASE.2025-04-22T22-12-26Z",
- "command": "server /data --address :9000 --console-address :9001",
- "volumes": [
- "neon_minio_data:/data"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "pageserver",
- "image": "ghcr.io/neondatabase/neon:latest",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "safekeeper1",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "safekeeper --listen-pg=safekeeper1:5454 --listen-http=0.0.0.0:7676 --id=1 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
- "dependsOn": [
- "storage_broker",
- "minio"
+ "name": "neond",
+ "image": "neond/neond:f04d396c133d81e28cf52560ea11ef7e9b814d71",
+ "exposed": true,
+ "exposedPort": 3000,
+ "routes": [
+ {
+ "port": 3000
+ }
],
"environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "safekeeper2",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "safekeeper --listen-pg=safekeeper2:5454 --listen-http=0.0.0.0:7676 --id=2 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "safekeeper3",
- "image": "ghcr.io/neondatabase/neon:latest",
- "command": "safekeeper --listen-pg=safekeeper3:5454 --listen-http=0.0.0.0:7676 --id=3 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
- "dependsOn": [
- "storage_broker",
- "minio"
- ],
- "environment": {
- "AWS_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "AWS_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}"
- },
- "secretEnv": [
- "AWS_SECRET_ACCESS_KEY"
- ],
- "restart": "unless-stopped"
- },
- {
- "name": "compute",
- "build": {
- "dockerfile": "FROM ghcr.io/neondatabase/compute-node-v16:latest\n\nUSER root\nRUN echo 'Acquire::Retries \"5\";' > /etc/apt/apt.conf.d/80-retries && \\\n apt-get update && \\\n apt-get install -y curl jq netcat-openbsd && \\\n rm -rf /var/lib/apt/lists/*\n\nCOPY compute/compute.sh /shell/compute.sh\nRUN chmod +x /shell/compute.sh\n\nUSER postgres\nENTRYPOINT [\"/shell/compute.sh\"]\n",
- "files": [
- {
- "path": "compute.sh",
- "content": "#!/usr/bin/env bash\nset -eux\n\n# Generate a random tenant or timeline ID\n#\n# Takes a variable name as argument. The result is stored in that variable.\ngenerate_id() {\n local -n resvar=${1}\n printf -v resvar '%08x%08x%08x%08x' ${SRANDOM} ${SRANDOM} ${SRANDOM} ${SRANDOM}\n}\n\nPG_VERSION=${PG_VERSION:-16}\n\nreadonly CONFIG_FILE_ORG=/var/db/postgres/configs/config.json\nreadonly CONFIG_FILE=/tmp/config.json\n\necho \"Waiting pageserver become ready.\"\nwhile ! nc -z pageserver 6400; do\n sleep 1\ndone\necho \"Page server is ready.\"\n\ncp \"${CONFIG_FILE_ORG}\" \"${CONFIG_FILE}\"\n\nif [[ -n \"${TENANT_ID:-}\" && -n \"${TIMELINE_ID:-}\" ]]; then\n tenant_id=${TENANT_ID}\n timeline_id=${TIMELINE_ID}\nelse\n echo \"Check if a tenant present\"\n PARAMS=(\n -X GET\n -H \"Content-Type: application/json\"\n \"http://pageserver:9898/v1/tenant\"\n )\n tenant_id=$(curl \"${PARAMS[@]}\" | jq -r .[0].id)\n if [[ -z \"${tenant_id}\" || \"${tenant_id}\" = null ]]; then\n echo \"Create a tenant\"\n generate_id tenant_id\n PARAMS=(\n -X PUT\n -H \"Content-Type: application/json\"\n -d \"{\\\"mode\\\": \\\"AttachedSingle\\\", \\\"generation\\\": 1, \\\"tenant_conf\\\": {}}\"\n \"http://pageserver:9898/v1/tenant/${tenant_id}/location_config\"\n )\n result=$(curl \"${PARAMS[@]}\")\n printf '%s\\n' \"${result}\" | jq .\n fi\n\n echo \"Check if a timeline present\"\n PARAMS=(\n -X GET\n -H \"Content-Type: application/json\"\n \"http://pageserver:9898/v1/tenant/${tenant_id}/timeline\"\n )\n timeline_id=$(curl \"${PARAMS[@]}\" | jq -r .[0].timeline_id)\n if [[ -z \"${timeline_id}\" || \"${timeline_id}\" = null ]]; then\n generate_id timeline_id\n PARAMS=(\n -sbf\n -X POST\n -H \"Content-Type: application/json\"\n -d \"{\\\"new_timeline_id\\\": \\\"${timeline_id}\\\", \\\"pg_version\\\": ${PG_VERSION}}\"\n \"http://pageserver:9898/v1/tenant/${tenant_id}/timeline/\"\n )\n result=$(curl \"${PARAMS[@]}\")\n printf '%s\\n' \"${result}\" | jq .\n fi\nfi\n\necho \"Overwrite tenant id and timeline id in spec file\"\nsed -i \"s|TENANT_ID|${tenant_id}|\" ${CONFIG_FILE}\nsed -i \"s|TIMELINE_ID|${timeline_id}|\" ${CONFIG_FILE}\n\ncat ${CONFIG_FILE}\n\necho \"Start compute node\"\n/usr/local/bin/compute_ctl --pgdata /var/db/postgres/compute \\\n -C \"postgresql://cloud_admin@localhost:55433/postgres\" \\\n -b /usr/local/bin/postgres \\\n --compute-id \"compute-${RANDOM}\" \\\n --config \"${CONFIG_FILE}\"\n"
- }
- ]
- },
- "dependsOn": [
- "pageserver",
- "safekeeper1",
- "safekeeper2",
- "safekeeper3"
- ],
- "environment": {
- "PG_VERSION": "16"
+ "PORT": "3000",
+ "PORT_RANGE": "55432-55437",
+ "DO_NOT_TRACK": "1",
+ "TELEMETRY_DISABLED": "1",
+ "RUST_LOG": "info"
},
"ports": [
- "55433:55433"
+ "8220:3000",
+ "0.0.0.0:55432:55432",
+ "0.0.0.0:55433:55433",
+ "0.0.0.0:55434:55434",
+ "0.0.0.0:55435:55435",
+ "0.0.0.0:55436:55436",
+ "0.0.0.0:55437:55437"
],
"volumes": [
- "neon_compute_data:/var/db/postgres/compute"
+ "neond_data:/neond"
],
"healthcheck": {
"test": [
- "CMD-SHELL",
- "pg_isready -h 127.0.0.1 -p 55433 -U cloud_admin || exit 1"
+ "CMD",
+ "curl",
+ "-fsS",
+ "http://127.0.0.1:3000/api/auth/setup"
],
- "interval": "10s",
+ "interval": "30s",
"timeout": "5s",
- "retries": 10,
- "startPeriod": "90s"
+ "retries": 3,
+ "startPeriod": "5m"
},
+ "stopGracePeriod": "10m",
"restart": "unless-stopped"
}
],
"configFields": [
{
- "key": "MINIO_ROOT_USER",
- "service": "minio",
- "label": "Object-storage access key",
- "help": "The S3 access key the Neon storage layer uses against its bundled MinIO.",
+ "key": "SERVER_SECRET",
+ "service": "neond",
+ "label": "Server secret",
+ "help": "Auto-generated, and PERMANENT — it is also the password of the internal management Postgres role, so changing it after the first launch makes the control plane unable to open its own database.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "ADMIN_EMAIL",
+ "service": "neond",
+ "label": "Console admin email",
+ "help": "The first account is created for you and is the instance admin. Sign-up closes as soon as it exists.",
"type": "text",
- "default": "neon",
+ "default": "admin@openship.local",
"required": true
},
{
- "key": "MINIO_ROOT_PASSWORD",
- "service": "minio",
- "label": "Object-storage secret key",
- "help": "Auto-generated. The S3 secret key shared by the pageserver and safekeepers.",
+ "key": "ADMIN_PASSWORD",
+ "service": "neond",
+ "label": "Console admin password",
+ "help": "Auto-generated. Use it with the admin email to sign in to the console.",
+ "generate": "secret",
+ "secret": true
+ },
+ {
+ "key": "PG_PASSWORD",
+ "service": "neond",
+ "label": "Database password",
+ "help": "Auto-generated. Set as the password of the `postgres` role on the first branch so the connection string below is usable immediately.",
"generate": "secret",
"secret": true
}
@@ -166,78 +108,73 @@
},
"prepare": [
{
- "service": "minio",
- "command": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" > /dev/null && mc mb --ignore-existing --region eu-north-1 local/neon > /dev/null && printf %s neon",
- "capture": "bucket",
+ "service": "neond",
+ "title": "Create the console account and first branch",
+ "description": "Signs in (registering the admin on a first install), then ensures an organization, project and branch exist and an endpoint is running, and reports its port. Every run reports a REAL port, so a first attempt that raced the endpoint cannot leave the connection string permanently blank.",
+ "command": "set -e; API=http://127.0.0.1:3000/api; J='Content-Type: application/json'; id1() { sed -n 's/.*\"id\":\"\\([^\"]*\\)\".*/\\1/p' | head -1; }; port1() { sed -n -e 's|.*\"connection_string\":\"[^\"]*@[^:\"]*:\\([0-9][0-9]*\\)/.*|\\1|p' -e 's/.*\"port\":\\([0-9][0-9]*\\).*/\\1/p' | head -1; }; get() { curl -fsS -H \"$A\" \"$1\" 2>/dev/null || printf ''; }; post() { n=0; while [ $n -lt 20 ]; do r=$(curl -fsS -X POST \"$1\" -H \"$J\" -H \"$A\" -d \"$2\" 2>/dev/null || printf ''); [ -n \"$r\" ] && { printf %s \"$r\"; return 0; }; n=$((n+1)); sleep 2; done; printf ''; }; SETUP=$(curl -fsS \"$API/auth/setup\" || printf ''); case \"$SETUP\" in *'\"registration_open\":true'*) TOKEN=$(curl -fsS -X POST \"$API/auth/register\" -H \"$J\" -d \"{\\\"name\\\":\\\"Admin\\\",\\\"email\\\":\\\"$ADMIN_EMAIL\\\",\\\"password\\\":\\\"$ADMIN_PASSWORD\\\"}\" 2>/dev/null | sed -n 's/.*\"token\":\"\\([^\"]*\\)\".*/\\1/p') ;; *) TOKEN=$(curl -fsS -X POST \"$API/auth/login\" -H \"$J\" -d \"{\\\"email\\\":\\\"$ADMIN_EMAIL\\\",\\\"password\\\":\\\"$ADMIN_PASSWORD\\\"}\" 2>/dev/null | sed -n 's/.*\"token\":\"\\([^\"]*\\)\".*/\\1/p') ;; esac; [ -n \"$TOKEN\" ] || { echo \"pg_port=no-token\"; exit 0; }; A=\"Authorization: Bearer $TOKEN\"; ORG=$(get \"$API/organizations\" | id1); [ -n \"$ORG\" ] || ORG=$(post \"$API/organizations\" '{\"name\":\"Default\"}' | id1); [ -n \"$ORG\" ] || { echo \"pg_port=no-org\"; exit 0; }; P=\"$API/organizations/$ORG/projects\"; PROJ=$(get \"$P\" | id1); [ -n \"$PROJ\" ] || PROJ=$(post \"$P\" '{\"name\":\"main\"}' | id1); [ -n \"$PROJ\" ] || { echo \"pg_port=no-project\"; exit 0; }; B=\"$P/$PROJ/branches\"; BRJSON=$(get \"$B\"); BR=$(printf %s \"$BRJSON\" | id1); if [ -z \"$BR\" ]; then BR=$(post \"$B\" '{\"name\":\"production\"}' | id1); [ -n \"$BR\" ] || { echo \"pg_port=branch-failed\"; exit 0; }; curl -fsS -X PUT \"$B/$BR/password\" -H \"$J\" -H \"$A\" -d \"{\\\"password\\\":\\\"$PG_PASSWORD\\\"}\" >/dev/null 2>&1 || true; fi; PORT=$(printf %s \"$BRJSON\" | port1); [ -n \"$PORT\" ] || PORT=$(post \"$B/$BR/endpoint\" '' | port1); echo \"pg_port=${PORT:-unstarted}\"",
+ "capture": "pgPort",
+ "capturePattern": "pg_port=([0-9]+)",
+ "persistAs": {
+ "key": "NEOND_PG_PORT"
+ },
+ "once": true,
"phase": "post-ready",
"readiness": {
- "test": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\"",
- "interval": 1000,
- "retries": 30
- },
- "once": true
+ "test": "curl -fsS http://127.0.0.1:3000/api/auth/setup",
+ "interval": 5000,
+ "retries": 60
+ }
}
],
"connection": {
- "title": "Connect to Neon",
- "description": "Point a Postgres driver at the compute node. The compute serves the Postgres wire protocol on port 55433 as user cloud_admin.",
+ "title": "Open the Neon console",
+ "description": "Sign in to the console to manage projects and branches. The install pre-creates the admin account plus a `production` branch with a running endpoint, so the Postgres URL below works immediately.",
"guide": {
- "intro": "Your project gets a Postgres connection served by the Neon compute node.",
- "useHint": "Read process.env.DATABASE_URL in your code — it's set the next time your project deploys.",
- "defaultMode": "internal"
+ "intro": "You get a Neon console for branching plus a normal Postgres connection string for the first branch.",
+ "useHint": "Each branch gets its own endpoint on its own port. Create a branch in the console, press Start endpoint, and copy that branch's connection string — six host ports (55432-55437) are published for endpoints, which is three concurrent branches.",
+ "defaultMode": "public"
},
"outputs": [
+ {
+ "id": "console",
+ "label": "Neon console",
+ "source": "publicUrl:neond",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the admin email and password below."
+ },
+ {
+ "id": "adminEmail",
+ "label": "Admin email",
+ "source": "env:neond:ADMIN_EMAIL",
+ "help": "The instance admin. Sign-up is closed once this account exists."
+ },
+ {
+ "id": "adminPassword",
+ "label": "Admin password",
+ "source": "env:neond:ADMIN_PASSWORD",
+ "secret": true
+ },
{
"id": "dbUrl",
"label": "Database URL",
- "source": "template:postgresql://cloud_admin:cloud_admin@{{host}}:55433/postgres",
- "sourceLabel": "Public",
- "variants": [
- {
- "id": "internal",
- "label": "Internal",
- "source": "template:postgresql://cloud_admin:cloud_admin@compute:55433/postgres"
- }
- ],
+ "source": "template:postgresql://postgres:{{env:neond:PG_PASSWORD}}@{{host}}:{{env:neond:NEOND_PG_PORT}}/postgres?sslmode=require",
"secret": true,
"envKey": "DATABASE_URL",
- "recommended": true,
- "help": "Postgres connection (cloud_admin). Published on port 55433. Switch to Internal for apps on the same project network."
+ "help": "The `production` branch endpoint. Neon assigns each endpoint its own port, so if this shows no port the endpoint has not started yet — open the console and press Start endpoint, then read the connection string there."
}
- ]
+ ],
+ "firstLogin": {
+ "note": "The admin account is created during install — use the email and password above. First boot initialises two embedded Postgres instances and can take a couple of minutes after a ~1.2 GB image pull.\n\nTwo things worth knowing, both upstream behaviour: if the console shows a branch as running but connections fail, stop and start that endpoint from the console — restarting the container can leave a stale compute lock behind while the API still reports it healthy. And if the container is killed hard (out of memory, power loss) it can refuse to boot with \"lease already held\"; delete neon_daemon_data/.lock inside the app's volume while nothing is running. An ordinary redeploy is safe — it shuts down gracefully and releases the lock."
+ }
},
"endpoints": [
{
- "service": "compute",
- "port": 55433,
- "label": "Postgres",
- "kind": "tcp"
- }
- ],
- "files": [
- {
- "service": "pageserver",
- "path": "/data/.neon/pageserver.toml",
- "content": "broker_endpoint='http://storage_broker:50051'\npg_distrib_dir='/usr/local/'\nlisten_pg_addr='0.0.0.0:6400'\nlisten_http_addr='0.0.0.0:9898'\nremote_storage={ endpoint='http://minio:9000', bucket_name='neon', bucket_region='eu-north-1', prefix_in_bucket='/pageserver' }\ncontrol_plane_api='http://0.0.0.0:6666'\ncontrol_plane_emergency_mode=true\nvirtual_file_io_mode=\"buffered\"\n"
- },
- {
- "service": "pageserver",
- "path": "/data/.neon/identity.toml",
- "content": "id=1234\n"
- },
- {
- "service": "compute",
- "path": "/var/db/postgres/configs/config.json",
- "content": "{\n \"spec\": {\n \"format_version\": 1.0,\n\n \"timestamp\": \"2022-10-12T18:00:00.000Z\",\n \"operation_uuid\": \"0f657b36-4b0f-4a2d-9c2e-1dcd615e7d8c\",\n \"suspend_timeout_seconds\": -1,\n\n \"cluster\": {\n \"cluster_id\": \"docker_compose\",\n \"name\": \"docker_compose_test\",\n \"state\": \"restarted\",\n \"roles\": [\n {\n \"name\": \"cloud_admin\",\n \"encrypted_password\": \"b093c0d3b281ba6da1eacc608620abd8\",\n \"options\": null\n }\n ],\n \"databases\": [\n ],\n \"settings\": [\n {\n \"name\": \"fsync\",\n \"value\": \"off\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"wal_level\",\n \"value\": \"logical\",\n \"vartype\": \"enum\"\n },\n {\n \"name\": \"wal_log_hints\",\n \"value\": \"on\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"log_connections\",\n \"value\": \"on\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"port\",\n \"value\": \"55433\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"shared_buffers\",\n \"value\": \"1MB\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_connections\",\n \"value\": \"100\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"listen_addresses\",\n \"value\": \"0.0.0.0\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_wal_senders\",\n \"value\": \"10\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"max_replication_slots\",\n \"value\": \"10\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"wal_sender_timeout\",\n \"value\": \"5s\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"wal_keep_size\",\n \"value\": \"0\",\n \"vartype\": \"integer\"\n },\n {\n \"name\": \"password_encryption\",\n \"value\": \"md5\",\n \"vartype\": \"enum\"\n },\n {\n \"name\": \"restart_after_crash\",\n \"value\": \"off\",\n \"vartype\": \"bool\"\n },\n {\n \"name\": \"synchronous_standby_names\",\n \"value\": \"walproposer\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"shared_preload_libraries\",\n \"value\": \"neon,pg_cron,timescaledb,pg_stat_statements\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.safekeepers\",\n \"value\": \"safekeeper1:5454,safekeeper2:5454,safekeeper3:5454\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.timeline_id\",\n \"value\": \"TIMELINE_ID\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.tenant_id\",\n \"value\": \"TENANT_ID\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"neon.pageserver_connstring\",\n \"value\": \"host=pageserver port=6400\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_replication_write_lag\",\n \"value\": \"500MB\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"max_replication_flush_lag\",\n \"value\": \"10GB\",\n \"vartype\": \"string\"\n },\n {\n \"name\": \"cron.database\",\n \"value\": \"postgres\",\n \"vartype\": \"string\"\n }\n ]\n },\n\n \"delta_operations\": [\n ]\n },\n \"compute_ctl_config\": {\n \"jwks\": {\n \"keys\": [\n {\n \"use\": \"sig\",\n \"key_ops\": [\n \"verify\"\n ],\n \"alg\": \"EdDSA\",\n \"kid\": \"ZGIxMzAzOGY0YWQwODk2ODU1MTk1NzMxMDFkYmUyOWU2NzZkOWNjNjMyMGRkZGJjOWY0MjdjYWVmNzE1MjUyOAo=\",\n \"kty\": \"OKP\",\n \"crv\": \"Ed25519\",\n \"x\": \"MGQ4ZDFhOTdmNTM0NmUwMDc3ZmJkN2Q0MWE0ZmI3M2NhNWE3YjFjOTNkM2IyYzRkZTQzOGM3MjBkZTk3N2E5ZAo=\"\n }\n ]\n }\n }\n}\n"
- }
- ],
- "provides": [
- {
- "id": "postgres",
- "outputRefs": [
- "dbUrl"
- ],
- "category": "database"
+ "service": "neond",
+ "port": 3000,
+ "label": "Console",
+ "kind": "http",
+ "defaultMode": "domain"
}
]
}
diff --git a/packages/core/src/apps/catalog/nocodb.json b/packages/core/src/apps/catalog/nocodb.json
index fbddafd1e..ebbc2166d 100644
--- a/packages/core/src/apps/catalog/nocodb.json
+++ b/packages/core/src/apps/catalog/nocodb.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "nocodb",
"name": "NocoDB",
- "description": "Airtable-style spreadsheet UI over an SQL database. The first sign-up becomes the admin.",
+ "description": "Airtable-style spreadsheet UI over an SQL database. Sign in with the admin account below.",
"kind": "template",
"logo": "nocodb",
"category": "database",
@@ -16,15 +16,65 @@
{
"name": "nocodb",
"image": "nocodb/nocodb:latest",
- "ports": [
- "8080:8080"
- ],
"exposedPort": 8080,
"exposed": true,
"volumes": [
"nocodb_data:/usr/app/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "environment": {
+ "NC_ADMIN_EMAIL": "admin@example.com"
+ },
+ "secretEnv": [
+ "NC_ADMIN_PASSWORD"
+ ],
+ "ports": [
+ "8208:8080"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "NC_ADMIN_PASSWORD",
+ "service": "nocodb",
+ "label": "Admin password",
+ "help": "Auto-generated. Password for the super-admin account created on first boot.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Sign in to NocoDB",
+ "description": "The super-admin account below is created on first boot. Change the password after signing in.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "NocoDB",
+ "source": "publicUrl:nocodb",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the email and password below."
+ },
+ {
+ "id": "email",
+ "label": "Admin email",
+ "source": "env:nocodb:NC_ADMIN_EMAIL",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:nocodb:NC_ADMIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "Anyone who reaches this URL can still create their own account (they land in their own workspace and cannot see your bases). Turn sign-up off in Account Settings → Authentication right after your first sign-in — the NC_INVITE_ONLY_SIGNUP env var no longer works in this version."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/posthog.json b/packages/core/src/apps/catalog/posthog.json
index 5577c53d8..5a69120cb 100644
--- a/packages/core/src/apps/catalog/posthog.json
+++ b/packages/core/src/apps/catalog/posthog.json
@@ -3,12 +3,12 @@
"verified": false,
"hosting": "experimental",
"minResources": {
- "memoryMb": 8192,
+ "memoryMb": 16384,
"cpuCores": 4
},
"id": "posthog",
"name": "PostHog",
- "description": "Self-hosted PostHog — product analytics, session replay, and feature flags. EXPERIMENTAL and heavy: the hobby stack runs the PostHog app plus Postgres, Redis, ClickHouse, Zookeeper, a Kafka-compatible broker (Redpanda), and MinIO (~9 containers) and wants roughly 4 vCPU / 8–16 GB RAM. Not for production.",
+ "description": "Self-hosted PostHog — product analytics, session replay, and feature flags, with real event ingestion. This mirrors PostHog's own hobby topology: a Caddy path router in front of the Django app, the Rust capture/flags/hypercache/persons services, the Node ingestion consumers, ClickHouse + Kafka + Postgres + Redis + Valkey + S3. EXPERIMENTAL and heavy: 21 containers, wants roughly 4 vCPU / 16 GB RAM and 30+ GB disk. PostHog ships no tagged releases for self-hosting and rebuilds `latest` hourly, so this tracks a moving upstream. Not for production.",
"repository": "https://github.com/PostHog/posthog",
"kind": "template",
"logo": "posthog",
@@ -22,6 +22,37 @@
],
"framework": "docker-compose",
"services": [
+ {
+ "name": "proxy",
+ "image": "caddy:2.10-alpine",
+ "exposed": true,
+ "exposedPort": 80,
+ "routes": [
+ {
+ "port": 80
+ }
+ ],
+ "dependsOn": [
+ "web",
+ "capture",
+ "replay-capture",
+ "feature-flags",
+ "hypercache-server",
+ "plugins",
+ "objectstorage"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget --no-verbose --tries=1 --spider http://127.0.0.1:80/openship-health || exit 1"
+ ],
+ "interval": "10s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "60s"
+ },
+ "restart": "unless-stopped"
+ },
{
"name": "db",
"image": "postgres:15.12-alpine",
@@ -41,16 +72,22 @@
"pg_isready -U posthog"
],
"interval": "5s",
- "timeout": "5s",
- "retries": 10,
+ "timeout": "30s",
+ "retries": 30,
"startPeriod": "10s"
},
"restart": "unless-stopped"
},
{
- "name": "redis",
+ "name": "redis7",
"image": "redis:7.2-alpine",
- "command": "redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb",
+ "commandArgv": [
+ "redis-server",
+ "--maxmemory-policy",
+ "allkeys-lru",
+ "--maxmemory",
+ "200mb"
+ ],
"volumes": [
"posthog_redis:/data"
],
@@ -60,8 +97,30 @@
"redis-cli",
"ping"
],
- "interval": "5s",
- "timeout": "5s",
+ "interval": "3s",
+ "timeout": "10s",
+ "retries": 10
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "valkey",
+ "image": "valkey/valkey:8.1-alpine",
+ "commandArgv": [
+ "valkey-server",
+ "--maxmemory-policy",
+ "allkeys-lru",
+ "--maxmemory",
+ "200mb"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD",
+ "valkey-cli",
+ "ping"
+ ],
+ "interval": "3s",
+ "timeout": "10s",
"retries": 10
},
"restart": "unless-stopped"
@@ -75,41 +134,90 @@
},
"volumes": [
"posthog_zk_data:/data",
- "posthog_zk_datalog:/datalog"
+ "posthog_zk_datalog:/datalog",
+ "posthog_zk_logs:/logs"
],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "echo ruok | nc -w 2 localhost 2181 | grep -q imok"
+ ],
+ "interval": "5s",
+ "timeout": "10s",
+ "retries": 20,
+ "startPeriod": "10s"
+ },
"restart": "unless-stopped"
},
{
"name": "kafka",
- "image": "docker.io/redpandadata/redpanda:v25.1.9",
- "command": "redpanda start --kafka-addr internal://0.0.0.0:9092 --advertise-kafka-addr internal://kafka:9092 --mode dev-container --smp 1 --memory 1G",
+ "image": "apache/kafka:4.1.0",
+ "environment": {
+ "KAFKA_NODE_ID": "1",
+ "KAFKA_PROCESS_ROLES": "broker,controller",
+ "KAFKA_LISTENERS": "PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093",
+ "KAFKA_ADVERTISED_LISTENERS": "PLAINTEXT://kafka:9092",
+ "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP": "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT",
+ "KAFKA_CONTROLLER_LISTENER_NAMES": "CONTROLLER",
+ "KAFKA_INTER_BROKER_LISTENER_NAME": "PLAINTEXT",
+ "KAFKA_CONTROLLER_QUORUM_VOTERS": "1@kafka:9093",
+ "KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR": "1",
+ "KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR": "1",
+ "KAFKA_TRANSACTION_STATE_LOG_MIN_ISR": "1",
+ "KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS": "0",
+ "KAFKA_AUTO_CREATE_TOPICS_ENABLE": "true",
+ "KAFKA_NUM_PARTITIONS": "1",
+ "KAFKA_DEFAULT_REPLICATION_FACTOR": "1",
+ "KAFKA_LOG_RETENTION_HOURS": "1",
+ "KAFKA_LOG_DIRS": "/var/lib/kafka/data"
+ },
"volumes": [
- "posthog_kafka:/var/lib/redpanda/data"
+ "posthog_kafka:/var/lib/kafka/data"
],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1 || exit 1"
+ ],
+ "interval": "5s",
+ "timeout": "10s",
+ "retries": 30,
+ "startPeriod": "20s"
+ },
"restart": "unless-stopped"
},
{
"name": "clickhouse",
- "image": "clickhouse/clickhouse-server:24.12",
+ "image": "clickhouse/clickhouse-server:26.6.2.158",
"dependsOn": [
- "kafka",
- "zookeeper"
+ "zookeeper",
+ "kafka"
],
"environment": {
- "CLICKHOUSE_SKIP_USER_SETUP": "1"
+ "CLICKHOUSE_SKIP_USER_SETUP": "1",
+ "KAFKA_HOSTS": "kafka:9092"
},
"volumes": [
"posthog_clickhouse:/var/lib/clickhouse"
],
+ "healthcheck": {
+ "test": [
+ "CMD-SHELL",
+ "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"
+ ],
+ "interval": "3s",
+ "timeout": "10s",
+ "retries": 30,
+ "startPeriod": "20s"
+ },
"restart": "unless-stopped"
},
{
"name": "objectstorage",
- "image": "minio/minio:RELEASE.2025-04-22T22-12-26Z",
- "command": "server /data --console-address :9001",
- "secretEnv": [
- "MINIO_ROOT_PASSWORD"
- ],
+ "image": "chrislusf/seaweedfs:4.29",
+ "environment": {
+ "S3_BUCKET": "posthog,ducklake-dev,ai-blobs"
+ },
"volumes": [
"posthog_objectstorage:/data"
],
@@ -118,116 +226,468 @@
{
"name": "web",
"image": "posthog/posthog:latest",
- "command": "./bin/docker-server",
+ "commandArgv": [
+ "sh",
+ "-c",
+ "./bin/migrate && exec ./bin/docker-server"
+ ],
"dependsOn": [
"db",
- "redis",
+ "redis7",
"clickhouse",
"kafka",
- "objectstorage"
- ],
- "exposedPort": 8000,
- "exposed": true,
- "routes": [
- {
- "port": 8000
- }
+ "objectstorage",
+ "personhog-router"
],
"environment": {
"DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_WRITER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_READER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PGHOST": "db",
+ "PGUSER": "posthog",
+ "PGPASSWORD": "{{config:POSTGRES_PASSWORD}}",
"CLICKHOUSE_HOST": "clickhouse",
"CLICKHOUSE_DATABASE": "posthog",
"CLICKHOUSE_SECURE": "false",
"CLICKHOUSE_VERIFY": "false",
- "KAFKA_HOSTS": "kafka:9092",
- "KAFKA_URL": "kafka://kafka:9092",
- "REDIS_URL": "redis://redis:6379/",
+ "CLICKHOUSE_API_USER": "api",
+ "CLICKHOUSE_API_PASSWORD": "apipass",
+ "CLICKHOUSE_APP_USER": "app",
+ "CLICKHOUSE_APP_PASSWORD": "apppass",
+ "CLICKHOUSE_BILLING_USER": "billing",
+ "CLICKHOUSE_BILLING_PASSWORD": "billingpass",
+ "CLICKHOUSE_DICT_READER_USER": "dict_reader",
+ "CLICKHOUSE_DICT_READER_PASSWORD": "dictreaderpass",
+ "CLICKHOUSE_LOGS_CLUSTER_HOST": "clickhouse",
+ "CLICKHOUSE_LOGS_CLUSTER_SECURE": "false",
+ "REDIS_URL": "redis://redis7:6379/",
+ "KAFKA_HOSTS": "kafka",
+ "DEPLOYMENT": "hobby",
"SECRET_KEY": "{{config:POSTHOG_SECRET}}",
- "OBJECT_STORAGE_ENABLED": "True",
- "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:9000",
- "OBJECT_STORAGE_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}",
- "OBJECT_STORAGE_BUCKET": "posthog",
- "SITE_URL": "{{publicUrl:web}}",
- "DISABLE_SECURE_SSL_REDIRECT": "true",
+ "SITE_URL": "{{publicUrl:proxy}}",
"IS_BEHIND_PROXY": "true",
- "TRUST_ALL_PROXIES": "true"
+ "DISABLE_SECURE_SSL_REDIRECT": "true",
+ "OTEL_SDK_DISABLED": "true",
+ "OPT_OUT_CAPTURE": "false",
+ "FLAGS_REDIS_ENABLED": "false",
+ "FEATURE_FLAGS_SERVICE_URL": "http://feature-flags:3001",
+ "CDP_API_URL": "http://plugins:6738",
+ "RECORDING_API_URL": "http://recording-api:6738",
+ "PERSONHOG_ADDR": "personhog-router:50052",
+ "PERSONHOG_ENABLED": "true",
+ "OBJECT_STORAGE_ENABLED": "true",
+ "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:8333",
+ "OBJECT_STORAGE_PUBLIC_ENDPOINT": "{{publicUrl:proxy}}",
+ "OBJECT_STORAGE_FORCE_PATH_STYLE": "true",
+ "OBJECT_STORAGE_BUCKET": "posthog",
+ "OBJECT_STORAGE_ACCESS_KEY_ID": "posthog",
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY": "posthog",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any"
},
"secretEnv": [
"DATABASE_URL",
- "SECRET_KEY",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY"
+ "PERSONS_DB_WRITER_URL",
+ "PERSONS_DB_READER_URL",
+ "PGPASSWORD",
+ "SECRET_KEY"
],
"restart": "unless-stopped"
},
{
"name": "worker",
"image": "posthog/posthog:latest",
- "command": "./bin/docker-worker-celery --with-scheduler",
+ "commandArgv": [
+ "./bin/docker-worker-celery",
+ "--with-scheduler"
+ ],
"dependsOn": [
"db",
- "redis",
+ "redis7",
"clickhouse",
"kafka",
"objectstorage",
- "web"
+ "web",
+ "personhog-router"
],
"environment": {
"DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_WRITER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DB_READER_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PGHOST": "db",
+ "PGUSER": "posthog",
+ "PGPASSWORD": "{{config:POSTGRES_PASSWORD}}",
"CLICKHOUSE_HOST": "clickhouse",
"CLICKHOUSE_DATABASE": "posthog",
"CLICKHOUSE_SECURE": "false",
"CLICKHOUSE_VERIFY": "false",
- "KAFKA_HOSTS": "kafka:9092",
- "KAFKA_URL": "kafka://kafka:9092",
- "REDIS_URL": "redis://redis:6379/",
+ "CLICKHOUSE_API_USER": "api",
+ "CLICKHOUSE_API_PASSWORD": "apipass",
+ "CLICKHOUSE_APP_USER": "app",
+ "CLICKHOUSE_APP_PASSWORD": "apppass",
+ "CLICKHOUSE_BILLING_USER": "billing",
+ "CLICKHOUSE_BILLING_PASSWORD": "billingpass",
+ "CLICKHOUSE_DICT_READER_USER": "dict_reader",
+ "CLICKHOUSE_DICT_READER_PASSWORD": "dictreaderpass",
+ "CLICKHOUSE_LOGS_CLUSTER_HOST": "clickhouse",
+ "CLICKHOUSE_LOGS_CLUSTER_SECURE": "false",
+ "REDIS_URL": "redis://redis7:6379/",
+ "KAFKA_HOSTS": "kafka",
+ "DEPLOYMENT": "hobby",
+ "POSTHOG_SKIP_MIGRATION_CHECKS": "1",
"SECRET_KEY": "{{config:POSTHOG_SECRET}}",
- "OBJECT_STORAGE_ENABLED": "True",
- "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:9000",
- "OBJECT_STORAGE_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}",
+ "SITE_URL": "{{publicUrl:proxy}}",
+ "IS_BEHIND_PROXY": "true",
+ "DISABLE_SECURE_SSL_REDIRECT": "true",
+ "OTEL_SDK_DISABLED": "true",
+ "FLAGS_REDIS_ENABLED": "false",
+ "FEATURE_FLAGS_SERVICE_URL": "http://feature-flags:3001",
+ "CDP_API_URL": "http://plugins:6738",
+ "RECORDING_API_URL": "http://recording-api:6738",
+ "PERSONHOG_ADDR": "personhog-router:50052",
+ "PERSONHOG_ENABLED": "true",
+ "OBJECT_STORAGE_ENABLED": "true",
+ "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:8333",
+ "OBJECT_STORAGE_PUBLIC_ENDPOINT": "{{publicUrl:proxy}}",
+ "OBJECT_STORAGE_FORCE_PATH_STYLE": "true",
"OBJECT_STORAGE_BUCKET": "posthog",
- "SITE_URL": "{{publicUrl:web}}"
+ "OBJECT_STORAGE_ACCESS_KEY_ID": "posthog",
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY": "posthog",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any"
},
"secretEnv": [
"DATABASE_URL",
- "SECRET_KEY",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY"
+ "PERSONS_DB_WRITER_URL",
+ "PERSONS_DB_READER_URL",
+ "PGPASSWORD",
+ "SECRET_KEY"
],
"restart": "unless-stopped"
},
{
- "name": "plugins",
- "image": "posthog/posthog:latest",
- "command": "./bin/plugin-server --no-restart-loop",
+ "name": "capture",
+ "image": "ghcr.io/posthog/posthog/capture:master",
+ "dependsOn": [
+ "kafka",
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3000",
+ "CAPTURE_MODE": "events",
+ "KAFKA_TOPIC": "events_plugin_ingestion",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "RUST_LOG": "info,rdkafka=warn",
+ "CAPTURE_V1_SINKS": "msk",
+ "CAPTURE_V1_SINK_MSK_KAFKA_HOSTS": "kafka:9092",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_MAIN": "events_plugin_ingestion",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_HISTORICAL": "events_plugin_ingestion_historical",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_OVERFLOW": "events_plugin_ingestion_overflow",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_DLQ": "events_plugin_ingestion_dlq",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_EXCEPTION": "ingestion-errortracking-main",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_HEATMAP": "heatmaps_ingestion",
+ "CAPTURE_V1_SINK_MSK_KAFKA_TOPIC_CLIENT_INGESTION_WARNING": "ingestion-clientwarnings-main-1"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "replay-capture",
+ "image": "ghcr.io/posthog/posthog/capture:master",
+ "dependsOn": [
+ "kafka",
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3000",
+ "CAPTURE_MODE": "recordings",
+ "KAFKA_TOPIC": "session_recording_snapshot_item_events",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "RUST_LOG": "info,rdkafka=warn"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "ingestion-general",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
"dependsOn": [
"db",
- "redis",
+ "redis7",
"clickhouse",
"kafka",
"objectstorage",
- "web"
+ "personhog-router"
+ ],
+ "environment": {
+ "PLUGIN_SERVER_MODE": "ingestion-v2-combined",
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "BEHAVIORAL_COHORTS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "CLICKHOUSE_HOST": "clickhouse",
+ "CLICKHOUSE_DATABASE": "posthog",
+ "CLICKHOUSE_SECURE": "false",
+ "CLICKHOUSE_VERIFY": "false",
+ "COOKIELESS_REDIS_HOST": "redis7",
+ "COOKIELESS_REDIS_PORT": "6379",
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379",
+ "PERSONHOG_ADDR": "personhog-router:50052",
+ "PERSONHOG_ENABLED": "true",
+ "AI_BLOB_S3_BUCKET": "ai-blobs",
+ "AI_BLOB_S3_PREFIX": "aio/",
+ "AI_BLOB_S3_ENDPOINT": "http://objectstorage:8333",
+ "AI_BLOB_S3_REGION": "us-east-1",
+ "AI_BLOB_S3_ACCESS_KEY_ID": "any",
+ "AI_BLOB_S3_SECRET_ACCESS_KEY": "any",
+ "AI_BLOB_OFFLOAD_TEAMS": "*"
+ },
+ "secretEnv": [
+ "DATABASE_URL",
+ "PERSONS_DATABASE_URL",
+ "BEHAVIORAL_COHORTS_DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "ingestion-sessionreplay",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "kafka",
+ "objectstorage"
+ ],
+ "environment": {
+ "PLUGIN_SERVER_MODE": "recordings-blob-ingestion-v2",
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "KAFKA_HOSTS": "kafka:9092",
+ "REDIS_URL": "redis://redis7:6379/",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any",
+ "SESSION_RECORDING_V2_S3_TIMEOUT_MS": "120000",
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379"
+ },
+ "secretEnv": [
+ "DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "recording-api",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "clickhouse"
],
"environment": {
+ "PLUGIN_SERVER_MODE": "recording-api",
"DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "REDIS_URL": "redis://redis7:6379/",
"CLICKHOUSE_HOST": "clickhouse",
"CLICKHOUSE_DATABASE": "posthog",
"CLICKHOUSE_SECURE": "false",
"CLICKHOUSE_VERIFY": "false",
+ "SESSION_RECORDING_API_REDIS_HOST": "redis7",
+ "SESSION_RECORDING_API_REDIS_PORT": "6379",
+ "SESSION_RECORDING_V2_S3_ENDPOINT": "http://objectstorage:8333",
+ "SESSION_RECORDING_V2_S3_ACCESS_KEY_ID": "any",
+ "SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY": "any",
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379"
+ },
+ "secretEnv": [
+ "DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "feature-flags",
+ "image": "ghcr.io/posthog/posthog/feature-flags:master",
+ "dependsOn": [
+ "db",
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3001",
+ "WRITE_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "READ_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_WRITE_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_READ_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "REDIS_URL": "redis://redis7:6379/",
+ "COOKIELESS_REDIS_HOST": "redis7",
+ "COOKIELESS_REDIS_PORT": "6379",
+ "MAXMIND_DB_PATH": "/app/share/GeoLite2-City.mmdb",
+ "RUST_LOG": "info"
+ },
+ "secretEnv": [
+ "WRITE_DATABASE_URL",
+ "READ_DATABASE_URL",
+ "PERSONS_WRITE_DATABASE_URL",
+ "PERSONS_READ_DATABASE_URL"
+ ],
+ "healthcheck": {
+ "test": [
+ "CMD",
+ "curl",
+ "-f",
+ "http://localhost:3001/_readiness"
+ ],
+ "interval": "5s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "10s"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "hypercache-server",
+ "image": "ghcr.io/posthog/posthog/hypercache-server:master",
+ "dependsOn": [
+ "redis7"
+ ],
+ "environment": {
+ "ADDRESS": "0.0.0.0:3002",
+ "REDIS_URL": "redis://redis7:6379/",
+ "RUST_LOG": "info"
+ },
+ "healthcheck": {
+ "test": [
+ "CMD",
+ "curl",
+ "-f",
+ "http://localhost:3002/_readiness"
+ ],
+ "interval": "5s",
+ "timeout": "5s",
+ "retries": 12,
+ "startPeriod": "10s"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "personhog-replica",
+ "image": "ghcr.io/posthog/posthog/personhog-replica:master",
+ "dependsOn": [
+ "db"
+ ],
+ "environment": {
+ "GRPC_ADDRESS": "0.0.0.0:50051",
+ "PRIMARY_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "METRICS_PORT": "9100",
+ "RUST_LOG": "info"
+ },
+ "secretEnv": [
+ "PRIMARY_DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "personhog-router",
+ "image": "ghcr.io/posthog/posthog/personhog-router:master",
+ "dependsOn": [
+ "personhog-replica"
+ ],
+ "environment": {
+ "GRPC_ADDRESS": "0.0.0.0:50052",
+ "REPLICA_URL": "http://personhog-replica:50051",
+ "BACKEND_TIMEOUT_MS": "5000",
+ "METRICS_PORT": "9101",
+ "RUST_LOG": "info"
+ },
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "property-defs-rs",
+ "image": "ghcr.io/posthog/posthog/property-defs-rs:master",
+ "dependsOn": [
+ "db",
+ "kafka"
+ ],
+ "environment": {
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "KAFKA_HOSTS": "kafka:9092",
+ "SKIP_WRITES": "false",
+ "SKIP_READS": "false",
+ "FILTER_MODE": "opt-out",
+ "RUST_LOG": "info"
+ },
+ "secretEnv": [
+ "DATABASE_URL"
+ ],
+ "restart": "unless-stopped"
+ },
+ {
+ "name": "plugins",
+ "image": "posthog/posthog-node:latest",
+ "commandArgv": [
+ "node",
+ "nodejs/dist/index.js"
+ ],
+ "dependsOn": [
+ "db",
+ "redis7",
+ "valkey",
+ "clickhouse",
+ "kafka",
+ "objectstorage"
+ ],
+ "environment": {
+ "DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "PERSONS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "BEHAVIORAL_COHORTS_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
+ "CYCLOTRON_DATABASE_URL": "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
"KAFKA_HOSTS": "kafka:9092",
- "KAFKA_URL": "kafka://kafka:9092",
- "REDIS_URL": "redis://redis:6379/",
+ "REDIS_URL": "redis://redis7:6379/",
+ "CLICKHOUSE_HOST": "clickhouse",
+ "CLICKHOUSE_DATABASE": "posthog",
+ "CLICKHOUSE_SECURE": "false",
+ "CLICKHOUSE_VERIFY": "false",
+ "SITE_URL": "{{publicUrl:proxy}}",
"SECRET_KEY": "{{config:POSTHOG_SECRET}}",
- "OBJECT_STORAGE_ENABLED": "True",
- "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:9000",
- "OBJECT_STORAGE_ACCESS_KEY_ID": "{{config:MINIO_ROOT_USER}}",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY": "{{config:MINIO_ROOT_PASSWORD}}",
- "OBJECT_STORAGE_BUCKET": "posthog"
+ "CDP_REDIS_HOST": "redis7",
+ "CDP_REDIS_PORT": "6379",
+ "CDP_VALKEY_HOST": "valkey",
+ "CDP_VALKEY_PORT": "6379",
+ "OBJECT_STORAGE_ENABLED": "true",
+ "OBJECT_STORAGE_ENDPOINT": "http://objectstorage:8333",
+ "OBJECT_STORAGE_PUBLIC_ENDPOINT": "{{publicUrl:proxy}}",
+ "OBJECT_STORAGE_FORCE_PATH_STYLE": "true",
+ "OBJECT_STORAGE_BUCKET": "posthog",
+ "OBJECT_STORAGE_ACCESS_KEY_ID": "any",
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY": "any"
},
"secretEnv": [
"DATABASE_URL",
- "SECRET_KEY",
- "OBJECT_STORAGE_SECRET_ACCESS_KEY"
+ "PERSONS_DATABASE_URL",
+ "BEHAVIORAL_COHORTS_DATABASE_URL",
+ "CYCLOTRON_DATABASE_URL",
+ "SECRET_KEY"
],
"restart": "unless-stopped"
}
@@ -237,7 +697,7 @@
"key": "POSTGRES_PASSWORD",
"service": "db",
"label": "Database password",
- "help": "Auto-generated. The Postgres password PostHog uses.",
+ "help": "Auto-generated. The Postgres password every PostHog service uses.",
"generate": "secret",
"secret": true
},
@@ -248,68 +708,72 @@
"help": "Auto-generated. Signs sessions and cookies (SECRET_KEY).",
"generate": "secret",
"secret": true
+ }
+ ],
+ "management": {
+ "kind": "schema"
+ },
+ "files": [
+ {
+ "service": "proxy",
+ "path": "/etc/caddy/Caddyfile",
+ "content": "{\n\tservers {\n\t\ttrusted_proxies static 127.0.0.1/32 ::1/128 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16\n\t}\n}\n\n# Host-less site address on purpose. PostHog's own default is\n# `http://localhost:8000`, which compiles to a Host matcher — behind Openship's\n# edge a foreign Host then returns an empty HTTP 200 instead of a 404, so every\n# ingestion POST would look successful while the events were silently dropped.\n# `:80` also emits no automatic_https and no tls app, so Caddy never tries ACME.\n:80 {\n\t# Health target for the container healthcheck. Deliberately answered by\n\t# Caddy itself: proxying /_health through to Django would report unhealthy\n\t# for the whole first-boot migration window and raise a false incident.\n\t@openship-health {\n\t\tpath /openship-health\n\t}\n\n\thandle @openship-health {\n\t\trespond \"ok\" 200\n\t}\n\n\t@replay-capture {\n\t\tpath /s\n\t\tpath /s/\n\t\tpath /s/*\n\t}\n\n\t@capture {\n\t\tpath /e\n\t\tpath /e/\n\t\tpath /e/*\n\t\tpath /i/v0\n\t\tpath /i/v0/\n\t\tpath /i/v0/*\n\t\tpath /i/v1/analytics/events\n\t\tpath /i/v1/analytics/events/\n\t\tpath /batch\n\t\tpath /batch/\n\t\tpath /batch/*\n\t\tpath /capture\n\t\tpath /capture/\n\t\tpath /capture/*\n\t}\n\n\t@flags {\n\t\tpath /flags\n\t\tpath /flags/\n\t\tpath /flags/*\n\t\tpath /api/feature_flag/local_evaluation\n\t\tpath /api/feature_flag/local_evaluation/\n\t\tpath /api/feature_flag/local_evaluation/*\n\t}\n\n\t@surveys {\n\t\tpath /surveys\n\t\tpath /surveys/\n\t\tpath /api/surveys\n\t\tpath /api/surveys/\n\t}\n\n\t@remote-config {\n\t\tpath /array/*\n\t}\n\n\t@webhooks {\n\t\tpath /public/webhooks\n\t\tpath /public/webhooks/\n\t\tpath /public/webhooks/*\n\t\tpath /public/m/\n\t\tpath /public/m/*\n\t}\n\n\t@objectstorage {\n\t\tpath /posthog\n\t\tpath /posthog/\n\t\tpath /posthog/*\n\t}\n\n\thandle @capture {\n\t\treverse_proxy capture:3000\n\t}\n\n\thandle @replay-capture {\n\t\treverse_proxy replay-capture:3000\n\t}\n\n\thandle @flags {\n\t\treverse_proxy feature-flags:3001\n\t}\n\n\thandle @surveys {\n\t\treverse_proxy hypercache-server:3002\n\t}\n\n\thandle @remote-config {\n\t\treverse_proxy hypercache-server:3002\n\t}\n\n\thandle @webhooks {\n\t\treverse_proxy plugins:6738\n\t}\n\n\thandle @objectstorage {\n\t\treverse_proxy objectstorage:8333\n\t}\n\n\thandle {\n\t\treverse_proxy web:8000\n\t}\n}\n"
},
{
- "key": "MINIO_ROOT_USER",
- "service": "objectstorage",
- "label": "Object-storage access key",
- "help": "The S3 access key PostHog uses against its bundled MinIO (session recordings).",
- "type": "text",
- "default": "posthog",
- "required": true
+ "service": "clickhouse",
+ "path": "/etc/clickhouse-server/config.d/openship-posthog.xml",
+ "content": "\n \n \n \n zookeeper \n 2181 \n \n \n \n 01 \n ch1 \n \n \n /clickhouse/task_queue/ddl \n \n \n 256 \n /var/lib/clickhouse/format_schemas/ \n \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n clickhouse 9000 \n \n \n \n \n \n \n \n \n \n \n \n \n \n"
},
{
- "key": "MINIO_ROOT_PASSWORD",
- "service": "objectstorage",
- "label": "Object-storage secret key",
- "help": "Auto-generated. The S3 secret key for the bundled MinIO.",
- "generate": "secret",
- "secret": true
+ "service": "clickhouse",
+ "path": "/etc/clickhouse-server/users.d/openship-posthog.xml",
+ "content": "\n \n \n \n 10000000000 \n 0 \n random \n \n \n \n \n \n ::/0 \n default \n default \n 1 \n \n \n apipass \n ::/0 \n default \n default \n \n \n apppass \n ::/0 \n default \n default \n \n \n billingpass \n ::/0 \n default \n default \n \n \n dictreaderpass \n ::/0 \n default \n default \n \n \n \n 3600 \n \n \n"
}
],
- "management": {
- "kind": "schema"
- },
"prepare": [
{
- "service": "objectstorage",
- "command": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" > /dev/null && mc mb --ignore-existing local/posthog > /dev/null && printf %s posthog",
- "capture": "bucket",
+ "service": "kafka",
+ "title": "Create Kafka topics",
+ "description": "Pre-creates the ingestion topics the Node consumers verify at startup.",
+ "command": "for t in events_plugin_ingestion events_plugin_ingestion_historical events_plugin_ingestion_overflow events_plugin_ingestion_dlq events_plugin_ingestion_ai events_plugin_ingestion_async session_recording_snapshot_item_events clickhouse_events_json clickhouse_ai_events_json clickhouse_heatmap_events clickhouse_flag_evaluations clickhouse_ingestion_warnings clickhouse_groups clickhouse_person clickhouse_person_distinct_id clickhouse_person_distinct_id2 clickhouse_person_overrides clickhouse_app_metrics2 clickhouse_session_replay_events clickhouse_session_recording_events clickhouse_tophog heatmaps_ingestion ingestion-clientwarnings-main-1 ingestion-errortracking-main log_entries plugin_log_entries events_dead_letter_queue; do /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --if-not-exists --topic \"$t\" --partitions 1 --replication-factor 1 >/dev/null 2>&1 || true; done; echo done",
+ "capture": "topics",
"phase": "post-ready",
"readiness": {
- "test": "mc alias set local http://127.0.0.1:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\"",
- "interval": 1000,
+ "test": "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092",
+ "interval": 3000,
"retries": 30
- },
- "once": true
+ }
}
],
"connection": {
"title": "Open PostHog",
- "description": "Open the PostHog UI and create your admin account on first load.",
+ "description": "Open the PostHog UI and create your admin account on first load. The same hostname serves the UI and the event-ingestion endpoints, so an SDK pointed at this URL works with no extra configuration.",
"guide": {
- "intro": "PostHog runs its own analytics UI — create the admin account when you first open it.",
+ "intro": "PostHog runs its own analytics UI — create the admin account when you first open it, then use the project API key it gives you in your SDK.",
+ "useHint": "Point your SDK's host at this URL. Caddy routes /e, /capture, /batch and /i/* to the capture service, /s/* to session replay, /flags to feature flags, and everything else to the app.",
"defaultMode": "public"
},
"outputs": [
{
"id": "ui",
"label": "PostHog",
- "source": "publicUrl:web",
+ "source": "publicUrl:proxy",
"kind": "url",
- "help": "PostHog has no default login — the first visitor creates the admin account."
+ "recommended": true,
+ "help": "PostHog has no default login — the first visitor creates the admin account. This host also receives your events."
}
],
"firstLogin": {
- "note": "PostHog ships no default credentials — open the URL above and sign up to create the first (admin) account."
+ "note": "PostHog ships no default credentials — open the URL above and sign up to create the first (admin) account. First boot runs database migrations and can take several minutes before the UI answers."
}
},
"endpoints": [
{
- "service": "web",
- "port": 8000,
+ "service": "proxy",
+ "port": 80,
"label": "PostHog",
- "kind": "http"
+ "kind": "http",
+ "defaultMode": "domain"
}
]
}
diff --git a/packages/core/src/apps/catalog/qdrant.json b/packages/core/src/apps/catalog/qdrant.json
index d8a1a6a79..6d72c8fd3 100644
--- a/packages/core/src/apps/catalog/qdrant.json
+++ b/packages/core/src/apps/catalog/qdrant.json
@@ -20,9 +20,6 @@
{
"name": "qdrant",
"image": "qdrant/qdrant:v1.18.3",
- "ports": [
- "6333:6333"
- ],
"exposedPort": 6333,
"exposed": true,
"routes": [
@@ -36,7 +33,10 @@
"volumes": [
"qdrant_storage:/qdrant/storage"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "8215:6333"
+ ]
}
],
"configFields": [
diff --git a/packages/core/src/apps/catalog/redis.json b/packages/core/src/apps/catalog/redis.json
index b45d129b1..e66424290 100644
--- a/packages/core/src/apps/catalog/redis.json
+++ b/packages/core/src/apps/catalog/redis.json
@@ -3,7 +3,8 @@
"verified": true,
"id": "redis",
"name": "Valkey (Redis)",
- "description": "In-memory data store for caching, sessions, rate limits, and queues — Valkey, the open-source Redis fork. Drop-in Redis-compatible: point any redis client at it.",
+ "description": "In-memory data store for caching, sessions, rate limits, and queues — Valkey, the open-source Redis fork. Drop-in Redis-compatible: point any redis client at it. Ships with RedisInsight, a browser UI that arrives already connected to this instance.",
+ "repository": "https://github.com/valkey-io/valkey",
"kind": "template",
"logo": "valkey",
"category": "database",
@@ -12,16 +13,22 @@
"redis",
"valkey",
"key-value",
- "queue"
+ "queue",
+ "gui"
],
"framework": "docker-compose",
"services": [
{
"name": "valkey",
"image": "valkey/valkey:8.1-alpine",
- "command": "valkey-server /etc/valkey/valkey.conf",
- "ports": [
- "6379:6379"
+ "commandArgv": [
+ "valkey-server"
+ ],
+ "environment": {
+ "VALKEY_EXTRA_FLAGS": "--requirepass {{config:VALKEY_PASSWORD}} --appendonly yes --appendfsync everysec --save 300 100"
+ },
+ "secretEnv": [
+ "VALKEY_EXTRA_FLAGS"
],
"volumes": [
"valkey_data:/data"
@@ -29,7 +36,7 @@
"healthcheck": {
"test": [
"CMD-SHELL",
- "valkey-cli --no-auth-warning -a \"$VALKEY_PASSWORD\" ping | grep -q PONG"
+ "valkey-cli ping 2>&1 | grep -q NOAUTH && valkey-cli --no-auth-warning -a \"$VALKEY_PASSWORD\" ping | grep -q PONG"
],
"interval": "10s",
"timeout": "5s",
@@ -37,6 +44,38 @@
"startPeriod": "10s"
},
"restart": "unless-stopped"
+ },
+ {
+ "name": "redisinsight",
+ "image": "redis/redisinsight:3.8.0",
+ "exposedPort": 5540,
+ "exposed": true,
+ "routes": [
+ {
+ "port": 5540
+ }
+ ],
+ "dependsOn": [
+ "valkey"
+ ],
+ "environment": {
+ "RI_ACCEPT_TERMS_AND_CONDITIONS": "true",
+ "RI_REDIS_HOST": "valkey",
+ "RI_REDIS_PORT": "6379",
+ "RI_REDIS_DB": "0",
+ "RI_REDIS_ALIAS": "Valkey (this app)",
+ "RI_REDIS_PASSWORD": "{{config:VALKEY_PASSWORD}}"
+ },
+ "secretEnv": [
+ "RI_REDIS_PASSWORD"
+ ],
+ "volumes": [
+ "redisinsight_data:/data"
+ ],
+ "restart": "unless-stopped",
+ "ports": [
+ "8219:5540"
+ ]
}
],
"configFields": [
@@ -44,46 +83,58 @@
"key": "VALKEY_PASSWORD",
"service": "valkey",
"label": "Password",
- "help": "Auto-generated. Required by every client (Valkey's `requirepass` auth).",
+ "help": "Auto-generated. Required by every client (Valkey's `requirepass` auth). The bundled browser UI is pre-loaded with it.",
"generate": "secret",
"secret": true
}
],
- "files": [
- {
- "service": "valkey",
- "path": "/etc/valkey/valkey.conf",
- "content": "requirepass {{config:VALKEY_PASSWORD}}\nappendonly yes\nappendfsync everysec\nsave 300 100\n"
- }
- ],
"management": {
"kind": "schema"
},
"connection": {
"title": "Connect to Valkey",
- "description": "A Redis-compatible endpoint. Point any redis client at the URL below — it already carries the password.",
+ "description": "A Redis-compatible endpoint on your project's private network. Point any redis client at the URL below — it already carries the password.",
"guide": {
- "intro": "Your project gets a Redis-compatible cache, ready to use.",
+ "intro": "Your project gets a Redis-compatible cache plus a browser UI that is already connected to it.",
"useHint": "Read `process.env.REDIS_URL` in your code — it's set the next time your project deploys.",
"defaultMode": "internal"
},
"outputs": [
+ {
+ "id": "ui",
+ "label": "Browser UI",
+ "source": "publicUrl:redisinsight",
+ "kind": "url",
+ "recommended": true,
+ "help": "RedisInsight, already pointed at this instance — no connection details to enter. It has NO login of its own, so anyone who can reach this address can read and write your data: keep it on the published port (reachable through an SSH tunnel) unless you deliberately put it on a domain you protect.",
+ "width": "full"
+ },
{
"id": "url",
"label": "Connection URL",
- "source": "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@{{host}}:6379",
- "sourceLabel": "Public",
- "variants": [
- {
- "id": "internal",
- "label": "Internal",
- "source": "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@valkey:6379"
- }
- ],
+ "source": "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@valkey:6379",
+ "sourceLabel": "Internal",
+ "service": "valkey",
"secret": true,
"envKey": "REDIS_URL",
"recommended": true,
- "help": "Redis-protocol URL with the password embedded. Published on port 6379. Switch to Internal for apps on the same project network."
+ "help": "Redis-protocol URL with the password embedded, on the project's private network. Port 6379 is not published to the internet — bind this app into another project to use it, or reach it from the server with `docker exec`."
+ },
+ {
+ "id": "host",
+ "label": "Host",
+ "source": "template:valkey",
+ "service": "valkey",
+ "envKey": "REDIS_HOST",
+ "width": "half"
+ },
+ {
+ "id": "port",
+ "label": "Port",
+ "source": "template:6379",
+ "service": "valkey",
+ "envKey": "REDIS_PORT",
+ "width": "half"
},
{
"id": "password",
@@ -105,11 +156,23 @@
}
],
"endpoints": [
+ {
+ "service": "redisinsight",
+ "port": 5540,
+ "label": "Browser UI",
+ "kind": "http",
+ "scope": "public"
+ },
{
"service": "valkey",
"port": 6379,
- "label": "Redis / Valkey",
- "kind": "tcp"
+ "label": "Redis / Valkey (private)",
+ "kind": "tcp",
+ "scope": "internal",
+ "defaultMode": "internal",
+ "allowedModes": [
+ "internal"
+ ]
}
]
}
diff --git a/packages/core/src/apps/catalog/stirling-pdf.json b/packages/core/src/apps/catalog/stirling-pdf.json
index ee1325b06..e9f83f1cc 100644
--- a/packages/core/src/apps/catalog/stirling-pdf.json
+++ b/packages/core/src/apps/catalog/stirling-pdf.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "stirling-pdf",
"name": "Stirling PDF",
- "description": "Split, merge, convert, OCR and edit PDFs locally. Default login is admin / stirling — change it.",
+ "description": "Split, merge, convert, OCR and edit PDFs locally. Sign in with the admin account below.",
"kind": "template",
"logo": "stirling-pdf",
"category": "other",
@@ -16,16 +16,67 @@
{
"name": "stirling-pdf",
"image": "stirlingtools/stirling-pdf:latest",
- "ports": [
- "8080:8080"
- ],
"exposedPort": 8080,
"exposed": true,
"volumes": [
"stirling_config:/configs",
"stirling_tessdata:/usr/share/tessdata"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "environment": {
+ "SECURITY_ENABLELOGIN": "true",
+ "SECURITY_INITIALLOGIN_USERNAME": "admin"
+ },
+ "secretEnv": [
+ "SECURITY_INITIALLOGIN_PASSWORD"
+ ],
+ "ports": [
+ "8209:8080"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "SECURITY_INITIALLOGIN_PASSWORD",
+ "service": "stirling-pdf",
+ "label": "Admin password",
+ "help": "Auto-generated. Replaces Stirling's public default password.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Sign in to Stirling PDF",
+ "description": "Login is enabled and the admin account below is seeded on first boot, so the well-known default password is never used.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Stirling PDF",
+ "source": "publicUrl:stirling-pdf",
+ "kind": "url",
+ "recommended": true,
+ "help": "Sign in with the username and password below."
+ },
+ {
+ "id": "username",
+ "label": "Admin username",
+ "source": "env:stirling-pdf:SECURITY_INITIALLOGIN_USERNAME",
+ "width": "half"
+ },
+ {
+ "id": "password",
+ "label": "Admin password",
+ "source": "env:stirling-pdf:SECURITY_INITIALLOGIN_PASSWORD",
+ "secret": true,
+ "width": "half"
+ }
+ ],
+ "firstLogin": {
+ "note": "Seeding these two values is what prevents Stirling from creating its documented admin/stirling account. The password above works as-is."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/umami.json b/packages/core/src/apps/catalog/umami.json
index 1801c7803..fa119f850 100644
--- a/packages/core/src/apps/catalog/umami.json
+++ b/packages/core/src/apps/catalog/umami.json
@@ -20,9 +20,6 @@
"image": "ghcr.io/umami-software/umami:postgresql-v2.19.0",
"exposedPort": 3000,
"exposed": true,
- "ports": [
- "3000:3000"
- ],
"routes": [
{
"port": 3000
@@ -48,7 +45,10 @@
"retries": 10,
"startPeriod": "30s"
},
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "3011:3000"
+ ]
},
{
"name": "umami-db",
diff --git a/packages/core/src/apps/catalog/uptime-kuma.json b/packages/core/src/apps/catalog/uptime-kuma.json
index 2f29fa9a4..11f489ac5 100644
--- a/packages/core/src/apps/catalog/uptime-kuma.json
+++ b/packages/core/src/apps/catalog/uptime-kuma.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "uptime-kuma",
"name": "Uptime Kuma",
- "description": "Self-hosted uptime monitoring with status pages and alerts.",
+ "description": "Self-hosted uptime monitoring with status pages and alerts. Create your admin account on the first visit.",
"kind": "template",
"logo": "uptime-kuma",
"category": "other",
@@ -16,15 +16,36 @@
{
"name": "uptime-kuma",
"image": "louislam/uptime-kuma:1",
- "ports": [
- "3001:3001"
- ],
"exposedPort": 3001,
"exposed": true,
"volumes": [
"uptime_kuma_data:/app/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "ports": [
+ "3008:3001"
+ ]
+ }
+ ],
+ "connection": {
+ "title": "Set up Uptime Kuma",
+ "description": "Uptime Kuma ships with no default login — the first page you see creates the admin account.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "url",
+ "label": "Uptime Kuma",
+ "source": "publicUrl:uptime-kuma",
+ "kind": "url",
+ "recommended": true,
+ "help": "First visit prompts you to create the administrator account."
+ }
+ ],
+ "firstLogin": {
+ "note": "Do this immediately after deploy: until you create the admin, anyone who opens this URL can claim the instance. Monitors and history live in the uptime_kuma_data volume."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/catalog/vaultwarden.json b/packages/core/src/apps/catalog/vaultwarden.json
index 7a1af5bde..db6c4c78a 100644
--- a/packages/core/src/apps/catalog/vaultwarden.json
+++ b/packages/core/src/apps/catalog/vaultwarden.json
@@ -1,8 +1,8 @@
{
- "available": false,
+ "available": true,
"id": "vaultwarden",
"name": "Vaultwarden",
- "description": "Lightweight self-hosted password manager (Bitwarden-compatible).",
+ "description": "Lightweight self-hosted password manager (Bitwarden-compatible). Open the admin panel with the token below to invite your first account.",
"kind": "template",
"logo": "vaultwarden",
"category": "other",
@@ -16,18 +16,67 @@
{
"name": "vaultwarden",
"image": "vaultwarden/server:latest",
- "ports": [
- "80:80"
- ],
"exposedPort": 80,
"exposed": true,
"environment": {
- "DOMAIN": "{{publicUrl:vaultwarden}}"
+ "DOMAIN": "{{publicUrl:vaultwarden}}",
+ "SIGNUPS_ALLOWED": "false"
},
"volumes": [
"vaultwarden_data:/data"
],
- "restart": "unless-stopped"
+ "restart": "unless-stopped",
+ "secretEnv": [
+ "ADMIN_TOKEN"
+ ],
+ "ports": [
+ "8206:80"
+ ]
+ }
+ ],
+ "configFields": [
+ {
+ "key": "ADMIN_TOKEN",
+ "service": "vaultwarden",
+ "label": "Admin panel token",
+ "help": "Auto-generated. Unlocks /admin, where you invite your own account.",
+ "generate": "secret",
+ "secret": true
+ }
+ ],
+ "connection": {
+ "title": "Set up Vaultwarden",
+ "description": "Public sign-up is disabled. Open the admin panel with the token below, invite your own email address, then register that address in any Bitwarden client.",
+ "guide": {
+ "defaultMode": "public"
+ },
+ "outputs": [
+ {
+ "id": "admin",
+ "label": "Admin panel",
+ "source": "template:{{env:vaultwarden:DOMAIN}}/admin",
+ "kind": "url",
+ "recommended": true,
+ "help": "Paste the admin token below to sign in, then use Invite User."
+ },
+ {
+ "id": "url",
+ "label": "Vault URL",
+ "source": "publicUrl:vaultwarden",
+ "kind": "url",
+ "help": "Point the Bitwarden app/extension at this as its self-hosted server URL."
+ },
+ {
+ "id": "adminToken",
+ "label": "Admin token",
+ "source": "env:vaultwarden:ADMIN_TOKEN",
+ "secret": true,
+ "help": "Full control of the server. Treat it like a root password."
+ }
+ ],
+ "firstLogin": {
+ "note": "Invitations work without SMTP: after inviting your address in /admin, register that same address from the vault URL to set your master password."
}
- ]
+ },
+ "verified": true
}
diff --git a/packages/core/src/apps/install-copy.test.ts b/packages/core/src/apps/install-copy.test.ts
index 31a7c75ce..8ff67574f 100644
--- a/packages/core/src/apps/install-copy.test.ts
+++ b/packages/core/src/apps/install-copy.test.ts
@@ -92,19 +92,53 @@ describe("inline service build context is additive (no schemaVersion bump)", ()
});
});
-describe("bundled catalog carries a real inline-build app", () => {
- it("Neon self-hosts: its compute node is a built service (build, no image)", () => {
+describe("Neon ships as one bundle with a console AND a connection", () => {
+ // No bundled app uses an inline build any more (Neon was the last, and it is
+ // now a single prebuilt control-plane container). The build path stays covered
+ // by the synthetic template above; what has to stay pinned here is that Neon
+ // is not headless — the whole point of the app is that it hands back a UI to
+ // open and a Postgres URL to connect with.
+ it("is a single service that routes its console", () => {
const neon = getAppTemplate("neon");
expect(neon).toBeDefined();
expect(neon!.kind).toBe("template");
expect(neon!.hosting).toBe("experimental");
- const compute = (neon!.services ?? []).find((s) => s.name === "compute");
- expect(compute).toBeDefined();
- expect(compute!.image).toBeUndefined();
- expect(compute!.build?.dockerfile).toContain("compute-node-v16");
- // The build context ships compute.sh, COPY'd under the service subdir.
- expect(compute!.build?.files?.some((f) => f.path === "compute.sh")).toBe(true);
- expect(compute!.build?.dockerfile).toContain("COPY compute/compute.sh");
+ expect(neon!.services).toHaveLength(1);
+ const svc = neon!.services![0];
+ expect(svc.name).toBe("neond");
+ expect(svc.image).toContain("neond/neond:");
+ expect(svc.exposed).toBe(true);
+ expect(svc.routes?.some((r) => r.port === 3000)).toBe(true);
+ });
+
+ it("survives a redeploy: a clean shutdown releases the boot lock", () => {
+ // neond's boot lease is a lockfile, not an flock, so a SIGKILL at Docker's
+ // 10s default leaves it behind and every later boot refuses to start.
+ const svc = getAppTemplate("neon")!.services![0];
+ expect(svc.stopGracePeriod).toBeTruthy();
+ });
+
+ it("publishes both a console URL and a usable database URL", () => {
+ const outputs = getAppTemplate("neon")!.connection?.outputs ?? [];
+ const console_ = outputs.find((o) => o.id === "console");
+ expect(console_?.source).toBe("publicUrl:neond");
+ expect(console_?.kind).toBe("url");
+ const db = outputs.find((o) => o.id === "dbUrl");
+ expect(db?.secret).toBe(true);
+ // The endpoint port is assigned at runtime, so the URL is only correct if it
+ // reads the port the bootstrap step captured rather than hardcoding one.
+ expect(db?.source).toContain("{{env:neond:NEOND_PG_PORT}}");
+ });
+
+ it("bootstraps the admin account so the console is not an empty signup form", () => {
+ const step = (getAppTemplate("neon")!.prepare ?? []).find((p) => p.capture === "pgPort");
+ expect(step).toBeDefined();
+ expect(step!.service).toBe("neond");
+ expect(step!.phase).toBe("post-ready");
+ // Registration is only open while zero users exist, so this must not fail a
+ // redeploy once it has already run.
+ expect(step!.mustSucceed).toBeFalsy();
+ expect(step!.persistAs?.key).toBe("NEOND_PG_PORT");
});
});
diff --git a/packages/core/src/apps/schema.ts b/packages/core/src/apps/schema.ts
index e1488fe2a..f5b24731e 100644
--- a/packages/core/src/apps/schema.ts
+++ b/packages/core/src/apps/schema.ts
@@ -79,6 +79,29 @@ const serviceSpec = z.object({
healthcheck: z.unknown().optional(),
restart: z.enum(["no", "always", "on-failure", "unless-stopped"]).optional(),
command: z.string().optional(),
+ /**
+ * Structured argv, passed through as the container Cmd with NO `sh -c` wrap.
+ * Wins over `command` when both are set.
+ *
+ * `command` is convenient but it is a SHELL string, so the container's argv
+ * becomes ["sh","-c",cmd] — and an image whose entrypoint rewrites argv rather
+ * than `exec "$@"` then sees the wrong thing. MinIO is the worked example: its
+ * entrypoint prepends `minio` unless argv[0] already is, so a `command` turned
+ * into `minio sh -c "server /data"` and the container exited with "'sh' is not
+ * a minio sub-command" on every boot. There is no command string that fixes
+ * that; the argv has to arrive unwrapped. Use `command` when you need a shell
+ * (`a && b`), `commandArgv` when the image needs exact argv.
+ */
+ commandArgv: z.array(z.string()).optional(),
+ /**
+ * How long Docker waits after SIGTERM before SIGKILL (compose duration, e.g.
+ * "10m"). The engine has always honored `advanced.stopGracePeriod`; only the
+ * template could not ask for it, which silently capped every app at Docker's
+ * 10s default. That is not a tuning knob for an app whose clean shutdown does
+ * a final checkpoint and whose boot lease is a lockfile — being killed
+ * mid-checkpoint leaves the lock behind and the next boot refuses to start.
+ */
+ stopGracePeriod: z.string().optional(),
});
const configField = z.object({
diff --git a/packages/core/src/audit-taxonomy.ts b/packages/core/src/audit-taxonomy.ts
index 288935b33..6dd7d230c 100644
--- a/packages/core/src/audit-taxonomy.ts
+++ b/packages/core/src/audit-taxonomy.ts
@@ -52,6 +52,11 @@ export const AUDIT_CATEGORIES = [
label: "Members & access",
description: "Who is in this organization and what they are allowed to do.",
},
+ {
+ id: "agent",
+ label: "AI agents",
+ description: "What connected assistants did over MCP — every tool call, and the scope they hold.",
+ },
{
id: "security",
label: "Security",
@@ -412,6 +417,16 @@ export const AUDIT_EVENTS: Record = {
label: "Mail server admin action",
tone: "warning",
},
+ // Catalogued because the taxonomy scan is deliberately over-inclusive: it greps
+ // apps/api/src for `eventType:` literals, so a notification-only emit that never
+ // writes an audit_event row is caught the same as an audit write. Without this the
+ // suite fails; with it, an operator who DOES surface these sees a real label.
+ "mail.inbound_received": {
+ category: "servers",
+ action: "received inbound mail matching an inbound rule on",
+ label: "Inbound email received",
+ tone: "info",
+ },
/* ---------------- Members & access ---------------- */
"organization.created": {
@@ -535,20 +550,35 @@ export const AUDIT_EVENTS: Record = {
description: "An existing grant was overwritten with a different scope.",
},
"mcp.authorized": {
- category: "members",
+ category: "agent",
action: "authorized the MCP client",
label: "MCP client authorized",
tone: "info",
description: "An AI agent was connected and given a scope to act within.",
},
"mcp.scope_changed": {
- category: "members",
+ category: "agent",
action: "changed the access of the MCP client",
label: "MCP access changed",
tone: "warning",
description:
"A connected agent's scope was edited. It takes effect on the agent's next request — no reconnect.",
},
+ "mcp.disconnected": {
+ category: "agent",
+ action: "disconnected the MCP client",
+ label: "MCP client disconnected",
+ tone: "warning",
+ description:
+ "A connected agent's tokens, consent and scope were torn down. It stops working immediately and must re-consent to return.",
+ },
+ "mcp.tool_called": {
+ category: "agent",
+ action: "ran the MCP tool",
+ label: "Agent tool call",
+ description:
+ "A tool call that left no other trace: a read, or an attempt that was refused or errored. A tool call that successfully changed something is recorded as the change itself, so it is not duplicated here.",
+ },
"grant.materialized": {
category: "members",
action: "received their pending permissions,",
@@ -912,6 +942,7 @@ export const AUDIT_RESOURCE_LABELS: Record = {
backup_run: "a backup",
backup_restore: "a restore",
incoming_webhook: "a webhook",
+ mcp_client: "a connected AI agent",
billing: "billing",
cloud: "Openship Cloud",
settings: "settings",
diff --git a/packages/core/src/host-channel.ts b/packages/core/src/host-channel.ts
index 5c5b037a0..d202eac67 100644
--- a/packages/core/src/host-channel.ts
+++ b/packages/core/src/host-channel.ts
@@ -86,6 +86,78 @@ export const HOST_CHANNEL_UNPROVISIONED =
"Openship is running in a container with no host channel — OPENSHIP_HOST_SSH_HOST is " +
"unset, so there is no address to reach the host at and nothing has been dialed.";
+/**
+ * The account the container→host channel logs in as, when nothing has been provisioned.
+ *
+ * `root` because that is what `chooseHostChannelUser` authorizes whenever it can reach it
+ * — the platform's host operations are root-owned by contract (see `stateDir()` in
+ * adapters/system/environment-ops). It is a DEFAULT, not a requirement: the value is
+ * whatever provisioning wrote, and this is only the answer for an install that has not
+ * written one.
+ */
+export const HOST_CHANNEL_DEFAULT_ACCOUNT = "root";
+
+/**
+ * THE host-channel account resolver.
+ *
+ * One function because this expression was spelled independently in five places — the
+ * adapters' dial (`hostChannelUser`), the api's self-server row (`desiredSshUser`), and
+ * three spots in the CLI's compose layer — and #527 is what that costs. The row rendered
+ * `admin@` from one copy while the dial used `root@host.docker.internal` from another,
+ * so the operator was shown an account the channel was not using, went to correct it, and
+ * corrected nothing.
+ *
+ * The row and the dial cannot disagree if they cannot spell it separately. Takes the
+ * environment as an argument rather than reading `process.env` because its callers read
+ * from two different sources: a live process (adapters, api) and a parsed `.env` file on
+ * disk (the CLI, deciding what to write).
+ */
+export function hostChannelAccount(env: {
+ OPENSHIP_HOST_SSH_USER?: string | undefined;
+}): string {
+ return env.OPENSHIP_HOST_SSH_USER?.trim() || HOST_CHANNEL_DEFAULT_ACCOUNT;
+}
+
+/**
+ * Why a channel that ANSWERS is still not working: sshd took the connection and then
+ * refused the key.
+ *
+ * Its own state and its own copy because of #527. The port was open, so the health
+ * check called the channel healthy, and the first thing the operator saw was a generic
+ * "SSH credentials rejected" card on the This Server row — a row whose stored
+ * credentials this channel never uses. They spent a dozen messages moving key files
+ * between /tmp, /root and ~/.ssh, none of which anything reads.
+ *
+ * Both causes are named because the remedies differ and neither is observable from
+ * inside the container: we can read neither the host's sshd_config nor the target
+ * account's authorized_keys. Naming only the first sends every hardened host to audit
+ * a file that was already correct — the same mistake #490 made with firewalls.
+ */
+export const HOST_CHANNEL_AUTH_REJECTED =
+ "The host accepted the connection and then refused Openship's key. Either the key is " +
+ "no longer in the target account's `authorized_keys`, or sshd does not permit that " +
+ "account to log in at all (for a root channel, check `sshd -T | grep -i permitrootlogin`).";
+
+/**
+ * The same fault in one line, for a surface with a column rather than a paragraph — a
+ * `openship doctor` row. Shared rather than re-worded there, because a doctor row that
+ * disagrees with the banner about the same probe is worse than no row.
+ */
+export const HOST_CHANNEL_AUTH_REJECTED_SHORT =
+ "the host refused the channel key — re-authorize it, or permit that account to log in";
+
+/**
+ * The sentence that stops the credential hunt.
+ *
+ * Every surface that can show an auth failure ON the local row needs it, because the
+ * row displays `user@host` and offers an edit form, and both are lies for this
+ * connection. Said once, here, so the banner, the deploy-log throw and the doctor row
+ * cannot word it differently.
+ */
+export const HOST_CHANNEL_ROW_CREDENTIALS_UNUSED =
+ "The SSH credentials stored on this server are not used for this connection — the " +
+ `host channel has its own key, provisioned by \`${HOST_CHANNEL_PROVISION_COMMAND}\`.`;
+
/**
* The opt-out, stated as what it is.
*
diff --git a/packages/core/src/mail-server/routing/build-routes.ts b/packages/core/src/mail-server/routing/build-routes.ts
index 714690302..ee595f407 100644
--- a/packages/core/src/mail-server/routing/build-routes.ts
+++ b/packages/core/src/mail-server/routing/build-routes.ts
@@ -48,8 +48,65 @@ export function buildMailServerRoutes(input: MailServerRouteInput): MailServerRo
* mail routes as orphans.
*/
export function mailServerRouteHostnames(userDomain: string): string[] {
- const d = userDomain;
- return [`mail.${d}`, `api.mail.${d}`, `autodiscover.${d}`];
+ return [
+ mailHostname(userDomain),
+ apiMailHostname(userDomain),
+ autodiscoverHostname(userDomain),
+ ];
+}
+
+/**
+ * The one definition of the mail host's label.
+ *
+ * It exists as a constant because the convention is needed in BOTH directions —
+ * building `mail.` and recognising it — and a second literal is how those
+ * two silently disagree. Everything below derives from this.
+ */
+export const MAIL_HOST_LABEL = "mail";
+
+/**
+ * The mail server's own hostname: IMAP, SMTP, the webmail UI, and the certificate
+ * all live here.
+ *
+ * Use this instead of writing the template inline. That is not style: the label was
+ * hand-written in ~40 places across the api, the dashboard, the adapters and the Zero
+ * server, so "change the mail hostname" was a find-and-replace across four packages
+ * with no way to know you had them all — and one of those sites PARSED it back off a
+ * hostname, which fails silently the moment the prefix moves.
+ */
+export function mailHostname(userDomain: string): string {
+ return `${MAIL_HOST_LABEL}.${userDomain}`;
+}
+
+/** The Zero server's tRPC API host, fronted alongside the webmail UI. */
+export function apiMailHostname(userDomain: string): string {
+ return `api.${mailHostname(userDomain)}`;
+}
+
+/** Autodiscover, for clients that look for it by convention. */
+export function autodiscoverHostname(userDomain: string): string {
+ return `autodiscover.${userDomain}`;
+}
+
+/**
+ * The inverse of {@link mailHostname}: `mail.example.com` → `example.com`, and null
+ * for anything that is not a mail host.
+ *
+ * Deriving it from the SAME constant is the whole point — a caller that needs to ask
+ * "which install does this hostname belong to?" must not carry its own `/^mail\./`
+ * regex, because then the build and the parse are two facts that can drift apart.
+ *
+ * Exact-label match, not a substring: `mailbox.example.com` is not a mail host, and
+ * neither is a bare `mail.com` reading as base `com`.
+ */
+export function mailHostBaseDomain(hostname: string): string | null {
+ const host = hostname.trim().toLowerCase();
+ const prefix = `${MAIL_HOST_LABEL}.`;
+ if (!host.startsWith(prefix)) return null;
+ const base = host.slice(prefix.length);
+ // A base still needs to be a domain — `mail.com` strips to `com`, which is a TLD and
+ // never an install's user domain.
+ return base.includes(".") ? base : null;
}
function buildRoutes(input: MailServerRouteInput): MailRoute[] {
@@ -157,17 +214,17 @@ function buildDnsRecords(input: MailServerRouteInput): MailDnsRecord[] {
{
id: "mail-client-cname",
type: "CNAME",
- name: `mail.${d}`,
+ name: mailHostname(d),
value: hostnameFromUrl(input.zeroClientOrigin),
- description: `Routes mail.${d} (the webmail UI) to openship's app-deploy ingress where the Zero client is hosted.`,
+ description: `Routes ${mailHostname(d)} (the webmail UI) to openship's app-deploy ingress where the Zero client is hosted.`,
required: true,
},
{
id: "mail-api-cname",
type: "CNAME",
- name: `api.mail.${d}`,
+ name: apiMailHostname(d),
value: hostnameFromUrl(input.zeroServerOrigin),
- description: `Routes api.mail.${d} (the Zero server's tRPC API) to the mail VPS via openship's routing layer.`,
+ description: `Routes ${apiMailHostname(d)} (the Zero server's tRPC API) to the mail VPS via openship's routing layer.`,
required: true,
},
// No email-admin CNAME - admin operations run inside openship's own API
diff --git a/packages/core/src/mail-server/routing/index.ts b/packages/core/src/mail-server/routing/index.ts
index e582287e1..c900a76f4 100644
--- a/packages/core/src/mail-server/routing/index.ts
+++ b/packages/core/src/mail-server/routing/index.ts
@@ -11,4 +11,12 @@
*/
export * from "./types";
-export { buildMailServerRoutes, mailServerRouteHostnames } from "./build-routes";
+export {
+ buildMailServerRoutes,
+ mailServerRouteHostnames,
+ MAIL_HOST_LABEL,
+ mailHostname,
+ apiMailHostname,
+ autodiscoverHostname,
+ mailHostBaseDomain,
+} from "./build-routes";
diff --git a/packages/core/src/shell-split.ts b/packages/core/src/shell-split.ts
index d48ffd7e1..979faa1b0 100644
--- a/packages/core/src/shell-split.ts
+++ b/packages/core/src/shell-split.ts
@@ -87,3 +87,54 @@ export function commandToArgv(
if (Array.isArray(command)) return command.map((part) => String(part));
return shellSplitWords(command);
}
+
+export interface ResolveCommandArgvInput {
+ /** Argv the writer supplied explicitly. `undefined` = didn't mention it. */
+ incomingArgv?: string[] | null;
+ /** Text command the writer supplied. `undefined` = didn't mention it. */
+ incomingCommand?: string | null;
+ /** The stored row's text command. */
+ storedCommand?: string | null;
+ /** The stored row's argv. */
+ storedArgv?: string[] | null;
+}
+
+/**
+ * Decide the `commandArgv` to persist next to an incoming text `command`.
+ *
+ * The runtime prefers `commandArgv` and only falls back to `["sh","-c",command]`
+ * when it's null (resolveComposeCmd), so a writer that sets `command` alone leaves
+ * the row in one of two wrong states: a stale argv keeps running the OLD command,
+ * or a null argv resurrects the `sh -c` wrap that breaks entrypoint+CMD images
+ * (#332). Every writer that accepts a text command resolves argv through here.
+ *
+ * The subtlety is that a row's `command` is a LOSSY display join for a list
+ * command — compose `["sh","-c","a && b"]` is stored as `sh -c a && b`, which
+ * re-splits into five words. Several wire shapes carry only the string, and
+ * clients echo it back on unrelated writes (the service form posts every field it
+ * owns; a deploy request replays its service list), so re-deriving unconditionally
+ * would corrupt a correct argv. Hence: an UNCHANGED command string never disturbs
+ * the stored argv, and only a real edit re-derives.
+ *
+ * Returns `undefined` when the caller should not write the column at all.
+ */
+export function resolveCommandArgv(
+ input: ResolveCommandArgvInput,
+): string[] | null | undefined {
+ const { incomingArgv, incomingCommand, storedCommand, storedArgv } = input;
+
+ // Explicit argv always wins — the writer speaks the runtime's own language.
+ if (incomingArgv !== undefined) return incomingArgv;
+
+ // Command not mentioned → leave the column alone.
+ if (incomingCommand === undefined) return undefined;
+
+ // Unchanged string → keep the stored argv. This is what protects a list command
+ // from being re-split out of its lossy display join.
+ const nextCommand = incomingCommand?.trim() || null;
+ const priorCommand = storedCommand?.trim() || null;
+ if (storedArgv != null && nextCommand === priorCommand) return storedArgv;
+
+ // A real edit (or a first write): split the way docker-compose does — no `sh -c`.
+ return commandToArgv(nextCommand);
+}
diff --git a/packages/core/test/audit-taxonomy.test.ts b/packages/core/test/audit-taxonomy.test.ts
index c3794f58b..8040e8648 100644
--- a/packages/core/test/audit-taxonomy.test.ts
+++ b/packages/core/test/audit-taxonomy.test.ts
@@ -107,12 +107,15 @@ describe("audit categories", () => {
// These travel in URLs (?category=deployments) and are sent to the API as a
// filter, so a rename breaks saved links and every bookmarked view. Labels
// above them are free to change — that's the point of the split.
+ // Adding an id is fine (and is why this list grows); RENAMING one is the
+ // breaking change this pins down.
expect(CATEGORY_IDS).toEqual([
"deployments",
"apps",
"domains",
"servers",
"members",
+ "agent",
"security",
"billing",
"system",
diff --git a/packages/core/test/host-channel-account-single-source.test.ts b/packages/core/test/host-channel-account-single-source.test.ts
new file mode 100644
index 000000000..e3e1c9e22
--- /dev/null
+++ b/packages/core/test/host-channel-account-single-source.test.ts
@@ -0,0 +1,52 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+/**
+ * The row and the dial cannot disagree if they cannot spell the account separately.
+ *
+ * #527's visible symptom was a server row rendering `admin@` while the channel dialed
+ * `root@host.docker.internal`. The cause was not the default — it was that five files each
+ * carried their own `OPENSHIP_HOST_SSH_USER?.trim() || "root"`, so "the account" was five
+ * facts that happened to agree until one of them didn't. Consolidating them fixes today;
+ * this test is what stops the sixth copy, which would reopen the same class silently.
+ *
+ * Enforced over source text rather than behaviour because that is precisely the failure
+ * mode: a re-introduced copy would return the right answer in every test while still being
+ * a second source of truth. There is nothing to observe until it drifts.
+ */
+describe("hostChannelAccount is the only place that spells the channel-account fallback", () => {
+ const REPO = join(__dirname, "../../..");
+
+ /** Every file that legitimately needs the account, plus the resolver's own home. */
+ const CONSUMERS = [
+ "packages/adapters/src/system/executor.ts",
+ "apps/api/src/lib/startup/self-server.ts",
+ "apps/cli/src/lib/compose.ts",
+ ];
+
+ /**
+ * The expression in any of its spellings: `|| "root"` / `?? "root"` applied to the env
+ * var, with or without `.trim()`, single or double quoted.
+ */
+ const LOCAL_COPY = /OPENSHIP_HOST_SSH_USER[^\n;]*(\|\||\?\?)\s*["']root["']/;
+
+ it.each(CONSUMERS)("%s reads the account through the resolver, not a local copy", (rel) => {
+ const src = readFileSync(join(REPO, rel), "utf8");
+ // Guards against a vacuous pass: if the file stops mentioning the account at all, this
+ // list is stale and the test is no longer checking anything.
+ expect(src, `${rel} no longer mentions the channel account — update CONSUMERS`).toContain(
+ "hostChannelAccount",
+ );
+ expect(src).not.toMatch(LOCAL_COPY);
+ });
+
+ it("catches the pattern it claims to catch", () => {
+ // The regex is the whole test; a typo in it would make every case above pass forever.
+ expect('process.env.OPENSHIP_HOST_SSH_USER?.trim() || "root"').toMatch(LOCAL_COPY);
+ expect("prev.OPENSHIP_HOST_SSH_USER?.trim() || 'root'").toMatch(LOCAL_COPY);
+ expect('env.OPENSHIP_HOST_SSH_USER ?? "root"').toMatch(LOCAL_COPY);
+ expect("hostChannelAccount(process.env)").not.toMatch(LOCAL_COPY);
+ });
+});
diff --git a/packages/core/test/host-channel.test.ts b/packages/core/test/host-channel.test.ts
index 01aead67e..09e9479be 100644
--- a/packages/core/test/host-channel.test.ts
+++ b/packages/core/test/host-channel.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
explainHostChannelCause,
+ hostChannelAccount,
+ HOST_CHANNEL_DEFAULT_ACCOUNT,
hostFirewallRule,
hostFirewallRuleRemoval,
SSHD_ENABLE_HINT_UNKNOWN,
@@ -201,3 +203,45 @@ describe("the command that starts sshd is this host's, not Debian's", () => {
expect(body).toMatch(/sshd/);
});
});
+
+/**
+ * One resolver, because five copies is what #527 was made of.
+ *
+ * The account was spelled independently in the adapters' dial (`hostChannelUser`), the
+ * api's self-server row (`desiredSshUser`), and three places in the CLI's compose layer.
+ * Nothing kept them equal, and the row is display-only — so when they diverged, the
+ * dashboard showed `admin@` while the dial used `root@host.docker.internal`, and the
+ * operator "corrected" an account the channel was never going to use.
+ *
+ * These tests pin the resolver's contract. The ratchet below pins the harder half: that
+ * nobody re-introduces a local copy of it.
+ */
+describe("hostChannelAccount — the one place the channel account is decided", () => {
+ it("uses what provisioning wrote", () => {
+ expect(hostChannelAccount({ OPENSHIP_HOST_SSH_USER: "deploy" })).toBe("deploy");
+ });
+
+ it("defaults to root when nothing was provisioned", () => {
+ expect(hostChannelAccount({})).toBe(HOST_CHANNEL_DEFAULT_ACCOUNT);
+ expect(hostChannelAccount({ OPENSHIP_HOST_SSH_USER: undefined })).toBe("root");
+ });
+
+ it("treats a blank or whitespace value as unset, not as an empty username", () => {
+ // `.env` round-trips can leave `OPENSHIP_HOST_SSH_USER=`; dialing "" fails with an
+ // sshd error that names no account at all.
+ expect(hostChannelAccount({ OPENSHIP_HOST_SSH_USER: "" })).toBe("root");
+ expect(hostChannelAccount({ OPENSHIP_HOST_SSH_USER: " " })).toBe("root");
+ });
+
+ it("trims, so a stray newline from a written .env cannot become part of the account", () => {
+ expect(hostChannelAccount({ OPENSHIP_HOST_SSH_USER: " deploy\n" })).toBe("deploy");
+ });
+
+ it("answers identically for a live process env and a parsed .env record", () => {
+ // The two callers read from different sources; the whole point is that the source
+ // cannot change the answer.
+ const parsed: Record = { OPENSHIP_HOST_SSH_USER: "deploy" };
+ const live: NodeJS.ProcessEnv = { OPENSHIP_HOST_SSH_USER: "deploy" };
+ expect(hostChannelAccount(parsed)).toBe(hostChannelAccount(live));
+ });
+});
diff --git a/packages/core/test/resolve-command-argv.test.ts b/packages/core/test/resolve-command-argv.test.ts
new file mode 100644
index 000000000..49ad69d74
--- /dev/null
+++ b/packages/core/test/resolve-command-argv.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect } from "vitest";
+import { resolveCommandArgv } from "../src/shell-split";
+
+/**
+ * #332 follow-through. The parser produced argv, but the WRITERS didn't: every
+ * path that takes a text `command` (service form, PATCH, sync endpoint, the deploy
+ * request's own service list) left `commandArgv` alone, so a compose-imported row
+ * kept running its old argv while the UI showed the new string, and a hand-created
+ * row fell back to the `sh -c` wrap that breaks entrypoint+CMD images.
+ */
+describe("resolveCommandArgv", () => {
+ it("derives argv from a first-write command (no sh -c)", () => {
+ expect(resolveCommandArgv({ incomingCommand: "server start" })).toEqual([
+ "server",
+ "start",
+ ]);
+ });
+
+ it("re-derives when the command is EDITED — the stale argv must not survive", () => {
+ expect(
+ resolveCommandArgv({
+ incomingCommand: "server start --verbose",
+ storedCommand: "server start",
+ storedArgv: ["server", "start"],
+ }),
+ ).toEqual(["server", "start", "--verbose"]);
+ });
+
+ it("keeps the stored argv when the echoed-back command is UNCHANGED", () => {
+ // The service form posts every field it owns on any save, so a restart-policy
+ // edit re-sends the command verbatim. Re-splitting the display join here would
+ // corrupt a list command (see next case).
+ expect(
+ resolveCommandArgv({
+ incomingCommand: "sh -c a && b",
+ storedCommand: "sh -c a && b",
+ storedArgv: ["sh", "-c", "a && b"],
+ }),
+ ).toEqual(["sh", "-c", "a && b"]);
+ });
+
+ it("shows why that matters: the stored string is a LOSSY join", () => {
+ // What re-deriving from the display join would have produced.
+ expect(resolveCommandArgv({ incomingCommand: "sh -c a && b" })).toEqual([
+ "sh",
+ "-c",
+ "a",
+ "&&",
+ "b",
+ ]);
+ });
+
+ it("treats whitespace-only differences as unchanged", () => {
+ expect(
+ resolveCommandArgv({
+ incomingCommand: " server start ",
+ storedCommand: "server start",
+ storedArgv: ["server", "start"],
+ }),
+ ).toEqual(["server", "start"]);
+ });
+
+ it("lets an explicit argv win over the string, including []", () => {
+ expect(
+ resolveCommandArgv({
+ incomingArgv: ["sh", "-c", "a && b"],
+ incomingCommand: "ignored",
+ storedArgv: ["old"],
+ }),
+ ).toEqual(["sh", "-c", "a && b"]);
+ // `[]` is meaningful: it clears the image CMD.
+ expect(resolveCommandArgv({ incomingArgv: [] })).toEqual([]);
+ // An explicit null clears the override.
+ expect(resolveCommandArgv({ incomingArgv: null, storedArgv: ["old"] })).toBeNull();
+ });
+
+ it("returns undefined when the command isn't mentioned — a PATCH of another field", () => {
+ expect(
+ resolveCommandArgv({ storedCommand: "server start", storedArgv: ["server", "start"] }),
+ ).toBeUndefined();
+ });
+
+ it("clears argv when the command is cleared", () => {
+ for (const cleared of ["", " ", null]) {
+ expect(
+ resolveCommandArgv({
+ incomingCommand: cleared,
+ storedCommand: "server start",
+ storedArgv: ["server", "start"],
+ }),
+ ).toBeNull();
+ }
+ });
+
+ it("derives for a legacy row that has a command but no argv", () => {
+ expect(
+ resolveCommandArgv({
+ incomingCommand: "server start",
+ storedCommand: "server start",
+ storedArgv: null,
+ }),
+ ).toEqual(["server", "start"]);
+ });
+});
diff --git a/packages/db/drizzle/0106_mcp_call_tracking.sql b/packages/db/drizzle/0106_mcp_call_tracking.sql
new file mode 100644
index 000000000..629f05402
--- /dev/null
+++ b/packages/db/drizzle/0106_mcp_call_tracking.sql
@@ -0,0 +1,23 @@
+-- MCP observability: attribute an audit row to the CLIENT that made it, and
+-- count how much a credential is used.
+--
+-- `source` already said "an AI assistant did this" (0088), unforgeably — but not
+-- WHICH assistant. With two clients connected under one user (Claude Desktop and
+-- Cursor, say) every row read the same, so "what did this agent do" could not be
+-- answered and disconnecting the wrong one was a coin flip. The column holds the
+-- canonical principal id the auth layer already mints (`oauth:` /
+-- `pat:`), so it resolves to a name through existing tables.
+--
+-- The index is PARTIAL: only MCP rows ever carry a client id, and mcp.tool_called
+-- makes audit_event's highest-volume writer, so indexing the NULLs would be paid
+-- for on every insert in the table for no read.
+ALTER TABLE "audit_event" ADD COLUMN IF NOT EXISTS "source_client_id" text;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "audit_event_org_client_idx"
+ ON "audit_event" ("organization_id", "source_client_id", "created_at" DESC)
+ WHERE "source_client_id" IS NOT NULL;--> statement-breakpoint
+
+-- Call counter for every bearer credential (manual PATs and the OAuth-binding
+-- rows behind MCP connections alike). NOT NULL DEFAULT 0 is safe here: existing
+-- rows genuinely have no counted history, and 0 alongside a non-null
+-- `last_used_at` reads correctly as "used before counting existed".
+ALTER TABLE "personal_access_token" ADD COLUMN IF NOT EXISTS "use_count" integer DEFAULT 0 NOT NULL;
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index be12213d9..4c6c584c7 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -743,6 +743,13 @@
"when": 1788042107325,
"tag": "0105_mail_inbound_rules",
"breakpoints": true
+ },
+ {
+ "idx": 106,
+ "version": "7",
+ "when": 1788128507325,
+ "tag": "0106_mcp_call_tracking",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index b30178710..6b970db28 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from "url";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { PgliteDatabase } from "drizzle-orm/pglite";
import type { Pool } from "pg";
+import { sleep } from "@repo/core";
import * as schema from "./schema";
import { acquirePgliteLock, releasePgliteLock } from "./pglite-lock";
@@ -161,6 +162,107 @@ async function createDb(): Promise {
// ─── PostgreSQL (node-postgres) ──────────────────────────────────────────────
+/**
+ * How long boot waits for Postgres to start answering before it gives up.
+ *
+ * Compose gates the api on `postgres: {condition: service_healthy}`, which covers
+ * every start COMPOSE drives — but not the ones Docker drives on its own. A
+ * `restart: unless-stopped` bounce (host reboot, postgres restarted or OOM-killed,
+ * the daemon coming back) ignores depends_on entirely, and Postgres running its own
+ * crash recovery can be tens of seconds behind us. Without a wait, boot connected
+ * once, threw, and exited — so the api came back as a crash loop whose logs blamed
+ * a DNS failure (Bun reports an unresolvable compose alias as `DNSException
+ * getaddrinfo ECONNREFUSED`) rather than the mundane truth that postgres was late.
+ *
+ * 90s covers crash recovery on a small box without waiting so long that a genuinely
+ * misconfigured DATABASE_URL looks like a hang. `0` opts out (fail on the first miss).
+ *
+ * Parsed defensively: a bare `Number()` turns a typo'd override into NaN, and every
+ * comparison against NaN is false — which would make the loop below wait FOREVER, a
+ * worse failure than the one it exists to fix. An unusable value falls back to the
+ * default rather than changing the shape of boot.
+ */
+function positiveMs(value: unknown, fallback: number): number {
+ const parsed = value === undefined || value === null ? Number.NaN : Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
+}
+
+const PG_READY_BUDGET_MS = positiveMs(process.env.OPENSHIP_DB_CONNECT_TIMEOUT_MS, 90_000);
+const PG_READY_RETRY_MS = 1_000;
+
+/**
+ * Connection failures that no amount of waiting will fix, so boot fails NOW with the
+ * real error instead of after the budget.
+ *
+ * Keeping this list — rather than an allowlist of retryable errors — is deliberate:
+ * this whole wait exists because an error class we hadn't enumerated (Bun labelling a
+ * DNS miss `ECONNREFUSED`) turned a two-second delay into a permanent crash loop. An
+ * unknown error costs one bounded wait and then reports itself; an unknown error we
+ * refuse to retry costs the operator their install.
+ *
+ * 28P01/28000 are the #488 signal (`.env` no longer matches the data volume) and 3D000
+ * is a missing database — each needs an operator, and each must stay fast and legible.
+ */
+const FATAL_PG_CONNECT_CODES = new Set(["28P01", "28000", "3D000"]);
+
+function isFatalConnectError(err: unknown): boolean {
+ const code = (err as { code?: unknown } | null)?.code;
+ return typeof code === "string" && FATAL_PG_CONNECT_CODES.has(code);
+}
+
+/**
+ * Block until the pool hands out a working connection, or the budget runs out.
+ *
+ * Acquire-and-release rather than a query: it exercises DNS, the TCP connect and the
+ * auth handshake — every layer that can be "not ready yet" — and nothing else.
+ *
+ * Not re-exported by the package barrel; `budgetMs`/`retryMs` are overridable so the
+ * tests can drive the budget-exhausted path without sitting through it.
+ */
+export async function awaitPgReady(
+ pool: Pool,
+ opts: { budgetMs?: number; retryMs?: number } = {},
+): Promise {
+ // Normalized through the same guard as the env override: a NaN budget would make
+ // every deadline comparison false and hang boot indefinitely.
+ const budgetMs = positiveMs(opts.budgetMs, PG_READY_BUDGET_MS);
+ const retryMs = positiveMs(opts.retryMs, PG_READY_RETRY_MS);
+ const startedAt = Date.now();
+ let attempt = 0;
+
+ for (;;) {
+ attempt++;
+ try {
+ const client = await pool.connect();
+ client.release();
+ if (attempt > 1) {
+ const waited = Math.round((Date.now() - startedAt) / 1000);
+ console.log(`[db] postgres accepted a connection after ${waited}s (${attempt} attempts)`);
+ }
+ return;
+ } catch (err) {
+ if (isFatalConnectError(err)) throw err;
+
+ const elapsed = Date.now() - startedAt;
+ if (elapsed + retryMs >= budgetMs) {
+ // Re-thrown as-is: the driver's message names the host, port and cause, and
+ // that is what the operator needs to see at the top of the crash.
+ throw err;
+ }
+ // One line on the first miss, then every 10th, so a slow start is visible in
+ // `docker logs` without burying it.
+ if (attempt === 1 || attempt % 10 === 0) {
+ const reason = err instanceof Error ? err.message : String(err);
+ console.warn(
+ `[db] postgres not ready yet (attempt ${attempt}, ${Math.round(elapsed / 1000)}s` +
+ `/${Math.round(budgetMs / 1000)}s): ${reason}`,
+ );
+ }
+ await sleep(retryMs);
+ }
+ }
+}
+
async function createPgClient(url: string): Promise {
_driver = "pg";
const { Pool } = await import("pg");
@@ -171,7 +273,21 @@ async function createPgClient(url: string): Promise {
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
+ // node-postgres re-emits errors raised on IDLE clients on the pool itself, and an
+ // EventEmitter with no 'error' listener THROWS — so a postgres restart under a
+ // long-running api took the whole process down from outside any try/catch, hours
+ // after the boot this file worries about. The pool retires the dead client on its
+ // own; all this has to do is exist. Attached before the first connect so no window
+ // is uncovered.
+ pool.on("error", (err) => {
+ console.warn("[db] idle postgres client error (connection retired):", err.message);
+ });
_pgPool = pool;
+
+ // Before migrate(), which is the first thing to touch the network and so the thing
+ // that used to turn "postgres is 2s late" into an exited process.
+ await awaitPgReady(pool);
+
const db = drizzle(pool, { schema });
// Run pending migrations
diff --git a/packages/db/src/migrate-chain.test.ts b/packages/db/src/migrate-chain.test.ts
new file mode 100644
index 000000000..dac4a457a
--- /dev/null
+++ b/packages/db/src/migrate-chain.test.ts
@@ -0,0 +1,304 @@
+import { describe, expect, test } from "vitest";
+import { copyFileSync, cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { fileURLToPath } from "node:url";
+import { dirname, join, resolve } from "node:path";
+import { PGlite } from "@electric-sql/pglite";
+import { drizzle } from "drizzle-orm/pglite";
+import { migrate } from "drizzle-orm/pglite/migrator";
+import * as schema from "./schema";
+
+// Does the migration chain actually APPLY to a database that already exists and
+// already holds rows?
+//
+// Everything else in the repo answers a weaker question. Every other suite calls
+// migrate() against an empty PGlite, which proves the chain applies to NOTHING —
+// and `migrations-additive.test.ts` reads the SQL as text, so it can only catch the
+// one pattern it greps for. Neither sees an ALTER that fails on a populated table, a
+// unique index an existing row violates, or a backfill whose assumption doesn't hold.
+// That is the entire class of "the update crash-looped on migrations", and until this
+// file it had no coverage anywhere, in CI or out.
+//
+// Cheap enough to run on every PR because PGlite is in-process: no daemon, no
+// container, no ports (the same reason the repo's other real-SQL tests use it).
+
+const MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
+
+type JournalEntry = {
+ idx: number;
+ version: string;
+ when: number;
+ tag: string;
+ breakpoints: boolean;
+};
+type Journal = { version: string; dialect: string; entries: JournalEntry[] };
+
+function readJournal(): Journal {
+ return JSON.parse(readFileSync(join(MIGRATIONS_DIR, "meta", "_journal.json"), "utf8")) as Journal;
+}
+
+/**
+ * A migrations folder holding only the first `count` entries — i.e. the database as
+ * an older release left it.
+ *
+ * This is a real upgrade and not an approximation of one: drizzle records each applied
+ * migration's `when` in `drizzle.__drizzle_migrations` and on the next run applies
+ * exactly those the journal lists after it. Pointing it at a truncated journal, then at
+ * the real one, is the same two-step an operator's box performs across an update.
+ */
+function migrationsPrefix(count: number): string {
+ const journal = readJournal();
+ const entries = journal.entries.slice(0, count);
+ const dir = mkdtempSync(join(tmpdir(), "osh-migrate-chain-"));
+ mkdirSync(join(dir, "meta"), { recursive: true });
+ for (const entry of entries) {
+ copyFileSync(join(MIGRATIONS_DIR, `${entry.tag}.sql`), join(dir, `${entry.tag}.sql`));
+ }
+ writeFileSync(
+ join(dir, "meta", "_journal.json"),
+ JSON.stringify({ ...journal, entries }, null, 2),
+ );
+ return dir;
+}
+
+/**
+ * The full chain plus one migration that is only unsafe on a populated table — the
+ * defect class this file exists to catch, so the suite can prove it is able to fail.
+ */
+function mutatedChain(): string {
+ const dir = mkdtempSync(join(tmpdir(), "osh-migrate-chain-bad-"));
+ cpSync(MIGRATIONS_DIR, dir, { recursive: true });
+ const journalPath = join(dir, "meta", "_journal.json");
+ const journal = JSON.parse(readFileSync(journalPath, "utf8")) as Journal;
+ const last = journal.entries[journal.entries.length - 1]!;
+ writeFileSync(
+ join(dir, "9999_populated_only_failure.sql"),
+ `ALTER TABLE "project" ADD COLUMN "chain_probe" text NOT NULL;`,
+ );
+ journal.entries.push({
+ idx: last.idx + 1,
+ version: last.version,
+ when: last.when + 1_000,
+ tag: "9999_populated_only_failure",
+ breakpoints: true,
+ });
+ writeFileSync(journalPath, JSON.stringify(journal, null, 2));
+ return dir;
+}
+
+async function freshDb() {
+ const client = new PGlite("memory://");
+ return { client, db: drizzle(client, { schema }) };
+}
+
+async function appliedMigrations(client: PGlite): Promise {
+ const res = await client.query<{ n: number }>(
+ `select count(*)::int as n from drizzle."__drizzle_migrations"`,
+ );
+ return res.rows[0]?.n ?? 0;
+}
+
+async function tableExists(client: PGlite, table: string): Promise {
+ const res = await client.query<{ n: number }>(
+ `select count(*)::int as n from information_schema.tables
+ where table_schema = 'public' and table_name = $1`,
+ [table],
+ );
+ return (res.rows[0]?.n ?? 0) > 0;
+}
+
+/** A literal that satisfies a column of this type — enough to make a row exist. */
+function fillerFor(table: string, column: string, dataType: string): string {
+ switch (dataType) {
+ case "text":
+ case "character varying":
+ case "character":
+ return `'seed'`;
+ case "uuid":
+ return `'00000000-0000-0000-0000-000000000001'`;
+ case "timestamp with time zone":
+ case "timestamp without time zone":
+ case "date":
+ return "now()";
+ case "boolean":
+ return "false";
+ case "integer":
+ case "bigint":
+ case "smallint":
+ case "numeric":
+ case "real":
+ case "double precision":
+ return "0";
+ case "json":
+ case "jsonb":
+ return `'{}'`;
+ case "ARRAY":
+ return `'{}'`;
+ default:
+ // Loud rather than skipped: a type we can't fill means this table silently
+ // stopped being seeded, and an unseeded table proves nothing.
+ throw new Error(
+ `migrate-chain: no filler for ${table}.${column} (${dataType}) — add one above`,
+ );
+ }
+}
+
+/**
+ * Insert exactly one row into `table`, by introspecting what the schema requires AT
+ * THIS POINT IN THE CHAIN rather than from the current TypeScript schema (which
+ * describes columns the older database doesn't have yet).
+ *
+ * Introspection is what keeps this test from rotting: the cutoffs below are relative to
+ * HEAD, so they move every release, and a hardcoded column list would break on the
+ * first migration that touched any of these tables.
+ */
+async function seedRow(client: PGlite, table: string, id: string): Promise {
+ const cols = await client.query<{
+ column_name: string;
+ data_type: string;
+ }>(
+ `select column_name, data_type
+ from information_schema.columns
+ where table_schema = 'public'
+ and table_name = $1
+ and is_nullable = 'NO'
+ and column_default is null
+ and is_generated = 'NEVER'
+ and identity_generation is null`,
+ [table],
+ );
+
+ const names: string[] = [];
+ const values: string[] = [];
+ for (const col of cols.rows) {
+ names.push(`"${col.column_name}"`);
+ values.push(
+ col.column_name === "id" ? `'${id}'` : fillerFor(table, col.column_name, col.data_type),
+ );
+ }
+ // `id` may carry a default (so it's excluded above) — force ours in anyway, because
+ // the assertions find the row by it.
+ if (!names.includes(`"id"`)) {
+ names.push(`"id"`);
+ values.push(`'${id}'`);
+ }
+
+ await client.exec(
+ `insert into "${table}" (${names.join(", ")}) values (${values.join(", ")});`,
+ );
+}
+
+async function rowExists(client: PGlite, table: string, id: string): Promise {
+ const res = await client.query<{ n: number }>(
+ `select count(*)::int as n from "${table}" where id = $1`,
+ [id],
+ );
+ return (res.rows[0]?.n ?? 0) > 0;
+}
+
+// Long-lived core tables (all present since 0000_init) that later migrations keep
+// touching. Rows here are what turn "the SQL parses" into "the SQL applies".
+const SEEDED_TABLES = [
+ "organization",
+ "project",
+ "deployment",
+ "servers",
+ "user",
+ "domain",
+ "env_var",
+ "service",
+];
+
+const SEED_ID = "migrate-chain-seed";
+
+/**
+ * How many migrations back the "recent upgrade" case starts — roughly a release's
+ * worth, so it covers the hop an operator actually takes (e.g. 0.6.1 → 0.6.5).
+ */
+const RECENT_WINDOW = 12;
+
+describe("migration chain applies to an existing, populated database", () => {
+ const journal = readJournal();
+ const total = journal.entries.length;
+
+ // Guards the two cases below against passing vacuously — a journal that stopped
+ // resolving would make every "upgrade" a no-op on an empty chain.
+ test("journal resolves and every entry has its .sql file", () => {
+ expect(total).toBeGreaterThan(10);
+ for (const entry of journal.entries) {
+ expect(
+ readFileSync(join(MIGRATIONS_DIR, `${entry.tag}.sql`), "utf8").length,
+ `${entry.tag}.sql is empty or missing`,
+ ).toBeGreaterThan(0);
+ }
+ });
+
+ test("a fresh apply converges and is idempotent", async () => {
+ const { client, db } = await freshDb();
+ await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
+ expect(await appliedMigrations(client)).toBe(total);
+
+ // Second run must be a no-op. A migration that re-applies is how an update that
+ // "already worked" fails the next time the api restarts.
+ await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
+ expect(await appliedMigrations(client)).toBe(total);
+ });
+
+ // Without this, the two cases below are unfalsifiable: they'd pass just as happily if
+ // the seeding silently stopped working or drizzle swallowed migration errors.
+ test("catches a migration that is only unsafe once rows exist", async () => {
+ const bad = mutatedChain();
+
+ // Against an empty database it applies without complaint — which is precisely what
+ // every other migration test in this repo, and a text scan of the SQL, would report.
+ const empty = await freshDb();
+ await migrate(empty.db, { migrationsFolder: bad });
+
+ // Against the same schema holding one row, it must fail.
+ const { client, db } = await freshDb();
+ await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
+ await client.exec("SET session_replication_role = replica;");
+ await seedRow(client, "project", SEED_ID);
+ expect(await rowExists(client, "project", SEED_ID), "probe row must exist").toBe(true);
+
+ // Matched on the injected column, not just "it threw": proves the failure came from
+ // the migration under test rather than incidentally from the seeding.
+ await expect(migrate(db, { migrationsFolder: bad })).rejects.toThrow(/chain_probe/);
+ });
+
+ // The cases that matter: stop partway, put rows in, then finish the chain.
+ for (const [label, cutoff] of [
+ [`the last ${RECENT_WINDOW} migrations`, total - RECENT_WINDOW],
+ ["half the chain", Math.floor(total / 2)],
+ ] as const) {
+ test(`upgrades a populated database across ${label}`, async () => {
+ expect(cutoff, "cutoff must leave migrations on both sides").toBeGreaterThan(0);
+ expect(cutoff).toBeLessThan(total);
+
+ const { client, db } = await freshDb();
+ await migrate(db, { migrationsFolder: migrationsPrefix(cutoff) });
+ expect(await appliedMigrations(client)).toBe(cutoff);
+
+ // FKs off so one row per table needs no parent graph — same approach as the
+ // repo's other real-SQL repo tests.
+ await client.exec("SET session_replication_role = replica;");
+ for (const table of SEEDED_TABLES) {
+ expect(await tableExists(client, table), `${table} missing at migration ${cutoff}`).toBe(
+ true,
+ );
+ await seedRow(client, table, SEED_ID);
+ expect(await rowExists(client, table, SEED_ID), `seed into ${table} did not land`).toBe(
+ true,
+ );
+ }
+
+ // The upgrade itself. Runs with rows present, which is the whole point.
+ await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
+
+ expect(await appliedMigrations(client)).toBe(total);
+ for (const table of SEEDED_TABLES) {
+ expect(await rowExists(client, table, SEED_ID), `${table} lost its row`).toBe(true);
+ }
+ });
+ }
+});
diff --git a/packages/db/src/pg-ready.test.ts b/packages/db/src/pg-ready.test.ts
new file mode 100644
index 000000000..e1d6efdf4
--- /dev/null
+++ b/packages/db/src/pg-ready.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import type { Pool } from "pg";
+import { awaitPgReady } from "./client";
+
+// Boot must survive a Postgres that isn't answering YET, and must not survive one that
+// will never answer.
+//
+// The regression: `createPgClient` connected once and ran migrate() immediately, so a
+// postgres that was seconds late (host reboot, a postgres restart, crash recovery —
+// none of which go through compose's `depends_on: service_healthy`) exited the process.
+// Docker's `restart: unless-stopped` then re-ran the same one-shot boot, which is what
+// an operator sees as a crash loop, on a log line that blames DNS.
+
+/** A pool whose connect() fails with `errs` in order, then succeeds forever. */
+function poolThatFails(...errs: unknown[]): { pool: Pool; connect: ReturnType } {
+ const release = vi.fn();
+ let call = 0;
+ const connect = vi.fn(async () => {
+ const err = errs[call++];
+ if (err) throw err;
+ return { release };
+ });
+ return { pool: { connect } as unknown as Pool, connect };
+}
+
+function pgError(code: string): Error & { code: string } {
+ return Object.assign(new Error(`pg error ${code}`), { code });
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("awaitPgReady", () => {
+ test("returns as soon as a connection is handed out", async () => {
+ const { pool, connect } = poolThatFails();
+ await awaitPgReady(pool, { budgetMs: 1_000, retryMs: 1 });
+ expect(connect).toHaveBeenCalledTimes(1);
+ });
+
+ test("releases the probe connection back to the pool", async () => {
+ const release = vi.fn();
+ const pool = { connect: async () => ({ release }) } as unknown as Pool;
+ await awaitPgReady(pool, { budgetMs: 1_000, retryMs: 1 });
+ expect(release).toHaveBeenCalledTimes(1);
+ });
+
+ test("retries a late postgres instead of throwing", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ // Bun reports an unresolvable compose alias exactly like this — the shape that
+ // used to kill boot outright.
+ const dnsMiss = Object.assign(new Error("getaddrinfo ECONNREFUSED"), {
+ code: "ECONNREFUSED",
+ syscall: "getaddrinfo",
+ name: "DNSException",
+ });
+ const { pool, connect } = poolThatFails(dnsMiss, dnsMiss, pgError("57P03"));
+ await awaitPgReady(pool, { budgetMs: 5_000, retryMs: 1 });
+ expect(connect).toHaveBeenCalledTimes(4);
+ });
+
+ test("retries an unclassified error rather than assuming it is permanent", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ const { pool, connect } = poolThatFails(new Error("timeout exceeded when trying to connect"));
+ await awaitPgReady(pool, { budgetMs: 5_000, retryMs: 1 });
+ expect(connect).toHaveBeenCalledTimes(2);
+ });
+
+ // The other half: waiting must not paper over a config error an operator has to fix.
+ // 28P01 is the #488 signal — `.env` no longer matches the surviving data volume — and
+ // burying it behind a 90s wait is how that diagnosis got lost the first time.
+ for (const code of ["28P01", "28000", "3D000"]) {
+ test(`fails immediately on ${code} without retrying`, async () => {
+ const { pool, connect } = poolThatFails(pgError(code), pgError(code));
+ await expect(awaitPgReady(pool, { budgetMs: 60_000, retryMs: 1 })).rejects.toThrow(
+ `pg error ${code}`,
+ );
+ expect(connect).toHaveBeenCalledTimes(1);
+ });
+ }
+
+ // A NaN budget makes every deadline comparison false, so the loop would never reach
+ // its exit — boot would hang instead of failing. Falls back to the real default.
+ test("an unusable budget cannot turn the wait into a hang", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const refused = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
+ const { pool } = poolThatFails(refused, refused);
+ await awaitPgReady(pool, { budgetMs: Number.NaN, retryMs: 1 });
+ });
+
+ test("gives up at the budget and rethrows the driver's own error", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const refused = Object.assign(new Error("connect ECONNREFUSED 10.0.0.2:5432"), {
+ code: "ECONNREFUSED",
+ });
+ // Never succeeds: every entry is a failure and the list is longer than the budget
+ // allows attempts for.
+ const { pool, connect } = poolThatFails(...Array.from({ length: 500 }, () => refused));
+ await expect(awaitPgReady(pool, { budgetMs: 30, retryMs: 10 })).rejects.toThrow(
+ "connect ECONNREFUSED 10.0.0.2:5432",
+ );
+ expect(connect.mock.calls.length).toBeGreaterThan(0);
+ expect(connect.mock.calls.length).toBeLessThan(10);
+ });
+});
diff --git a/packages/db/src/repos/audit-event.repo.ts b/packages/db/src/repos/audit-event.repo.ts
index 8a661e4b4..200dadd8f 100644
--- a/packages/db/src/repos/audit-event.repo.ts
+++ b/packages/db/src/repos/audit-event.repo.ts
@@ -51,6 +51,9 @@ export interface AuditEventFilters {
resourceId?: string;
/** Call surface: "dashboard" | "mcp" | "cli" | "api" | "webhook" | "system". */
source?: string;
+ /** One client of that surface — `oauth:` / `pat:`. The
+ * per-agent feed: "everything THIS assistant did", not just "an assistant". */
+ sourceClientId?: string;
from?: Date;
to?: Date;
/** Free text over event type, resource id/name and actor name/email. */
@@ -73,6 +76,7 @@ export function createAuditEventRepo(db: Database, settings?: AuditRecordingSwit
if (f?.resourceType) filters.push(eq(auditEvent.resourceType, f.resourceType));
if (f?.resourceId) filters.push(eq(auditEvent.resourceId, f.resourceId));
if (f?.source) filters.push(eq(auditEvent.source, f.source));
+ if (f?.sourceClientId) filters.push(eq(auditEvent.sourceClientId, f.sourceClientId));
if (f?.from) filters.push(gte(auditEvent.createdAt, f.from));
if (f?.to) filters.push(lte(auditEvent.createdAt, f.to));
if (f?.q) {
@@ -231,6 +235,37 @@ export function createAuditEventRepo(db: Database, settings?: AuditRecordingSwit
return rows.map((r) => ({ source: r.source, count: Number(r.count) }));
},
+ /**
+ * Row count per source client — the option list for "which agent". NULLs are
+ * dropped rather than grouped: an unattributed row is not a client, and the
+ * partial index only covers the non-null rows anyway.
+ *
+ * Bounded by `limit` because the group-by is over an unbounded key space (a
+ * dynamically-registered OAuth client is a new value), and the filter bar
+ * cannot render an unbounded list. Busiest first, so the cut drops the
+ * quietest clients rather than an arbitrary set.
+ */
+ async countBySourceClient(
+ organizationId: string,
+ filters?: AuditEventFilters,
+ limit = 20,
+ ): Promise<{ sourceClientId: string; count: number }[]> {
+ const where = and(
+ ...buildFilters(organizationId, filters),
+ sql`${auditEvent.sourceClientId} IS NOT NULL`,
+ );
+ const rows = await db
+ .select({ sourceClientId: auditEvent.sourceClientId, count: sql`count(*)` })
+ .from(auditEvent)
+ .where(where)
+ .groupBy(auditEvent.sourceClientId)
+ .orderBy(desc(sql`count(*)`))
+ .limit(limit);
+ return rows
+ .filter((r): r is { sourceClientId: string; count: number } => !!r.sourceClientId)
+ .map((r) => ({ sourceClientId: r.sourceClientId, count: Number(r.count) }));
+ },
+
/**
* Distinct actor ids that appear in the window — the option list for the
* "who did this" filter. Bounded by the window (and by `limit`) so this
diff --git a/packages/db/src/repos/compose-spec-command.test.ts b/packages/db/src/repos/compose-spec-command.test.ts
index 46ab5d00f..fdad377bb 100644
--- a/packages/db/src/repos/compose-spec-command.test.ts
+++ b/packages/db/src/repos/compose-spec-command.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { composeSpecDiff, composeSpecsEqual, toComposeSpec } from "./service.repo";
+import { composeSpecDiff, composeSpecsEqual, composeWritePatch, toComposeSpec } from "./service.repo";
/**
* #332 drift stability: adding structured `commandArgv` must NOT make a legacy
@@ -37,3 +37,59 @@ describe("compose command drift stability (#332)", () => {
expect(composeSpecsEqual(toComposeSpec({}), toComposeSpec({}))).toBe(true);
});
});
+
+/**
+ * `composeWritePatch` is the ONE gate every compose-sync writer passes through —
+ * the sync endpoint, the migration importer, and (the one that bit) the deploy
+ * request's own service list, whose wire shape carried `command` as a string only.
+ * Because the stored string is a lossy display join, letting toComposeSpec re-derive
+ * argv there meant a client replaying its service list re-split a correct
+ * `["sh","-c","a && b"]` into five words on the next deploy.
+ */
+describe("composeWritePatch keeps argv faithful across a string-only writer (#332)", () => {
+ const storedListCommand = {
+ command: "sh -c a && b", // the lossy display join of the argv below
+ commandArgv: ["sh", "-c", "a && b"],
+ };
+
+ it("an unchanged command string leaves the stored argv alone", () => {
+ const patch = composeWritePatch({ name: "web", command: "sh -c a && b" }, storedListCommand);
+ expect(patch.commandArgv).toEqual(["sh", "-c", "a && b"]);
+ });
+
+ it("without that rule the same input would have re-split the join", () => {
+ // No stored row (a brand-new service): deriving is correct here.
+ const patch = composeWritePatch({ name: "web", command: "sh -c a && b" }, null);
+ expect(patch.commandArgv).toEqual(["sh", "-c", "a", "&&", "b"]);
+ });
+
+ it("a genuinely changed command re-derives argv", () => {
+ const patch = composeWritePatch(
+ { name: "web", command: "server start --verbose" },
+ { command: "server start", commandArgv: ["server", "start"] },
+ );
+ expect(patch.commandArgv).toEqual(["server", "start", "--verbose"]);
+ });
+
+ it("an explicit argv from the parser wins over both", () => {
+ const patch = composeWritePatch(
+ { name: "web", command: "sh -c a && b", commandArgv: ["sh", "-c", "a && b"] },
+ { command: "server start", commandArgv: ["server", "start"] },
+ );
+ expect(patch.commandArgv).toEqual(["sh", "-c", "a && b"]);
+ });
+
+ it("a dropped command clears argv with it", () => {
+ const patch = composeWritePatch({ name: "web" }, storedListCommand);
+ expect(patch.command).toBeNull();
+ expect(patch.commandArgv).toBeNull();
+ });
+
+ it("still backfills a legacy row that has a command but no argv", () => {
+ const patch = composeWritePatch(
+ { name: "web", command: "server start" },
+ { command: "server start", commandArgv: null },
+ );
+ expect(patch.commandArgv).toEqual(["server", "start"]);
+ });
+});
diff --git a/packages/db/src/repos/personal-access-token-usage.repo.test.ts b/packages/db/src/repos/personal-access-token-usage.repo.test.ts
new file mode 100644
index 000000000..d28536169
--- /dev/null
+++ b/packages/db/src/repos/personal-access-token-usage.repo.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect, beforeAll } from "vitest";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+import { PGlite } from "@electric-sql/pglite";
+import { drizzle } from "drizzle-orm/pglite";
+import { migrate } from "drizzle-orm/pglite/migrator";
+import * as schema from "../schema";
+import { createPersonalAccessTokenRepo } from "./personal-access-token.repo";
+
+const MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../drizzle");
+
+/**
+ * Real (in-memory PGlite) test for the credential usage stamp.
+ *
+ * `last_used_at` answered "is this still in use". It could not answer "how much",
+ * which is the difference between an agent someone tried once and one running
+ * unattended — so the settings UI could show a connection as active without any
+ * sense of scale. The counter rides along in the same UPDATE, and being SQL rather
+ * than read-modify-write is the whole point under a burst of concurrent tool calls,
+ * which is what a mock would hide.
+ *
+ * FK enforcement is off so a token can exist without the user/org chain.
+ */
+async function freshRepo() {
+ const client = new PGlite("memory://");
+ const db = drizzle(client, { schema });
+ await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
+ await client.exec("SET session_replication_role = replica;");
+ return { client, db, repo: createPersonalAccessTokenRepo(db) };
+}
+
+let repo: Awaited>["repo"];
+let seq = 0;
+
+async function newToken(): Promise {
+ const row = await repo.create({
+ userId: "u1",
+ organizationId: "org1",
+ name: `token-${seq}`,
+ tokenPrefix: `opsh_pat_${seq}`,
+ tokenHash: `hash-${seq++}`,
+ readOnly: false,
+ scoped: false,
+ expiresAt: null,
+ });
+ return row.id;
+}
+
+beforeAll(async () => {
+ ({ repo } = await freshRepo());
+});
+
+describe("touchLastUsed", () => {
+ it("starts a fresh credential at zero, never null", async () => {
+ const id = await newToken();
+ const rows = await repo.listByUser("u1");
+ expect(rows.find((r) => r.id === id)?.useCount).toBe(0);
+ });
+
+ it("stamps the timestamp and advances the count together", async () => {
+ const id = await newToken();
+ await repo.touchLastUsed(id);
+ const [row] = (await repo.listByUser("u1")).filter((r) => r.id === id);
+ expect(row?.useCount).toBe(1);
+ expect(row?.lastUsedAt).toBeInstanceOf(Date);
+ });
+
+ it("counts every call, not just the first", async () => {
+ const id = await newToken();
+ for (let i = 0; i < 5; i++) await repo.touchLastUsed(id);
+ const row = (await repo.listByUser("u1")).find((r) => r.id === id);
+ expect(row?.useCount).toBe(5);
+ });
+
+ it("loses nothing when calls overlap", async () => {
+ // An agent fires tool calls concurrently; a read-modify-write here would
+ // interleave and under-count.
+ const id = await newToken();
+ await Promise.all(Array.from({ length: 20 }, () => repo.touchLastUsed(id)));
+ const row = (await repo.listByUser("u1")).find((r) => r.id === id);
+ expect(row?.useCount).toBe(20);
+ });
+
+ it("counts per credential — one agent's traffic is not another's", async () => {
+ const a = await newToken();
+ const b = await newToken();
+ await repo.touchLastUsed(a);
+ await repo.touchLastUsed(a);
+ await repo.touchLastUsed(b);
+ const rows = await repo.listByUser("u1");
+ expect(rows.find((r) => r.id === a)?.useCount).toBe(2);
+ expect(rows.find((r) => r.id === b)?.useCount).toBe(1);
+ });
+});
+
+describe("listNamesByIds", () => {
+ it("labels the tokens an audit row can be attributed to", async () => {
+ // `pat:` rows have no OAuth application to name them, so without this the
+ // audit feed shows a raw pat_ id where the client name belongs.
+ const id = await newToken();
+ const names = await repo.listNamesByIds([id]);
+ expect(names).toEqual([{ id, name: expect.stringMatching(/^token-/) }]);
+ });
+
+ it("is empty for no ids, and skips ids that do not exist", async () => {
+ expect(await repo.listNamesByIds([])).toEqual([]);
+ expect(await repo.listNamesByIds(["pat_missing"])).toEqual([]);
+ });
+});
diff --git a/packages/db/src/repos/personal-access-token.repo.ts b/packages/db/src/repos/personal-access-token.repo.ts
index c62e4db47..4daa52c35 100644
--- a/packages/db/src/repos/personal-access-token.repo.ts
+++ b/packages/db/src/repos/personal-access-token.repo.ts
@@ -1,4 +1,4 @@
-import { and, desc, eq, isNull, isNotNull } from "drizzle-orm";
+import { and, desc, eq, inArray, isNull, isNotNull, sql } from "drizzle-orm";
import {
generateId,
serializeSourceAccessScope,
@@ -102,6 +102,23 @@ export function createPersonalAccessTokenRepo(db: Database) {
});
},
+ /**
+ * Display names for a set of token ids. Used to label audit rows attributed
+ * to `pat:` — a static-token MCP connection has no OAuth application
+ * to read a name from, so the token's own name is the only label there is.
+ *
+ * Deliberately NOT user-scoped: the caller is reading rows in an org it holds
+ * audit:read on, and a token used against that org is part of its history even
+ * if it belongs to another member. Only the name is projected.
+ */
+ async listNamesByIds(ids: string[]): Promise> {
+ if (ids.length === 0) return [];
+ return db
+ .select({ id: personalAccessToken.id, name: personalAccessToken.name })
+ .from(personalAccessToken)
+ .where(inArray(personalAccessToken.id, ids));
+ },
+
/**
* The grant-holder row for an OAuth MCP client binding, keyed by
* (userId, oauthClientId). Returns null (never revoked/expired-filtered —
@@ -224,11 +241,18 @@ export function createPersonalAccessTokenRepo(db: Database) {
return rows.length > 0;
},
- /** Best-effort last-used stamp (called on each authenticated request). */
+ /**
+ * Best-effort usage stamp (called on each authenticated request).
+ *
+ * The counter rides along in the same UPDATE — it is the same row and the same
+ * write, so "how many calls has this agent made" costs nothing on top of "when
+ * did it last call". Incremented in SQL, not read-modify-write, so concurrent
+ * requests from one agent don't lose counts.
+ */
async touchLastUsed(id: string): Promise {
await db
.update(personalAccessToken)
- .set({ lastUsedAt: new Date() })
+ .set({ lastUsedAt: new Date(), useCount: sql`${personalAccessToken.useCount} + 1` })
.where(eq(personalAccessToken.id, id));
},
};
diff --git a/packages/db/src/repos/server-container-status.repo.ts b/packages/db/src/repos/server-container-status.repo.ts
index ffcabb32f..1078ef732 100644
--- a/packages/db/src/repos/server-container-status.repo.ts
+++ b/packages/db/src/repos/server-container-status.repo.ts
@@ -13,7 +13,15 @@ export type ServerContainerComponent = "edge" | "mail";
export function createServerContainerStatusRepo(db: Database) {
return {
- /** Upsert a scan result for a (server, component). Unique on (serverId, component). */
+ /**
+ * Upsert a scan result for a (server, component). Unique on (serverId, component).
+ *
+ * `latestInProgress` is PRESERVED when the caller omits it: a detect probe knows
+ * what the box runs, not whether an apply is mid-flight, and writing its default
+ * would clear the flag under a running swap (every scan path — the 6-hourly job,
+ * the boot hook, a manual Scan, the dashboard's mount auto-scan — hits this).
+ * Only the apply itself, through {@link setInProgress}, owns the flag.
+ */
async upsert(data: Omit): Promise {
const id = generateId("scs");
await db
@@ -28,7 +36,9 @@ export function createServerContainerStatusRepo(db: Database) {
runningVersion: data.runningVersion ?? null,
pinnedVersion: data.pinnedVersion ?? null,
behind: data.behind,
- latestInProgress: data.latestInProgress,
+ ...(data.latestInProgress === undefined
+ ? {}
+ : { latestInProgress: data.latestInProgress }),
detail: data.detail ?? null,
checkedAt: data.checkedAt ?? new Date(),
updatedAt: new Date(),
@@ -40,6 +50,11 @@ export function createServerContainerStatusRepo(db: Database) {
* Drop a (server, component) row. Used when a scan finds the component is no
* longer present on the box (edge removed, mail uninstalled) — leaving the row
* would keep reporting drift for a container that is gone.
+ *
+ * A row whose apply is IN PROGRESS is left alone: mid-swap the container is
+ * legitimately absent for a moment, and deleting the row there both erases the
+ * in-flight state every surface renders and orphans the apply's own
+ * `setInProgress(false)` (it would update zero rows).
*/
async remove(serverId: string, component: ServerContainerComponent): Promise {
await db
@@ -48,6 +63,7 @@ export function createServerContainerStatusRepo(db: Database) {
and(
eq(serverContainerStatus.serverId, serverId),
eq(serverContainerStatus.component, component),
+ eq(serverContainerStatus.latestInProgress, false),
),
);
},
@@ -113,5 +129,19 @@ export function createServerContainerStatusRepo(db: Database) {
),
);
},
+
+ /**
+ * Clear every in-progress flag. Boot-only: an apply lives in the API process
+ * (its session, logs and step model are in memory), so a flag that survived a
+ * restart describes a run that no longer exists. Without this the flag is
+ * indistinguishable from a live swap and every surface renders a permanent
+ * "Updating…" — the very reason readers used to bound their polling.
+ */
+ async clearAllInProgress(): Promise {
+ await db
+ .update(serverContainerStatus)
+ .set({ latestInProgress: false, updatedAt: new Date() })
+ .where(eq(serverContainerStatus.latestInProgress, true));
+ },
};
}
diff --git a/packages/db/src/repos/service-deployment-failure-upsert.test.ts b/packages/db/src/repos/service-deployment-failure-upsert.test.ts
new file mode 100644
index 000000000..44a36e260
--- /dev/null
+++ b/packages/db/src/repos/service-deployment-failure-upsert.test.ts
@@ -0,0 +1,209 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { PGlite } from "@electric-sql/pglite";
+import { drizzle } from "drizzle-orm/pglite";
+import { migrate } from "drizzle-orm/pglite/migrator";
+import { and, eq } from "drizzle-orm";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import * as schema from "../schema";
+import { createServiceRepo } from "./service.repo";
+
+const MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../drizzle");
+
+/**
+ * Recording a service FAILURE must not depend on the row not existing yet.
+ *
+ * `service_deployment` carries `uq_service_deployment_dep_svc` UNIQUE on
+ * (deployment_id, service_id), and a deploy has two writers: the smart-deploy path
+ * pre-creates a `skipped` row for every service it did not target, and the compose
+ * path writes the services it did. So by the time a dependency failure needs
+ * recording, a row for that pair very often already exists — and a plain INSERT
+ * there raised a unique violation that killed the deploy on its own BOOKKEEPING,
+ * hiding whatever actually failed.
+ *
+ * The other half is just as important: the row it collides with holds the LIVE
+ * runtime details of a container that is still running (`container_id`,
+ * `image_digest`, `host_port`, `ip`). A full-row upsert would blank those, which is
+ * why this cannot simply reuse `upsertServiceDeployment` — recording that service B
+ * failed must not erase what service A is running.
+ */
+async function fresh() {
+ const client = new PGlite("memory://");
+ const db = drizzle(client, { schema });
+ await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
+ // Seed rows without the full org→project→service FK chain.
+ await client.exec("SET session_replication_role = replica;");
+ return { db, repo: createServiceRepo(db) };
+}
+
+const DEP = "dep_1";
+const SVC = "svc_postgres";
+
+let ctx: Awaited>;
+
+beforeEach(async () => {
+ ctx = await fresh();
+});
+
+function readRow() {
+ return ctx.db.query.serviceDeployment.findFirst({
+ where: and(
+ eq(schema.serviceDeployment.deploymentId, DEP),
+ eq(schema.serviceDeployment.serviceId, SVC),
+ ),
+ });
+}
+
+async function countRows() {
+ const rows = await ctx.db.query.serviceDeployment.findMany({
+ where: eq(schema.serviceDeployment.deploymentId, DEP),
+ });
+ return rows.length;
+}
+
+/** What the smart-deploy path pre-creates for an untargeted, still-running service. */
+async function seedCarriedForwardRow() {
+ await ctx.db.insert(schema.serviceDeployment).values({
+ id: "sd_existing",
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ status: "skipped",
+ reason: "unchanged",
+ reasonSkipped: "unchanged",
+ imageRef: "postgres:16-alpine",
+ imageDigest: "sha256:deadbeef",
+ containerId: "container_abc",
+ hostPort: 5432,
+ ip: "172.18.0.5",
+ });
+}
+
+describe("markServiceDeploymentFailed", () => {
+ it("inserts when no row exists yet", async () => {
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ imageRef: "postgres:16-alpine",
+ errorMessage: "boom",
+ });
+
+ const row = await readRow();
+ expect(row?.status).toBe("failure");
+ expect(row?.errorMessage).toBe("boom");
+ expect(row?.imageRef).toBe("postgres:16-alpine");
+ expect(row?.finishedAt).toBeInstanceOf(Date);
+ });
+
+ // THE regression. A plain insert here is the unique violation that surfaced as
+ // "Failed query: insert into service_deployment" and aborted the deploy.
+ it("does not throw when a row for the pair already exists", async () => {
+ await seedCarriedForwardRow();
+
+ await expect(
+ ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ imageRef: "postgres:16-alpine",
+ errorMessage: "Skipped because required service api did not deploy.",
+ }),
+ ).resolves.not.toThrow();
+
+ // Updated in place — the unique index permits exactly one row per pair.
+ expect(await countRows()).toBe(1);
+ const row = await readRow();
+ expect(row?.status).toBe("failure");
+ expect(row?.errorMessage).toContain("did not deploy");
+ });
+
+ it("PRESERVES the live runtime fields of the row it updates", async () => {
+ // The reason this is not `upsertServiceDeployment`: that one coalesces these to
+ // null, so recording a failure would erase the record of a running container.
+ await seedCarriedForwardRow();
+
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ errorMessage: "dependency failed",
+ });
+
+ const row = await readRow();
+ expect(row?.containerId).toBe("container_abc");
+ expect(row?.imageDigest).toBe("sha256:deadbeef");
+ expect(row?.hostPort).toBe(5432);
+ expect(row?.ip).toBe("172.18.0.5");
+ });
+
+ it("keeps the existing imageRef when the failure knew none", async () => {
+ // Several call sites fail before an image is resolved. Passing null there must
+ // not blank the image the carried-forward row recorded.
+ await seedCarriedForwardRow();
+
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ errorMessage: "no image resolved",
+ });
+
+ expect((await readRow())?.imageRef).toBe("postgres:16-alpine");
+ });
+
+ it("overwrites imageRef when the failure DID resolve one", async () => {
+ await seedCarriedForwardRow();
+
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ imageRef: "postgres:17-alpine",
+ errorMessage: "started the new image and it died",
+ });
+
+ expect((await readRow())?.imageRef).toBe("postgres:17-alpine");
+ });
+
+ it("records the reason so it outlives the deploy's SSE session", async () => {
+ // Every call site already had this message in hand for its live broadcast and
+ // was discarding it, which is why a finished deploy showed status=failure with
+ // no explanation anywhere.
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ errorMessage: "port 5432 already allocated",
+ reason: "port-conflict",
+ });
+
+ const row = await readRow();
+ expect(row?.errorMessage).toBe("port 5432 already allocated");
+ expect(row?.reason).toBe("port-conflict");
+ });
+
+ it("is idempotent across repeated failures for the same pair", async () => {
+ for (const msg of ["first", "second", "third"]) {
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ errorMessage: msg,
+ });
+ }
+ expect(await countRows()).toBe(1);
+ expect((await readRow())?.errorMessage).toBe("third");
+ });
+
+ it("accepts a non-default status without hardcoding 'failure'", async () => {
+ await ctx.repo.markServiceDeploymentFailed({
+ deploymentId: DEP,
+ serviceId: SVC,
+ serviceName: "postgres",
+ status: "cancelled",
+ errorMessage: "operator cancelled the deploy",
+ });
+ expect((await readRow())?.status).toBe("cancelled");
+ });
+});
diff --git a/packages/db/src/repos/service.repo.ts b/packages/db/src/repos/service.repo.ts
index 726959f09..104f91374 100644
--- a/packages/db/src/repos/service.repo.ts
+++ b/packages/db/src/repos/service.repo.ts
@@ -1,5 +1,5 @@
import { eq, and, asc, inArray } from "drizzle-orm";
-import { commandToArgv, generateId, mergeAdvanced, normalizeCustomHostname, type ComposeAdvanced } from "@repo/core";
+import { commandToArgv, generateId, mergeAdvanced, normalizeCustomHostname, resolveCommandArgv, type ComposeAdvanced } from "@repo/core";
import type { Database } from "../client";
import { service, serviceDeployment } from "../schema";
import type { ComposeServiceSpec, ServicePublicEndpoint } from "../schema/service";
@@ -99,16 +99,32 @@ export const composeSpecsEqual = (a: ComposeServiceSpec, b: ComposeServiceSpec)
* SHOULD disappear, and the 3-way merge against `importedSpec` is what decides
* whether that is safe.
*/
-function composeWritePatch(
+export function composeWritePatch(
parsed: ParsedComposeService,
- stored?: { advanced?: ComposeAdvanced | null } | null,
+ stored?:
+ | { advanced?: ComposeAdvanced | null; command?: string | null; commandArgv?: string[] | null }
+ | null,
/** `parsed` is a full re-read of the compose FILE, so an absent compose-owned
* key means the author deleted it. See {@link COMPOSE_OWNED_ADVANCED_KEYS}. */
composeAuthoritative = false,
): ComposeServiceSpec {
const advanced = mergeAdvanced(stored?.advanced ?? null, parsed.advanced);
+ const spec = toComposeSpec(parsed);
+ // #332: several wire shapes into this path carry `command` as a STRING only
+ // (BuildServiceInput on the deploy request, the sync endpoint), and the stored
+ // string is a lossy display join for a list command. toComposeSpec's fallback
+ // would re-split it — turning a correct `["sh","-c","a && b"]` into five words on
+ // the next deploy. An unchanged string therefore keeps the stored argv; only a
+ // real change re-derives. See resolveCommandArgv.
+ const commandArgv = resolveCommandArgv({
+ incomingArgv: parsed.commandArgv,
+ incomingCommand: parsed.command ?? null,
+ storedCommand: stored?.command,
+ storedArgv: stored?.commandArgv,
+ });
return {
- ...toComposeSpec(parsed),
+ ...spec,
+ ...(commandArgv !== undefined ? { commandArgv } : {}),
advanced: composeAuthoritative ? clearComposeOwnedKeys(advanced, parsed.advanced) : advanced,
};
}
@@ -778,6 +794,70 @@ export function createServiceRepo(db: Database) {
});
},
+ /**
+ * Record that a service FAILED in this deployment, whether or not a row for it
+ * already exists.
+ *
+ * Why this exists next to `upsertServiceDeployment` rather than reusing it: a
+ * smart/partial redeploy pre-creates a `skipped` row for every service it did not
+ * target (service-checks.ts), and that row carries the LIVE runtime details of a
+ * container that is still running — `containerId`, `imageDigest`, `hostPort`, `ip`.
+ * `upsertServiceDeployment` coalesces all of those to null, so using it here would
+ * erase the record of a running container just because a *different* service
+ * failed. Using a plain insert instead is what violated
+ * `uq_service_deployment_dep_svc` and killed the deploy on its own bookkeeping.
+ *
+ * So the `set` below lists ONLY the failure facts. Drizzle updates just the listed
+ * columns, so every runtime field is preserved by OMISSION — that is the load-bearing
+ * detail, and the reason not to "simplify" this into the sibling method.
+ *
+ * `imageRef` is overwritten only when one is actually known: several call sites fail
+ * before an image is resolved, and passing null there must not blank the image the
+ * carried-forward row recorded.
+ */
+ async markServiceDeploymentFailed(data: {
+ deploymentId: string;
+ serviceId: string;
+ serviceName: string;
+ /** Defaults to "failure". Present so a caller can record e.g. "cancelled". */
+ status?: string;
+ imageRef?: string | null;
+ /** Operator-facing reason. Persisted so it outlives the deploy's SSE session. */
+ errorMessage?: string | null;
+ reason?: string | null;
+ }) {
+ const now = new Date();
+ const status = data.status ?? "failure";
+
+ const set: Partial = {
+ serviceName: data.serviceName,
+ status,
+ finishedAt: now,
+ updatedAt: now,
+ };
+ if (data.errorMessage !== undefined) set.errorMessage = data.errorMessage;
+ if (data.reason !== undefined) set.reason = data.reason;
+ if (data.imageRef) set.imageRef = data.imageRef;
+
+ await db
+ .insert(serviceDeployment)
+ .values({
+ id: generateId("sd"),
+ deploymentId: data.deploymentId,
+ serviceId: data.serviceId,
+ serviceName: data.serviceName,
+ status,
+ imageRef: data.imageRef ?? null,
+ errorMessage: data.errorMessage ?? null,
+ reason: data.reason ?? null,
+ finishedAt: now,
+ })
+ .onConflictDoUpdate({
+ target: [serviceDeployment.deploymentId, serviceDeployment.serviceId],
+ set,
+ });
+ },
+
async updateServiceDeployment(id: string, data: Partial) {
await db
.update(serviceDeployment)
diff --git a/packages/db/src/schema/audit-event.ts b/packages/db/src/schema/audit-event.ts
index 8113e8045..194afe4b6 100644
--- a/packages/db/src/schema/audit-event.ts
+++ b/packages/db/src/schema/audit-event.ts
@@ -1,3 +1,4 @@
+import { sql } from "drizzle-orm";
import { pgTable, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
import { user } from "./auth";
import { organization } from "./organization";
@@ -60,6 +61,20 @@ export const auditEvent = pgTable(
* taken from a client header: see apps/api/src/lib/call-source.ts.
*/
source: text("source"),
+ /**
+ * WHICH client of that surface, when the surface has more than one and the
+ * distinction matters forensically. Today that means MCP: `source` says an AI
+ * assistant acted, this says whether it was Claude Desktop or Cursor.
+ *
+ * Holds the canonical principal id the auth layer already mints —
+ * `oauth:` for a consented MCP app, `pat:` for a static
+ * token — so it resolves to a name through a table that already exists and
+ * needs no new concept. Null for every other surface.
+ *
+ * Carried on the same nonce-signed channel as `source`, for the same reason:
+ * an attributable row a caller could rewrite is worse than no attribution.
+ */
+ sourceClientId: text("source_client_id"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
@@ -73,5 +88,12 @@ export const auditEvent = pgTable(
index("audit_event_resource_idx").on(t.resourceType, t.resourceId),
// "Only what the AI assistant did" — source filter, newest first.
index("audit_event_org_source_idx").on(t.organizationId, t.source, t.createdAt.desc()),
+ // "Only what THIS agent did" — per-connection feed. PARTIAL: only MCP rows
+ // carry a client id, and tool-call rows are the highest-volume writer in the
+ // table, so an unfiltered index here would be mostly NULLs paid for on every
+ // insert.
+ index("audit_event_org_client_idx")
+ .on(t.organizationId, t.sourceClientId, t.createdAt.desc())
+ .where(sql`${t.sourceClientId} IS NOT NULL`),
],
);
diff --git a/packages/db/src/schema/personal-access-token.ts b/packages/db/src/schema/personal-access-token.ts
index ac964c199..27e24ec97 100644
--- a/packages/db/src/schema/personal-access-token.ts
+++ b/packages/db/src/schema/personal-access-token.ts
@@ -1,4 +1,4 @@
-import { pgTable, text, boolean, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
+import { pgTable, text, boolean, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
/**
* Personal Access Token — a revocable, per-user Bearer credential for
@@ -44,6 +44,14 @@ export const personalAccessToken = pgTable(
oauthClientId: text("oauth_client_id"),
expiresAt: timestamp("expires_at"),
lastUsedAt: timestamp("last_used_at"),
+ /**
+ * Authenticated requests this credential has made. Written by the same
+ * best-effort UPDATE as `lastUsedAt`, so it costs nothing extra: one
+ * timestamp answers "is this still in use", the counter answers "how much" —
+ * the difference between an agent that ran one tool and one that ran a
+ * thousand. Approximate by design (the write is fire-and-forget).
+ */
+ useCount: integer("use_count").notNull().default(0),
revokedAt: timestamp("revoked_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},