Files
mesh-catalog/modules/umami/client.ts
T
jschoubben 5973d41966 umami: read the admin password from its mounted secret file
The whole-mesh dry-run found umami's runtime crash-looping "admin password is
not set": its `admin` own-secret is mounted at /run/secrets/admin, but the
client read the bare env UMAMI_ADMIN_PASSWORD, which nothing sets. Same shape as
the six tool-runtime credential fixes — read the mounted file first
(MESH_UMAMI_ADMIN_PASSWORD_FILE), falling back to the env. (photos and mailu
remain deeper conversion jobs — a stub app image and a full Mailu config env —
not credential-wiring, tracked separately.)

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-08 18:43:48 +02:00

91 lines
3.3 KiB
TypeScript

// The umami API client — moved here from the shared sdk, because it is umami's own code and
// changes when umami's API does (novox/hq ADR 0039). Both this module's tools and its provisioner
// import it; nothing outside umami does.
import { readFileSync } from "node:fs";
/** Read a secret from the file the mesh mounted it at, if the pointing env is set. */
function readSecret(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim() || undefined;
} catch {
return undefined;
}
}
export interface Website {
id: string;
name: string;
domain: string;
}
export class UmamiClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly username: string,
private readonly password: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/** Build from the module's resolved environment. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): UmamiClient {
const url = env.MESH_PROVISION_UMAMI_URL ?? env.UMAMI_URL;
const username = env.UMAMI_USERNAME ?? "admin";
const password = readSecret(env.MESH_UMAMI_ADMIN_PASSWORD_FILE) ?? env.UMAMI_ADMIN_PASSWORD;
if (!url || !password) {
throw new Error("UMAMI url or admin password is not set — umami's own code cannot reach it");
}
return new UmamiClient(url, username, password);
}
async getToken(): Promise<string> {
const res = await fetch(`${this.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: this.username, password: this.password }),
});
if (!res.ok) throw new Error(`umami login failed: ${res.status} ${await res.text()}`);
return ((await res.json()) as { token: string }).token;
}
async findWebsite(token: string, domain: string): Promise<Website | null> {
const res = await fetch(`${this.baseUrl}/api/websites?limit=100`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`umami list websites failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { data: Website[] };
return data.data.find((w) => w.domain === domain) ?? null;
}
async createWebsite(token: string, domain: string, name: string): Promise<Website> {
const res = await fetch(`${this.baseUrl}/api/websites`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ domain, name }),
});
if (!res.ok) throw new Error(`umami create website failed: ${res.status} ${await res.text()}`);
return (await res.json()) as Website;
}
async deleteWebsite(token: string, id: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/api/websites/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`umami delete website failed: ${res.status} ${await res.text()}`);
}
/** The embed snippet a tracked site includes — the heart of the analytics grant. */
snippet(websiteId: string): string {
return `<script defer src="${this.baseUrl}/script.js" data-website-id="${websiteId}"></script>`;
}
dashboard(websiteId: string): string {
return `${this.baseUrl}/websites/${websiteId}`;
}
}