From e8061191c742f89d0d948b1dd6b33598df14e41c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 23:05:20 +0200 Subject: [PATCH] =?UTF-8?q?umami:=20complete,=20on=20the=20sdk=20=E2=80=94?= =?UTF-8?q?=20the=20first=20fully=20converted=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit umami is now a whole module, not a manifest: its own API client, its tools, and its provisioner all live in the module and build on @novox/mesh-sdk. - client.ts — umami's API client, moved out of the shared sdk into the module (ADR 0044); umami's tools and provisioner both import it. - tools/ — umami_create_site / umami_delete_site on the sdk tool harness (registerModuleTools); the tool logic and client are the module's. - provisioner/ — the adapter making umami a provider of the mesh 'analytics' interface: a consumer contributes {domain}, receives {siteId, snippet, dashboard}. ~20 lines, because the watch/seal/grant loop is the sdk harness's. Type-checks against the real mesh-sdk (tsc --noEmit clean); the manifest parses against internal/catalogue. Remaining to actually run: build and publish the module + its mesh-provision-umami-analytics image (the placeholder digest), which is the pipeline's job. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- .gitignore | 2 + modules/umami/client.ts | 78 ++++++++++++++++++++++++++++++ modules/umami/package.json | 14 ++++++ modules/umami/provisioner/index.ts | 35 ++++++++++++++ modules/umami/tools/index.ts | 61 +++++++++++++++++++++++ modules/umami/tsconfig.json | 12 +++++ 6 files changed, 202 insertions(+) create mode 100644 .gitignore create mode 100644 modules/umami/client.ts create mode 100644 modules/umami/package.json create mode 100644 modules/umami/provisioner/index.ts create mode 100644 modules/umami/tools/index.ts create mode 100644 modules/umami/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/modules/umami/client.ts b/modules/umami/client.ts new file mode 100644 index 0000000..1c960ac --- /dev/null +++ b/modules/umami/client.ts @@ -0,0 +1,78 @@ +// 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 0044). Both this module's tools and its provisioner +// import it; nothing outside umami does. + +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 = 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 { + 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 { + 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 { + 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 { + 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 ``; + } + + dashboard(websiteId: string): string { + return `${this.baseUrl}/websites/${websiteId}`; + } +} diff --git a/modules/umami/package.json b/modules/umami/package.json new file mode 100644 index 0000000..a897bf7 --- /dev/null +++ b/modules/umami/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-umami", + "version": "0.1.0", + "description": "umami — web analytics. A provider of the mesh analytics interface, with its own tools.", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/umami/provisioner/index.ts b/modules/umami/provisioner/index.ts new file mode 100644 index 0000000..33f6f5f --- /dev/null +++ b/modules/umami/provisioner/index.ts @@ -0,0 +1,35 @@ +// umami's provisioner — the adapter that makes umami a provider of the mesh `analytics` interface. +// The watching, sealing and grant-file handling are the sdk harness's; this writes only the +// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045). +// +// The `analytics` interface: a consumer contributes `{ domain }` (the site it wants tracked) and +// receives `{ siteId, snippet, dashboard }`. umami adapts its own API to that contract, so a +// consumer depends on `analytics`, not on umami. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { UmamiClient } from "../client.js"; + +const umami = UmamiClient.fromEnv(); + +runProvisioner("analytics", { + async create(grant: Grant): Promise { + const domain = grant.values.domain ?? grant.consumer; + const name = grant.values.name ?? domain; + const token = await umami.getToken(); + const site = (await umami.findWebsite(token, domain)) ?? (await umami.createWebsite(token, domain, name)); + return { + fields: { + siteId: site.id, + snippet: umami.snippet(site.id), + dashboard: umami.dashboard(site.id), + }, + }; + }, + + async remove(grant: Grant): Promise { + const domain = grant.values.domain ?? grant.consumer; + const token = await umami.getToken(); + const site = await umami.findWebsite(token, domain); + if (site) await umami.deleteWebsite(token, site.id); + }, +}); diff --git a/modules/umami/tools/index.ts b/modules/umami/tools/index.ts new file mode 100644 index 0000000..88b8f28 --- /dev/null +++ b/modules/umami/tools/index.ts @@ -0,0 +1,61 @@ +// umami's tools — moved here from the shared sdk (novox/hq ADR 0044). They import umami's own +// client (../client) and plug into the mesh through the sdk's tool harness. Editing them rebuilds +// umami and nothing else. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { UmamiClient } from "../client.js"; + +export function getUmamiTools(umami: UmamiClient): ToolDefinition[] { + return [ + { + name: "umami_create_site", + description: "Register a website in umami and return its tracking snippet.", + input: { + domain: { type: "string", description: "the site domain, e.g. my-app.example" }, + name: { type: "string", description: "display name (defaults to the domain)" }, + }, + run: async (args) => { + const domain = String(args.domain); + const name = args.name ? String(args.name) : domain; + const token = await umami.getToken(); + const existing = await umami.findWebsite(token, domain); + const site = existing ?? (await umami.createWebsite(token, domain, name)); + return { + created: !existing, + id: site.id, + name: site.name, + domain: site.domain, + snippet: umami.snippet(site.id), + dashboard: umami.dashboard(site.id), + }; + }, + }, + { + name: "umami_delete_site", + description: "Delete a website from umami (destructive).", + input: { + domain: { type: "string", description: "the site domain to delete" }, + confirm: { type: "boolean", description: "must be true to delete" }, + }, + run: async (args) => { + if (args.confirm !== true) return { deleted: false, reason: "confirm must be true" }; + const domain = String(args.domain); + const token = await umami.getToken(); + const site = await umami.findWebsite(token, domain); + if (!site) return { deleted: false, reason: `no website with domain ${domain}` }; + await umami.deleteWebsite(token, site.id); + return { deleted: true, id: site.id, domain: site.domain }; + }, + }, + ]; +} + +// The module contributes its tools as a function of its resolved environment; the client lives in +// the module, so a token being absent simply yields no tools rather than a failure. +registerModuleTools("umami", (env) => { + try { + return getUmamiTools(UmamiClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/umami/tsconfig.json b/modules/umami/tsconfig.json new file mode 100644 index 0000000..f560283 --- /dev/null +++ b/modules/umami/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/**/*.ts", "provisioner/**/*.ts"] +}