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"] +}