diff --git a/modules/baserow/client.ts b/modules/baserow/client.ts new file mode 100644 index 0000000..59921aa --- /dev/null +++ b/modules/baserow/client.ts @@ -0,0 +1,114 @@ +// The Baserow API client — baserow's own code, living in the module (novox/hq ADR 0039). Both this +// module's tools and anything else baserow-specific import it; nothing outside baserow does. +// +// Baserow authenticates a person with email + password, exchanged for a JWT at /api/user/token-auth/. +// Those credentials are the mesh's own: a person signs up in Baserow (the standard image creates no +// admin from env), and the credential is placed in the runtime config file the mesh mounts. Until +// that happens fromEnv throws and the module simply exposes no tools — the same dormant-until- +// configured shape gitea uses for its token. + +import { readFileSync } from "node:fs"; + +export interface BaserowApplication { + id: number; + name: string; + type: string; +} + +export interface BaserowRow { + id: number; + [key: string]: unknown; +} + +function meshConfig(file?: string): Record { + if (!file) return {}; + try { + return JSON.parse(readFileSync(file, "utf8")) as Record; + } catch { + return {}; + } +} + +export class BaserowClient { + readonly baseUrl: string; + private token: string | null = null; + + constructor( + url: string, + private readonly email: string, + private readonly password: string, + private readonly hostHeader?: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. URL, credentials and an optional Host override + * come from the runtime config file first (MESH_BASEROW_CONFIG_FILE), then the mesh's own env + * names, then the bare BASEROW_* names. Credentials are required — without them there is no + * authenticated call to make, so this throws rather than hand back a client that fails on first use. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): BaserowClient { + const cfg = meshConfig(env.MESH_BASEROW_CONFIG_FILE); + const url = cfg.url ?? env.MESH_BASEROW_URL ?? env.BASEROW_URL ?? "http://127.0.0.1:80"; + const email = cfg.email ?? env.MESH_BASEROW_EMAIL ?? env.BASEROW_EMAIL; + const password = cfg.password ?? env.MESH_BASEROW_PASSWORD ?? env.BASEROW_PASSWORD; + // Baserow's bundled Caddy routes by the Host header against BASEROW_PUBLIC_URL; a co-located + // caller reaching it over the container network may need to present that host. + const hostHeader = cfg.host ?? env.MESH_BASEROW_HOST; + if (!email || !password) { + throw new Error("no Baserow credentials — set MESH_BASEROW_EMAIL and MESH_BASEROW_PASSWORD"); + } + return new BaserowClient(url, email, password, hostHeader); + } + + private headers(extra: Record = {}): Record { + const h: Record = { "Content-Type": "application/json", ...extra }; + if (this.hostHeader) h.Host = this.hostHeader; + return h; + } + + /** Exchange email + password for a JWT, caching it for the client's lifetime. Handles both the + * older `{ token }` and the newer `{ access_token }` response shapes. */ + async authenticate(): Promise { + if (this.token) return this.token; + const res = await fetch(`${this.baseUrl}/api/user/token-auth/`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ email: this.email, password: this.password }), + }); + if (!res.ok) throw new Error(`baserow auth failed: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { token?: string; access_token?: string }; + const token = data.access_token ?? data.token; + if (!token) throw new Error("baserow auth returned no token"); + this.token = token; + return token; + } + + private async authed(path: string, options: RequestInit = {}): Promise { + const token = await this.authenticate(); + const res = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: this.headers({ + Authorization: `JWT ${token}`, + ...(options.headers as Record | undefined), + }), + }); + if (!res.ok) throw new Error(`baserow ${path}: ${res.status} ${await res.text()}`); + const text = await res.text(); + return (text ? JSON.parse(text) : null) as T; + } + + /** The applications (databases) the account can see, across all its workspaces. */ + async listApplications(): Promise { + return (await this.authed(`/api/applications/`)) ?? []; + } + + /** Rows of a table, by numeric table id, with human field names. */ + async listRows(tableId: number, size = 100): Promise { + const data = await this.authed<{ results: BaserowRow[] }>( + `/api/database/rows/table/${tableId}/?size=${size}&user_field_names=true`, + ); + return data?.results ?? []; + } +} diff --git a/modules/baserow/module.json b/modules/baserow/module.json new file mode 100644 index 0000000..ccea7ce --- /dev/null +++ b/modules/baserow/module.json @@ -0,0 +1,113 @@ +{ + "module": "baserow", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "redis-cache" + ], + "contributes": { + "postgres-database": { + "name": "baserow" + }, + "redis-cache": {} + }, + "binds": { + "postgres-database": "/var/lib/baserow/database.json", + "redis-cache": "/var/lib/baserow/redis.json" + }, + "secrets": { + "postgres-database": "/var/lib/baserow/database.secret", + "redis-cache": "/var/lib/baserow/redis.secret" + }, + "own-secrets": { + "secret-key": "/var/lib/baserow/secret-key.secret", + "broker": "/var/lib/mesh/baserow/broker" + }, + "listens": [ + { + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "the Baserow web UI and REST API; a public name is a route grant later" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/baserow", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/baserow", + "mode": "0700" + }, + { + "id": "data", + "type": "directory", + "path": "/services/baserow/data", + "mode": "0700", + "owner": "9999:9999" + }, + { + "id": "server-env", + "type": "file", + "path": "/var/lib/baserow/server.env", + "mode": "0600", + "content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=baserow\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD=${secret:postgres-database}\nREDIS_HOST=${bound:redis-cache:at}\nREDIS_PORT=${bound:redis-cache:port}\nREDIS_PROTOCOL=redis\nREDIS_PASSWORD=${secret:redis-cache}\nSECRET_KEY=${secret:secret-key}\nBASEROW_PUBLIC_URL=http://localhost\n" + }, + { + "id": "net", + "type": "network", + "name": "baserow" + }, + { + "id": "server", + "type": "container", + "name": "baserow", + "image": "baserow/baserow@sha256:834424a10413798567f76428f255dc259445b7f8dcec56598c05b4073bb2a124", + "network": "baserow", + "env-file": [ + "/var/lib/baserow/server.env" + ], + "ports": [ + "80" + ], + "volumes": [ + "/services/baserow/data:/baserow/data" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/baserow/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-baserow", + "image": "mesh-runtime-baserow@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "baserow", + "volumes": [ + "/var/lib/mesh/baserow/broker:/run/secrets/broker:ro", + "/var/lib/mesh/baserow/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_BASEROW_URL": "http://baserow:80", + "MESH_BASEROW_CONFIG_FILE": "/run/config/config.json" + }, + "restart-on": [ + "runtime-config" + ] + } + ] +} diff --git a/modules/baserow/package.json b/modules/baserow/package.json new file mode 100644 index 0000000..5e788b5 --- /dev/null +++ b/modules/baserow/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-baserow", + "version": "0.1.0", + "description": "baserow — open-source no-code database. A consumer of a mesh Postgres database and Redis cache, with its own tools (novox/hq ADR 0039).", + "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/baserow/tools/index.ts b/modules/baserow/tools/index.ts new file mode 100644 index 0000000..1b1fe2e --- /dev/null +++ b/modules/baserow/tools/index.ts @@ -0,0 +1,38 @@ +// baserow's tools — baserow's own code (novox/hq ADR 0039). They import baserow's own client +// (../client) and plug into the mesh through the sdk's tool harness. Editing them rebuilds baserow +// and nothing else. Absent credentials yield no tools rather than a failure. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { BaserowClient } from "../client.js"; + +export function getBaserowTools(baserow: BaserowClient): ToolDefinition[] { + return [ + { + name: "baserow_list_databases", + description: "List the Baserow applications (databases) the configured account can see.", + input: {}, + run: async () => ({ applications: await baserow.listApplications() }), + }, + { + name: "baserow_list_rows", + description: "List rows in a Baserow table by its numeric id.", + input: { + table_id: { type: "number", description: "the numeric table id" }, + size: { type: "number", description: "max rows to return (default 100)" }, + }, + run: async (args) => { + const tableId = Number(args.table_id); + const size = args.size ? Number(args.size) : 100; + return { rows: await baserow.listRows(tableId, size) }; + }, + }, + ]; +} + +registerModuleTools("baserow", (env) => { + try { + return getBaserowTools(BaserowClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/baserow/tsconfig.json b/modules/baserow/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/baserow/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/index.ts"] +} diff --git a/modules/invoicing/module.json b/modules/invoicing/module.json new file mode 100644 index 0000000..cb0e049 --- /dev/null +++ b/modules/invoicing/module.json @@ -0,0 +1,98 @@ +{ + "module": "invoicing", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "mongodb-database", + "s3-bucket" + ], + "contributes": { + "mongodb-database": { + "name": "invoicing" + }, + "s3-bucket": { + "bucket": "invoicing" + } + }, + "binds": { + "mongodb-database": "/var/lib/invoicing/database.json", + "s3-bucket": "/var/lib/invoicing/store.json" + }, + "secrets": { + "mongodb-database": "/var/lib/invoicing/database.secret", + "s3-bucket": "/var/lib/invoicing/store.secret" + }, + "listens": [ + { + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "the invoicing web frontend; a public name is a route grant later" + }, + { + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the invoicing REST API the frontend and integrations call" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/invoicing", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/invoicing", + "mode": "0700" + }, + { + "id": "api-env", + "type": "file", + "path": "/var/lib/invoicing/api.env", + "mode": "0600", + "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" + }, + { + "id": "net", + "type": "network", + "name": "invoicing" + }, + { + "id": "app", + "type": "container", + "name": "invoicing-app", + "image": "registry-api.example/novox/invoicing-app:latest", + "network": "invoicing", + "env": { + "UID": "2201", + "GID": "2201" + }, + "ports": [ + "80" + ] + }, + { + "id": "api", + "type": "container", + "name": "invoicing-api", + "image": "registry-api.example/novox/invoicing-api:latest", + "network": "invoicing", + "env": { + "UID": "2201", + "GID": "2201" + }, + "env-file": [ + "/var/lib/invoicing/api.env" + ], + "ports": [ + "9000" + ] + } + ] +} diff --git a/modules/letta/client.ts b/modules/letta/client.ts new file mode 100644 index 0000000..6e194db --- /dev/null +++ b/modules/letta/client.ts @@ -0,0 +1,85 @@ +// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools +// import it; nothing outside letta does. +// +// Letta authenticates with a single server password, presented as a Bearer token. That password is +// a mesh own-secret, minted once and handed to both the server (LETTA_SERVER_PASSWORD) and this +// client (MESH_LETTA_PASSWORD) — so the module's tools are live without anything configured by hand. +// The runtime config file may still override the URL or password. + +import { readFileSync } from "node:fs"; + +export interface LettaAgent { + id: string; + name: string; +} + +export interface LettaMessage { + id?: string; + role?: string; + text?: string; + [key: string]: unknown; +} + +function meshConfig(file?: string): Record { + if (!file) return {}; + try { + return JSON.parse(readFileSync(file, "utf8")) as Record; + } catch { + return {}; + } +} + +export class LettaClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly password: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. URL and password come from the runtime config + * file first (MESH_LETTA_CONFIG_FILE), then the mesh's own names, then the bare LETTA_* names. A + * password is required — Letta rejects unauthenticated calls when SECURE is on — so this throws + * rather than hand back a client that fails on first use. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): LettaClient { + const cfg = meshConfig(env.MESH_LETTA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_LETTA_URL ?? env.LETTA_URL ?? "http://127.0.0.1:8283"; + const password = cfg.password ?? env.MESH_LETTA_PASSWORD ?? env.LETTA_SERVER_PASSWORD; + if (!password) throw new Error("no Letta password — set MESH_LETTA_PASSWORD"); + return new LettaClient(url, password); + } + + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.password}`, + ...(options.headers as Record | undefined), + }, + }); + if (!res.ok) throw new Error(`letta ${path}: ${res.status} ${await res.text()}`); + if (res.status === 204) return null as T; + const text = await res.text(); + return (text ? JSON.parse(text) : null) as T; + } + + async listAgents(): Promise { + return (await this.request(`/v1/agents/`)) ?? []; + } + + async getMessages(agentId: string, limit = 20): Promise { + return (await this.request(`/v1/agents/${agentId}/messages?limit=${limit}`)) ?? []; + } + + async sendMessage(agentId: string, text: string): Promise { + return this.request(`/v1/agents/${agentId}/messages`, { + method: "POST", + body: JSON.stringify({ messages: [{ role: "user", content: text }] }), + }); + } +} diff --git a/modules/letta/module.json b/modules/letta/module.json new file mode 100644 index 0000000..6dd5afa --- /dev/null +++ b/modules/letta/module.json @@ -0,0 +1,109 @@ +{ + "module": "letta", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "letta" + } + }, + "binds": { + "postgres-database": "/var/lib/letta/database.json" + }, + "secrets": { + "postgres-database": "/var/lib/letta/database.secret" + }, + "own-secrets": { + "server-password": "/var/lib/letta/server-password.secret", + "broker": "/var/lib/mesh/letta/broker" + }, + "listens": [ + { + "port": 8283, + "protocol": "tcp", + "from": "mesh", + "why": "the Letta agent server REST API and web UI; a public name is a route grant later" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/letta", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/letta", + "mode": "0700" + }, + { + "id": "server-env", + "type": "file", + "path": "/var/lib/letta/server.env", + "mode": "0600", + "content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/letta\nLETTA_SERVER_PASSWORD=${secret:server-password}\nSECURE=true\nTZ=Europe/Brussels\n" + }, + { + "id": "net", + "type": "network", + "name": "letta" + }, + { + "id": "server", + "type": "container", + "name": "letta", + "image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b", + "network": "letta", + "env-file": [ + "/var/lib/letta/server.env" + ], + "ports": [ + "8283" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/letta/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime-env", + "type": "file", + "path": "/var/lib/letta/runtime.env", + "mode": "0600", + "content": "MESH_LETTA_PASSWORD=${secret:server-password}\n" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-letta", + "image": "mesh-runtime-letta@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "letta", + "volumes": [ + "/var/lib/mesh/letta/broker:/run/secrets/broker:ro", + "/var/lib/mesh/letta/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_LETTA_URL": "http://letta:8283", + "MESH_LETTA_CONFIG_FILE": "/run/config/config.json" + }, + "env-file": [ + "/var/lib/letta/runtime.env" + ], + "restart-on": [ + "runtime-config" + ] + } + ] +} diff --git a/modules/letta/package.json b/modules/letta/package.json new file mode 100644 index 0000000..5246a82 --- /dev/null +++ b/modules/letta/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-letta", + "version": "0.1.0", + "description": "letta — AI agent framework. A consumer of a mesh Postgres database, with its own tools (novox/hq ADR 0039).", + "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/letta/tools/index.ts b/modules/letta/tools/index.ts new file mode 100644 index 0000000..785a34e --- /dev/null +++ b/modules/letta/tools/index.ts @@ -0,0 +1,51 @@ +// letta's tools — letta's own code (novox/hq ADR 0039). They import letta's own client (../client) +// and plug into the mesh through the sdk's tool harness. Editing them rebuilds letta and nothing +// else. Absent a password they yield no tools rather than a failure. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { LettaClient } from "../client.js"; + +export function getLettaTools(letta: LettaClient): ToolDefinition[] { + return [ + { + name: "letta_list_agents", + description: "List the Letta agents on the server.", + input: {}, + run: async () => ({ agents: await letta.listAgents() }), + }, + { + name: "letta_get_messages", + description: "Fetch recent messages from a Letta agent's conversation.", + input: { + agent_id: { type: "string", description: "the agent id" }, + limit: { type: "number", description: "max messages to return (default 20)" }, + }, + run: async (args) => { + const agentId = String(args.agent_id); + const limit = args.limit ? Number(args.limit) : 20; + return { messages: await letta.getMessages(agentId, limit) }; + }, + }, + { + name: "letta_send_message", + description: "Send a user message to a Letta agent and return its reply.", + input: { + agent_id: { type: "string", description: "the agent id" }, + text: { type: "string", description: "the message to send" }, + }, + run: async (args) => { + const agentId = String(args.agent_id); + const text = String(args.text); + return { reply: await letta.sendMessage(agentId, text) }; + }, + }, + ]; +} + +registerModuleTools("letta", (env) => { + try { + return getLettaTools(LettaClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/letta/tsconfig.json b/modules/letta/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/letta/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/index.ts"] +}