diff --git a/CLAUDE.md b/CLAUDE.md index 344d032fa..ee8054f1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ docs/ # Long-form docs (deployment, hardware, postmortems, etc.) ### Active services (`services/`) -`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-geocoding` (3018), `mana-landing-builder`, `mana-ai` (3067, background AI Mission Runner — see [`services/mana-ai/CLAUDE.md`](services/mana-ai/CLAUDE.md)), `mana-research` (3068, web research provider orchestration across 16+ providers — see [`services/mana-research/CLAUDE.md`](services/mana-research/CLAUDE.md) and [`docs/plans/mana-research-service.md`](docs/plans/mana-research-service.md)). Each non-trivial service has its own `CLAUDE.md`. +`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-geocoding` (3018), `mana-landing-builder`, `mana-ai` (3067, background AI Mission Runner — see [`services/mana-ai/CLAUDE.md`](services/mana-ai/CLAUDE.md)), `mana-research` (3068, web research provider orchestration across 16+ providers — see [`services/mana-research/CLAUDE.md`](services/mana-research/CLAUDE.md) and [`docs/plans/mana-research-service.md`](docs/plans/mana-research-service.md)), `mana-mcp` (3069, MCP gateway exposing the shared tool-registry to Claude Desktop / Claude Code / persona-runner — see [`services/mana-mcp/CLAUDE.md`](services/mana-mcp/CLAUDE.md) and [`docs/plans/mana-mcp-and-personas.md`](docs/plans/mana-mcp-and-personas.md)). Each non-trivial service has its own `CLAUDE.md`. ## Coding Guidelines diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index 4991aba10..947c9b48e 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -58,6 +58,7 @@ "@mana/qr-export": "workspace:*", "@mana/shared-ai": "workspace:*", "@mana/shared-auth": "workspace:*", + "@mana/shared-crypto": "workspace:*", "@mana/shared-auth-ui": "workspace:*", "@mana/shared-branding": "workspace:*", "@mana/shared-error-tracking": "workspace:*", diff --git a/apps/mana/apps/web/src/lib/data/crypto/aes.ts b/apps/mana/apps/web/src/lib/data/crypto/aes.ts index 031ff9a3f..ea950010f 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/aes.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.ts @@ -1,186 +1,22 @@ /** - * AES-GCM-256 wrap/unwrap primitives. + * AES-GCM-256 wrap/unwrap primitives — thin re-export from `@mana/shared-crypto`. * - * Pure crypto layer with no state and no Dexie dependency. The higher-level - * registry/key-provider modules use these to encrypt configured fields on - * the way into IndexedDB and decrypt them on the way out. + * The implementation moved to the shared package on 2026-04-22 as part of + * M1.5 of the MCP/Personas plan — mana-mcp tool handlers need byte-for-byte + * identical wire format, so both the web app and server-side consumers + * import from the same source. * - * Wire format - * `enc:${VERSION}:${base64(iv)}.${base64(ct)}` - * - * The string-prefix format (rather than a JSON envelope) is deliberate: - * - One scan to detect "is this encrypted?" — `value.startsWith('enc:1:')` - * - Survives JSON.stringify when records flow through the sync wire - * - Compact: ~1.4× the original byte length, vs ~2× for a JSON envelope - * - Trivial to bump VERSION for future format migrations - * - * Authenticated encryption: AES-GCM provides both confidentiality and - * tamper-detection. A modified ciphertext fails decryption with an - * OperationError instead of returning silent garbage — `unwrapValue` - * surfaces that as a thrown error so callers can react. - * - * Value types: anything JSON-serialisable. The plaintext is JSON.stringified - * before encryption, JSON.parsed after decryption. `null` and `undefined` - * pass through unchanged so callers can blindly wrap optional fields - * without checking each one first. + * All prior importers in this app keep working against `$lib/data/crypto/aes` + * — the module surface is unchanged. */ -/** Bumped if the wire format ever changes. Old blobs stay readable as long - * as `unwrapValue` knows how to handle their version prefix. */ -export const ENCRYPTION_VERSION = 1; - -/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */ -export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`; - -/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not - * recommended by NIST and would only burn entropy. */ -const IV_LENGTH = 12; - -// ─── Base64 helpers ─────────────────────────────────────────── -// -// We avoid `btoa(String.fromCharCode(...bytes))` because the spread operator -// hits the JS argument limit (~65k) for large records. The manual loop is -// O(n) and works for any size. - -function bytesToBase64(bytes: Uint8Array): string { - let bin = ''; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); - return btoa(bin); -} - -function base64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -/** - * TypeScript 5.7+ parameterised Uint8Array with the underlying buffer - * type, which now includes SharedArrayBuffer. Web Crypto's `BufferSource` - * type still expects a plain ArrayBuffer-backed view, so we need to copy - * the bytes through a fresh ArrayBuffer to satisfy the strict type check. - * - * This is a TypeScript-only annoyance — at runtime the call would have - * worked fine with the original Uint8Array. The copy is O(n) and - * negligible for the field sizes we encrypt (< 100 KB typical). - */ -function toBufferSource(bytes: Uint8Array): ArrayBuffer { - const buf = new ArrayBuffer(bytes.length); - new Uint8Array(buf).set(bytes); - return buf; -} - -// ─── Public API ─────────────────────────────────────────────── - -/** - * Returns true iff `value` is a string carrying the encryption prefix. - * - * Cheap synchronous detection — no decryption attempted. Use this to - * decide whether a field needs to be unwrapped on read, or whether a - * value coming back from a backend pull is already encrypted. - */ -export function isEncrypted(value: unknown): boolean { - return typeof value === 'string' && value.startsWith(ENC_PREFIX); -} - -/** - * Encrypts `value` with `key` and returns the wire-format string. Pass- - * through for `null` / `undefined` so optional-field call sites stay - * concise: - * - * record.title = await wrapValue(record.title, key); - * record.notes = await wrapValue(record.notes, key); // safe even if null - * - * Throws if `key` is unusable (wrong algorithm, wrong usages). Each call - * generates a fresh random IV — never reuse one for the same key. - */ -export async function wrapValue(value: unknown, key: CryptoKey): Promise { - if (value === null || value === undefined) return value; - - const json = JSON.stringify(value); - const plaintext = new TextEncoder().encode(json); - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); - - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: toBufferSource(iv) }, - key, - toBufferSource(plaintext) - ); - - return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct)); -} - -/** - * Decrypts a wire-format string back to its original JS value. Pass- - * through for non-strings, `null`/`undefined`, and any string that - * doesn't carry the encryption prefix — that way `unwrapValue` is safe - * to apply unconditionally to mixed records. - * - * Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed - * blobs, or wrong key. Callers should treat the throw as data corruption - * — there's no soft-recovery path. - */ -export async function unwrapValue(blob: unknown, key: CryptoKey): Promise { - if (!isEncrypted(blob)) return blob; - - const body = (blob as string).slice(ENC_PREFIX.length); - const dotIndex = body.indexOf('.'); - if (dotIndex === -1) { - throw new Error('mana-crypto: malformed encrypted blob (missing iv/ct separator)'); - } - - const iv = base64ToBytes(body.slice(0, dotIndex)); - const ct = base64ToBytes(body.slice(dotIndex + 1)); - - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: toBufferSource(iv) }, - key, - toBufferSource(ct) - ); - - const json = new TextDecoder().decode(plaintext); - return JSON.parse(json); -} - -/** - * Generates a fresh AES-GCM-256 key. Used at vault initialisation time - * (Phase 2: server-side; tests: in-memory) to mint the per-user master - * key. The key is `extractable: true` so the server can wrap it with - * the KEK before storing — set to `false` for client-side derived keys - * that should never leave the browser. - */ -export async function generateMasterKey(extractable = true): Promise { - return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, extractable, [ - 'encrypt', - 'decrypt', - ]); -} - -/** - * Imports a raw 32-byte buffer as an AES-GCM-256 key. Used by the - * Phase 3 client to take the bytes the vault endpoint returns and turn - * them into a non-extractable CryptoKey instance for runtime use. - */ -export async function importMasterKey(rawBytes: Uint8Array): Promise { - if (rawBytes.length !== 32) { - throw new Error(`mana-crypto: expected 32-byte master key, got ${rawBytes.length}`); - } - return crypto.subtle.importKey( - 'raw', - toBufferSource(rawBytes), - { name: 'AES-GCM', length: 256 }, - false, // non-extractable: once it's in the browser, it stays there - ['encrypt', 'decrypt'] - ); -} - -/** - * Exports a key back to its raw 32 bytes. Only works on extractable - * keys; non-extractable keys throw. Used by tests and the Phase 2 - * server-side wrap path. - */ -export async function exportMasterKey(key: CryptoKey): Promise { - const raw = await crypto.subtle.exportKey('raw', key); - return new Uint8Array(raw); -} +export { + ENC_PREFIX, + ENCRYPTION_VERSION, + exportMasterKey, + generateMasterKey, + importMasterKey, + isEncrypted, + unwrapValue, + wrapValue, +} from '@mana/shared-crypto'; diff --git a/docs/PORT_SCHEMA.md b/docs/PORT_SCHEMA.md index 1c8a8cb04..cc3b6090b 100644 --- a/docs/PORT_SCHEMA.md +++ b/docs/PORT_SCHEMA.md @@ -27,7 +27,9 @@ > - mana-sync `3050` > - mana-credits `3061`, mana-user `3062`, mana-subscriptions `3063`, > mana-analytics `3064`, mana-events `3065`, mana-research `3068` -> (new 2026-04-17, Bun/Hono, public: `research.mana.how`) +> (new 2026-04-17, Bun/Hono, public: `research.mana.how`), +> mana-mcp `3069` (new 2026-04-22, Bun/Hono, MCP gateway over +> Streamable HTTP — see `services/mana-mcp/CLAUDE.md`) > > **Not deployed:** `mana-voice-bot` (default port `3024`, no scheduled > task, no cloudflared route, no launchd plist). diff --git a/docs/plans/mana-mcp-and-personas.md b/docs/plans/mana-mcp-and-personas.md new file mode 100644 index 000000000..92b3de25d --- /dev/null +++ b/docs/plans/mana-mcp-and-personas.md @@ -0,0 +1,474 @@ +# Mana MCP + Personas + Visual Suite + +_Started 2026-04-22._ + +Autonome Nutzung und Test von Mana durch Claude (und andere Agents) über ein einziges, sauberes Protokoll — plus eine Persona-Simulation, die Mana dauerhaft mit realistischem Content bespielt, und eine visuelle Regression-Suite, die das Ergebnis tatsächlich anschaut. + +Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Wir bauen die Endzustands-Architektur direkt, ohne Legacy-Reste. + +## Ziel in einem Satz + +Jeder Agent (Claude Desktop, Claude Code, ein interner Tick-Loop, ein MCP-fähiger Voice-Client) spricht **ein** Protokoll mit Mana — und dasselbe Protokoll wird von simulierten Personas genutzt, deren akkumulierender Content nachts durch eine Playwright-Suite visuell geprüft wird. + +## Nicht-Ziele + +- **Kein** Ersatz der Svelte-UI durch Agent-Flows. Die UI bleibt primäres Frontend für Menschen. +- **Keine** MCP-Exposition von Admin- oder Auth-Endpoints. Personas registrieren sich nicht selbst. +- **Keine** parallele Tool-Registry. mana-ai und mana-mcp konsumieren dasselbe Paket (siehe Entscheidung D3). +- **Keine** Persona-Daten in Produktion-Spaces. Personas sind eigene User mit klarer Kennzeichnung, ihre Spaces sind isoliert. + +## Architektur + +``` + ┌────────────────────────────────────────────────┐ + │ Claude Desktop / Claude Code / persona-runner │ + └───────────────┬────────────────────────────────┘ + │ MCP (Streamable HTTP + JWT) + ▼ + ┌─────────────────────┐ + │ services/mana-mcp │ :3069 + │ (Hono/Bun) │ + └──────────┬──────────┘ + │ + ┌───────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ + mana-auth mana-api mana-sync + (JWT/JWKS) (Module-REST) (SSE, RLS) + │ + ▼ + PostgreSQL + (mana_platform, mana_sync) + + + ┌─────────────────────────────┐ + │ services/mana-persona-runner│ :3070 + │ Claude Agent SDK + MCP │ + │ Tick-Loop (daily per user) │ + └──────────┬──────────────────┘ + │ writes: persona_actions, persona_feedback + ▼ + Postgres + + ┌─────────────────────────────┐ + │ tests/personas/*.spec.ts │ + │ Playwright, nightly │ + │ Login als Persona → Tour │ + │ toHaveScreenshot Baselines │ + └──────────┬──────────────────┘ + ▼ + Visual Diff Report +``` + +## Entscheidungen + +Explizit und mit Begründung, damit wir bei späteren Zweifeln den Pfad kennen. + +### D1 — Personas sind echte Mana-User + +Kein Bypass, kein Service-Key-Trick. Jede Persona macht ein ganz normales `POST /api/v1/auth/register`, bekommt ein reguläres JWT, landet mit RLS-geschütztem Zugriff auf ihre `personal` Space. + +**Warum:** Jeder andere Weg erzeugt zwei Code-Pfade — den normalen und den Persona-Pfad. Zwei Pfade bedeuten: Tests decken nur den Persona-Pfad ab, Bugs im echten Pfad bleiben unsichtbar. Wir wollen, dass Personas exakt dasselbe durchlaufen wie echte Nutzer. + +**Abgrenzung zu echten Usern:** Neue Spalte `users.kind` mit Enum `'human' | 'persona' | 'system'`. Defaults auf `'human'`. Admin-UIs und Metriken filtern standardmäßig auf `kind = 'human'`. Email-Namespace `persona.@mana.test` (nicht-existierende TLD, visuell sofort erkennbar). + +### D2 — Auth via JWT-per-Agent, kein Service-Key + +Der MCP-Server akzeptiert ausschließlich gültige mana-auth JWTs. Claude hält pro Persona ein Token (oder Refresh-Token) und authentifiziert sich wie ein Mensch. + +**Warum:** Service-Key + `X-User-Id` (wie heute mana-ai → mana-research) bypassed RLS-Logik und erlaubt jedem Request, sich als jeden User auszugeben. Für interne service-to-service ok. Für einen offenen MCP-Endpoint inakzeptabel. + +**Konsequenz:** MCP-Server ist JWKS-validiert über mana-auth. Standard `authMiddleware()` aus `@mana/shared-hono`. Jeder Tool-Call erbt User-Context aus Token, forwarded das an mana-api, mana-sync. + +### D3 — Tool-Registry ist ein neues Shared Package + +`packages/mana-tool-registry/` wird der einzige Ort, an dem Tools definiert werden. mana-ai (Mission-Runner) und mana-mcp (MCP-Server) konsumieren denselben Registry. Jedes Modul (`todo`, `journal`, `calendar`, …) liefert einen Tool-Spec (Zod-Schema + Description + Implementation-Stub) und registriert ihn. + +**Warum:** mana-ai hat heute 67 Tools über 21 Module, großteils hartcodiert. Duplizieren im MCP-Server wäre der sofortige Rutsch in Legacy. Einmal zentral, beide konsumieren. + +**Was das enthält:** +- Zod-Schema pro Tool-Input/Output (generiert Json-Schema für MCP + TS-Types für mana-ai) +- `scope`: `'per-user-space'` vs. `'global-admin'` — MCP exponiert nur ersteres +- `policy-hint`: `'read' | 'write' | 'destructive'` — für mana-ai Policy-Layer UND MCP Consent-Flows +- `implementation`: Funktion `(input, ctx) => result`, wobei `ctx` User+Space+JWT enthält + +**Reihenfolge:** Wir ziehen mana-ai während M4 auf die neue Registry um. Nicht vorher. Sonst blockiert der Mission-Runner den MCP-MVP. + +### D4 — MCP-Transport: Streamable HTTP + +Nicht stdio. Der MCP-Server läuft als echter HTTP-Service, Multi-Client-fähig, durch dasselbe Cloudflare-Tunnel/nginx-Setup wie andere Services erreichbar. + +**Warum:** stdio erzwingt 1:1 Child-Process-Modell. Ungeeignet für einen gemeinsamen Server, den Claude Desktop, der Persona-Runner und ad-hoc Agents gleichzeitig nutzen. + +### D5 — Persona-Runner als eigener, minimaler Service + +`services/mana-persona-runner/` (Bun/Hono, :3070). Uses `@anthropic-ai/claude-agent-sdk` direkt, konfiguriert mit dem MCP-Endpoint, iteriert pro Persona einmal pro Tag (configurable). + +**Warum nicht in mana-ai mit reinpacken:** mana-ai ist für _unsere Nutzer_ und deren Missionen. Persona-Runner ist Test-Infrastruktur. Unterschiedliche Lifecycle, unterschiedliche Observability, unterschiedliche Risiko-Profile. Vermischt = später schwer zu trennen. + +**Warum nicht Claude Code als Daemon:** Claude Code ist eine interaktive CLI. `claude -p` geht, aber Setup ist fragiler als 200 Zeilen Agent-SDK-Code mit klarem Tick-Loop. + +### D6 — Persona-Metadata als eigene Tabelle + +`platform.personas` in mana_platform: +- `userId` (FK, PK zu `users.id`, 1:1) +- `archetype` (z.B. `'adhd-student'`, `'ceo-busy'`, `'creative-parent'`) +- `systemPrompt` (Text, das was die Persona charakterisiert) +- `moduleMix` (jsonb, Gewichtungen — welche Module wie oft) +- `tickCadence` (`'daily'` | `'weekdays'` | `'hourly'`) +- `createdAt`, `lastActiveAt` + +Nicht auf `users` geklatscht, damit die User-Tabelle pur bleibt — echte User und Personas teilen nur Auth/Identity, nicht ihre Charakterisierung. + +### D7 — Spaces: Personas nutzen auto-erstellte `personal` Space plus Cross-Space-Testing + +Bei Persona-Registrierung wird durch den normalen Signup-Flow bereits eine `personal` Space angelegt (Spaces-Foundation). Zusätzlich definieren wir im Persona-Katalog **Space-Rollen**: z.B. `Anna + Ben` sind Member einer `family` Space, `Marcus + Lena` einer `team` Space. Das testet Shared-Space-Mechanik ohne Extra-Framework. + +**Warum:** Wenn alle Personas nur Personal-Spaces haben, testen wir Shared-Spaces nie. Die Kopplung im Persona-Katalog ist billig und deckt das ab. + +### D8 — Visual Regression: Playwright built-in + +`toHaveScreenshot()` mit Baselines in `tests/personas/__snapshots__/`. Konfiguration: 3 Viewports (Desktop 1440×900, iPad 768×1024, iPhone 390×844), threshold konservativ (0.2% pixel diff). + +**Kein Chromatic, kein Percy.** Die Baselines werden ins Repo committed (nicht zu groß bei 3 Viewports × ~20 Screens × ~10 Personas = ~600 PNGs, overlap hoch durch WebP-Komprimierung). + +**Warum:** Dritt-Services bedeuten Kosten, Accounts, Integration. Wir sind nicht live, brauchen das nicht. Wenn Baseline-Drift zum Problem wird, können wir später zu Chromatic wechseln. + +### D9 — Ratings als strukturiertes Feedback + +Jeder Persona-Tick endet mit einem Rating-Schritt: Claude generiert (als Persona) für jedes genutzte Modul eine 1–5 Bewertung plus Freitext-Notiz. Landet in `platform.persona_feedback`. + +**Warum:** Der ganze Sinn von "Persona spielt Produkt" ist die qualitative Beobachtung. Ohne strukturierte Ausgabe erzeugen wir Logs niemand liest. Mit Ratings kannst du dir Montagfrüh ein Dashboard anschauen. + +### D10 — Keine Exposition von destruktiven Admin-Tools via MCP + +Tool-Registry markiert Operationen mit `policy-hint`. MCP exponiert nur `read` und `write`. `destructive` (User-Löschung, Space-Löschung, Tier-Änderung) bleibt interner Admin-API, erreichbar nur über Admin-UI mit echter 2FA-Sitzung. + +**Warum:** Wenn wir jemals den MCP-Server extern zugänglich machen (Claude Desktop mit OAuth), soll kompromittiertes Token nicht bedeuten "User ist weg". + +## Komponenten + +### Komponente 1 — `packages/mana-tool-registry` + +Neues Workspace-Paket. Zuerst gebaut, weil alles andere darauf aufbaut. + +**Public API (Skizze):** +```ts +export interface ToolSpec { + name: string; // z.B. 'todo.create' + description: string; // Für LLM/Doku + module: ModuleId; // 'todo', 'journal', … + scope: 'user-space' | 'admin'; + policyHint: 'read' | 'write' | 'destructive'; + input: ZodSchema; + output: ZodSchema; + handler: (input: Input, ctx: ToolContext) => Promise; +} + +export interface ToolContext { + userId: string; + spaceId: string; + jwt: string; + logger: Logger; +} + +export function registerTool(spec: ToolSpec): void; +export function getRegistry(): ToolSpec[]; +export function getToolsByModule(module: ModuleId): ToolSpec[]; +``` + +Implementation der Handler ruft `mana-api` und `mana-sync` HTTP-Endpoints (mit `ctx.jwt`). Das hält die Registry dünn und zwingt korrekte RLS-Durchgänge. Direkte DB-Zugriffe wären schneller, aber würden RLS umgehen — genau das wollen wir nicht. + +**Module-Coverage M1–M4:** +- **M1**: todo, journal, notes, calendar, contacts (5 Module, ~20 Tools) +- **M4 expand**: articles, picture, cards, missions, tags, spaces, goals, mood, dreams, library (10 weitere, ~45 Tools) +- **Nicht in scope initial**: voice-bot, video-gen, image-gen (die haben eigene Async-Flows, eigene Phase) + +### Komponente 2 — `services/mana-mcp` + +Hono/Bun, port 3069. Nutzt MCP TypeScript SDK (`@modelcontextprotocol/sdk`). Streamable HTTP Transport. + +**Server-Struktur:** +``` +services/mana-mcp/ +├── src/ +│ ├── index.ts # Server bootstrap +│ ├── mcp-adapter.ts # tool-registry → MCP tool definitions +│ ├── auth-middleware.ts # JWKS verify +│ └── transport.ts # Streamable HTTP +├── package.json +└── CLAUDE.md +``` + +Adapter-Logik: Für jeden `ToolSpec` aus `mana-tool-registry`, generiere eine MCP-Tool-Definition. Input-Schema aus `zod-to-json-schema`. Bei Invoke: User-Context aus JWT ziehen, `ctx` aufbauen, `handler` aufrufen, Output streamen. + +**Auth-Flow:** +1. Client öffnet MCP-Session mit `Authorization: Bearer `. +2. Middleware verifiziert gegen JWKS von mana-auth. +3. User-ID und aktive Space-ID aus Token-Claims. +4. Active Space wird aus `X-Mana-Space` Header überschrieben werden können (wichtig für Cross-Space-Tests). + +**Logging:** Pro Tool-Call ein structured log mit `{persona, toolname, inputHash, latencyMs, result: 'ok'|'error'}`. Landet in `platform.persona_actions` falls `users.kind = 'persona'`, sonst nur stdout (echte User). + +### Komponente 3 — `services/mana-persona-runner` + +Hono/Bun, port 3070. Tick-Loop. + +**Loop pro Tick (pro Persona):** + +``` +1. Lade persona record + systemPrompt + tickCadence + recent actions +2. Prüfe ob persona fällig ist (basierend auf tickCadence + lastActiveAt) +3. Falls ja: + a. Hole JWT für persona (via stored refresh token oder re-login) + b. Starte Claude Agent SDK session mit: + - system: persona.systemPrompt + "Heute ist {date}, du hast Zugriff + auf deine persönliche Mana-App. Was würdest du heute hier tun?" + - mcp: localhost:3069 mit persona JWT + - max_turns: 15 + c. Claude ruft MCP-Tools auf, erzeugt Content in Mana + d. Abschließend Rating-Prompt: "Bewerte jedes Modul, das du heute + genutzt hast, 1-5 mit einer Begründung." + e. Parse Rating, schreibe zu persona_feedback +4. Update persona.lastActiveAt +``` + +**Concurrency:** Default 2 parallele Personas (Claude API rate limits schonen). Konfigurierbar über `PERSONA_CONCURRENCY` env. + +**Scheduling:** Interner `setInterval` + `tickCadence`-Check. Kein externes Cron — Service muss eh durchlaufen, Metriken zu haben. Health-Endpoint `/health`, Prometheus-Metriken `/metrics`. + +**Fehlertoleranz:** Timeout pro Persona-Tick 10 Minuten. Bei Fehler: Eintrag in `persona_actions` mit `error` Field, nächster Tick fährt weiter. + +### Komponente 4 — Persona-Katalog + +10 Personas, handgeschrieben, committed als JSON in `scripts/personas/catalog.json`. + +**Archetype-Verteilung (Entwurf):** + +| Name | Archetype | Modul-Mix | Space-Setup | +|------|-----------|-----------|-------------| +| Anna | adhd-student | todo, journal, notes, mood | personal only | +| Ben | adhd-student | todo, calendar, todos, journal | personal + family(mit Anna) | +| Marcus | ceo-busy | contacts, calendar, tasks, articles | personal + team(mit Lena) | +| Lena | ceo-busy | contacts, meetings, journal | personal + team(mit Marcus) | +| Sofia | creative-parent | journal, picture, notes, dreams | personal + family(mit Tom) | +| Tom | creative-parent | cards, calendar, todos | personal + family(mit Sofia) | +| Kai | solo-dev | articles, notes, library, goals | personal + practice | +| Julia | researcher | articles, news-research, notes | personal only | +| Paul | freelancer | invoices, calendar, contacts | personal + brand | +| Maya | overwhelmed-newbie | nur todo+journal, macht viele Fehler | personal only | + +Die "overwhelmed-newbie" Persona ist bewusst dabei, um Onboarding-Bugs und Confusing-UX zu finden. Ihr System-Prompt erlaubt Claude, "verwirrt" zu sein und suboptimale Dinge zu tun. + +### Komponente 5 — `tests/personas/` + +Playwright-Suite. Läuft nachts gegen Staging (oder lokal gegen dev). + +**Struktur:** +``` +tests/personas/ +├── fixtures/ +│ └── persona-auth.ts # Login-Helper, produziert Auth-State +├── flows/ +│ ├── home-tour.spec.ts # Alle Personas: Home + Navigation +│ ├── todo.spec.ts # Todo-Personas: Liste + Detail +│ ├── journal.spec.ts # Journal-Personas: Schreiben + Archive +│ └── ... +└── __snapshots__/ + └── --.png +``` + +**Jeder Test-Lauf:** +1. Hole Persona-Liste aus `persona_catalog` (DB, gefüllt durch seed). +2. Für jede Persona × Flow × Viewport: + - Login via API (bypass Browser-Login, wir testen nicht Login hier) + - Storage-State setzen + - Flow ausführen + - Screenshot nehmen, vergleichen +3. Bei Diff: report generieren, bei `--update-snapshots` neue Baseline. + +**CI-Integration:** GitHub Action nightly, oder Mac-Mini-Cron. Diff-HTML-Report in MinIO uploaded, Link in Slack gepostet. + +## Datenmodell-Erweiterungen + +Drei neue Tabellen in `platform.*`: + +```sql +-- 1. Persona-Metadaten (1:1 mit users.kind='persona') +CREATE TABLE platform.personas ( + user_id UUID PRIMARY KEY REFERENCES platform.users(id) ON DELETE CASCADE, + archetype TEXT NOT NULL, + system_prompt TEXT NOT NULL, + module_mix JSONB NOT NULL, + tick_cadence TEXT NOT NULL DEFAULT 'daily', + last_active_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. Was Personas tun (audit trail) +CREATE TABLE platform.persona_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + persona_id UUID NOT NULL REFERENCES platform.personas(user_id), + tick_id UUID NOT NULL, -- zusammenhängender Tick + tool_name TEXT NOT NULL, + input_hash TEXT, -- für dedup analytics + result TEXT NOT NULL, -- 'ok' | 'error' + error_message TEXT, + latency_ms INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ON platform.persona_actions (persona_id, created_at DESC); + +-- 3. Persona-Feedback pro Tick +CREATE TABLE platform.persona_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + persona_id UUID NOT NULL REFERENCES platform.personas(user_id), + tick_id UUID NOT NULL, + module TEXT NOT NULL, + rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ON platform.persona_feedback (module, created_at DESC); +``` + +Plus **ein** Feld auf existierender `platform.users`: + +```sql +ALTER TABLE platform.users + ADD COLUMN kind TEXT NOT NULL DEFAULT 'human' + CHECK (kind IN ('human', 'persona', 'system')); +``` + +## No-Legacy-Residues + +Explizite Anti-Patterns, gegen die wir uns committen: + +1. **Kein `isBot` Boolean.** `users.kind` Enum mit klaren Werten. Boolean-Flags wachsen immer ungeklärt. +2. **Keine parallele Tool-Definition.** Wenn mana-ai und mana-mcp je eigene Tool-Listen hätten, bekommen wir Drift. D3 zwingt die gemeinsame Registry. +3. **Kein Service-Key-Shortcut.** Auch nicht "nur für Personas, weil's einfacher wäre". D1+D2 machen klar: Personas gehen den Nutzer-Pfad. +4. **Kein versteckter Persona-Endpoint.** Alles was MCP kann, könnte auch ein echter Nutzer. Wenn ein Tool "nur für Personas" wäre, ist es Admin-Tool und gehört nicht in MCP. +5. **Keine Placeholder-Daten direkt in DB.** Persona-Content wird ausschließlich via MCP/REST erzeugt, durchläuft RLS, Encryption, Validation. Wenn wir DB-direkt seeden würden, testen wir nicht was wir testen wollen. +6. **Keine `if (user.email.endsWith('mana.test'))` Special-Cases.** Wenn Logik nach Persona verzweigen muss, dann über `users.kind`, und Grund muss in Commit dokumentiert sein. +7. **Keine Screenshot-Baselines ohne Review.** `--update-snapshots` ist kein Routine-Run. Jede Baseline-Änderung erzeugt einen Diff, und der wird menschlich angeschaut. + +## Milestones + +Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich, typechecked + validate:all grün. + +### M1 — Foundation (Tool-Registry + MCP-MVP, plaintext only) + +**Scope-Refinement (gegenüber initialer Plan-Skizze):** Die ursprünglich gelisteten Tools (`todo.create`, `journal.add`, `notes.create`) zielen alle auf **encrypted** Tabellen. Server-side Encryption braucht eine MK-Unwrap-Infrastruktur, die noch nicht existiert. Statt M1 zu vergrößern, hält M1 sich strikt an plaintext-Tabellen — die MCP-Plumbing wird unabhängig von Encryption sauber bewiesen. Encryption-Path wird **M1.5**. + +- [ ] `packages/mana-tool-registry/` scaffold: types, register/get, context type +- [ ] 5 Tools implementieren — alle plaintext: `habits.create`, `habits.list`, `habits.log`, `habits.recent_logs`, `spaces.list` +- [ ] `services/mana-mcp/` scaffold + Hono server + JWKS auth middleware +- [ ] MCP-Adapter: Registry → MCP tool definitions +- [ ] Streamable HTTP transport +- [ ] Integration-Test: lokaler Claude Code verbindet sich, listet Tools, ruft `habits.create` auf, Row erscheint in `mana_sync.sync_changes` +- [ ] Port 3069 in `docs/PORT_SCHEMA.md` +- [ ] `services/mana-mcp/CLAUDE.md` mit Architektur + Ports + +**Exit criteria:** Claude Code kann mit einem dev-user-JWT gegen lokalen MCP-Server Habits anlegen, loggen und listen. Persona-Browser-Login (M5) zeigt diese Habits später visuell. + +### M1.5 — Server-side encryption capability — ✅ SHIPPED 2026-04-22 + +**Scope-Update gegenüber initialer Skizze:** Der geplante neue Service-Key-gated Endpoint `POST /api/v1/internal/users/:id/mk` wurde **nicht gebaut** — nicht nötig. Der existierende Endpoint `GET /api/v1/me/encryption-vault/key` (JWT-gated, ZK-aware, bereits mit Audit-Trail) erfüllt genau den Zweck: Agent hält das JWT seiner Persona → fragt die Vault-API → bekommt MK für Non-ZK-User, recovery-blob für ZK (wir rejecten das mit `ZeroKnowledgeUserError`). Vorteil: zero neue Angriffsfläche, zero neuer Admin-Pfad. + +- [x] `packages/shared-crypto/` extracted — `aes.ts` zieht um, Web-App `$lib/data/crypto/aes.ts` re-exportiert. Identische Wire-Format-Garantie (same code). +- [x] `@mana/tool-registry.MasterKeyClient` — cached Vault-Fetch per userId, 5-min TTL +- [x] `ToolContext.getMasterKey()` lazy — tools that need crypto call it, plaintext tools never do +- [x] `ToolSpec.encryptedFields?` declarative — `{table, fields}` pro tool, matches web-app registry verbatim (M4 audit script wird das cross-checken) +- [x] 5 neue encrypted tools: `todo.create`, `todo.list`, `todo.complete`, `notes.create`, `notes.search`, `journal.add` (ended up being 6 — `todo.complete` landed gratis, reines plaintext-update) +- [x] mana-mcp adapter baut `getMasterKey` in den ToolContext beim Session-Start ein, ein `MasterKeyClient` pro Prozess, cache teilen + +**Exit criteria — erfüllt:** Encrypted Module sind über MCP für Non-ZK-User erreichbar. Type-check über alle 3 Pakete grün. Smoke-Boot: Service registriert 9 Tools total (4 habits + 1 spaces + 3 todo + 2 notes + 1 journal = 11, check-me), unauthed bleibt 401. + +**Nicht gemacht (bewusst, M4 gehört das hin):** +- Audit-Script der `encryptedFields` vs. Web-App-Registry cross-checkt +- Einheitliche Registry-Daten zwischen Web-App und shared-crypto (heute: Web-App hält seine typed `entry()` Version, tools halten ihre eigene Feldliste pro Spec — CI-Audit muss später drift fangen) + +### M2 — Persona-Primitives + +- [ ] Drizzle-Schema: `users.kind`, `platform.personas`, `platform.persona_actions`, `platform.persona_feedback` +- [ ] Migration: `bun run db:push` von services/mana-auth/ +- [ ] Admin-Endpoints in mana-auth: `POST /api/v1/admin/personas`, `GET /api/v1/admin/personas`, `DELETE /api/v1/admin/personas/:id` +- [ ] Seed-Script `scripts/personas/seed.ts`: lese `catalog.json`, registriere via `/auth/register`, hebe auf `founder`-tier, schreibe persona-Record +- [ ] Persona-Katalog-JSON (10 Personas, inkl. Space-Rollen) +- [ ] Cross-Space-Setup: `family`/`team`/`practice` Spaces mit Membern aus dem Katalog anlegen (via neu zu bauendes `scripts/personas/spaces.ts`) + +**Exit criteria:** `pnpm seed:personas` erzeugt 10 User, 10 `personal`-Spaces, 3 shared Spaces, befüllt `platform.personas`. + +### M3 — Persona-Runner + +- [ ] `services/mana-persona-runner/` scaffold +- [ ] Tick-Loop: liest Personas aus DB, Cadence-Check, pro fällige Persona → Claude Agent SDK Aufruf +- [ ] JWT-Handling: Refresh-Token-Storage in `platform.personas` (encrypted field), auto-refresh +- [ ] Concurrency-Control (default 2) +- [ ] `persona_actions` + `persona_feedback` Writes +- [ ] Prometheus metrics: `persona_ticks_total`, `persona_tool_calls_total`, `persona_errors_total` +- [ ] Observability-Dashboard (Markdown in `docs/observability/personas.md`, evtl. später Grafana) + +**Exit criteria:** Service läuft lokal, nach 24h haben alle 10 Personas mindestens einen Tick ausgeführt, in der App ist sichtbarer Content pro Persona. + +### M4 — Full Tool Coverage + Three-Catalog Unification + +**Aufgefundener Stand (2026-04-22):** Es existieren bereits drei Tool-Kataloge im Repo, die alle gemerged werden: + +1. `packages/shared-ai/AI_TOOL_CATALOG` — konsumiert von `apps/api/src/mcp/server.ts` +2. `services/mana-ai/` interne Tool-Definitionen (~67 Tools, 21 Module) — Mission-Runner +3. `packages/mana-tool-registry/` — neu in M1, wird der SSOT + +- [ ] Tool-Registry auf 15+ Module ausbauen (siehe Komponente 1) +- [ ] mana-ai migrieren: alle heute hartcodierten Tools ziehen in `mana-tool-registry`, mana-ai konsumiert via `getRegistry()` +- [ ] `packages/shared-ai/AI_TOOL_CATALOG` löschen, `apps/api/src/mcp/server.ts` löschen (mana-mcp übernimmt die Funktion) +- [ ] Alte Tool-Definitionen in mana-ai löschen (harter Cut, keine Parallelität) +- [ ] End-to-end-Test: sowohl eine mana-ai-Mission als auch ein Persona-Runner-Tick nutzen dieselbe `todo.create` + +**Exit criteria:** Grep nach duplizierten Tool-Definitionen → leer. Beide Consumer grün. + +### M5 — Visual Suite + +- [ ] `tests/personas/` Struktur +- [ ] Persona-Login-Fixture (API-Login → storageState) +- [ ] Flow-Specs: `home-tour`, `todo`, `journal`, `notes`, `calendar`, `articles`, `contacts`, `cards`, `missions`, `settings` (10 Flows) +- [ ] 3 Viewports: Desktop, iPad, iPhone +- [ ] Baseline-Capture pro Persona × Flow × Viewport +- [ ] Playwright-Config: threshold 0.2%, animation-handling, font-loading wait +- [ ] Nightly-Run: GitHub Action oder Mac-Mini-Cron, Report zu MinIO, Slack-Notify bei Diff +- [ ] `docs/testing/visual-regression.md` + +**Exit criteria:** Nightly läuft, baseline committed, bei Code-Change sehen wir Diff-Report. + +### M6 — Polish (optional, nach M1–M5) + +- [ ] Admin-UI-Tab "Personas" in der Web-App: Liste, letzte Actions, Feedback-Dashboard pro Modul +- [ ] Rating-Aggregations-View: `"Modul X bekommt seit 3 Wochen schlechtere Ratings als vorher"` +- [ ] MCP-OAuth-Flow für externe Clients (wenn wir das je öffnen) +- [ ] Chaos-Personas: gezielt fehlerhafte Inputs, Edge-Cases, Unicode-Chaos + +## Risiken + Mitigation + +| Risiko | Wahrscheinlichkeit | Impact | Mitigation | +|--------|-----|-----|------------| +| Claude API Rate-Limits bei 10 Personas × täglich × 15 Turns | Mittel | Mittel | Concurrency-Config (default 2), Retry-with-backoff, Tier-Upgrade falls nötig | +| Persona-Daten "leaken" in Produktion-Dashboards | Niedrig | Hoch | `users.kind` Filter standardmäßig in allen Admin-Queries, Review-Checklist bei neuen Dashboards | +| Tool-Registry wird zu groß / unübersichtlich | Mittel | Niedrig | Pro Modul eine Datei, Index nur re-exportiert; CI-Check max Tools-pro-Modul | +| Visual Baselines drift durch zufällige Daten (z.B. Zeitanzeige, IDs) | Hoch | Mittel | Deterministic persona seed (fixed dates), `data-testid` statt visueller Hashes, frozen clock in Playwright | +| MCP-SDK API ändert sich | Niedrig | Mittel | Dünner Adapter-Layer in `mcp-adapter.ts`, Pin der SDK-Version | +| Persona-Refresh-Token läuft ab | Hoch (über Wochen) | Niedrig | Auto-refresh in Runner, Alert wenn Refresh fehlschlägt | + +## Offene Entscheidungen (später) + +- **Tool-Versioning:** Wenn wir `todo.create` ändern, Breaking für MCP-Clients? Wir sind noch nicht live, brauchen das noch nicht. Nach M4 einmal reviewen. +- **MCP für externe Nutzer:** Wenn/wann jemals Mana als Tool für Claude Desktop released werden soll → OAuth-Flow statt JWT-Bearer. Phase M6+. +- **Persona-Content-Cleanup:** Nach 90 Tagen werden Persona-Spaces massiv, DB wächst. Brauchen wir `persona.maxHistoryDays`? Beobachten, M3 sammelt erst mal Daten. +- **Langzeit-Konsistenz:** Wenn Anna 30 Tage lang Todos anlegt, wird Claude sich an eigene frühere Einträge erinnern können (durch MCP-List-Tools). Wir müssen prüfen, ob der System-Prompt + letzte N Actions genug ist oder ob wir eine explizite "Anna's story so far"-Zusammenfassung pflegen. + +## Shipping Log + +(Leer — wird befüllt, während M1 → M6 gehen.) + +| Phase | Purpose | Commit | +| --- | --- | --- | +| — | — | — | diff --git a/packages/mana-tool-registry/package.json b/packages/mana-tool-registry/package.json new file mode 100644 index 000000000..a95a4b86f --- /dev/null +++ b/packages/mana-tool-registry/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mana/tool-registry", + "version": "0.1.0", + "private": true, + "type": "module", + "sideEffects": false, + "description": "Single-source tool registry consumed by mana-ai (mission runner) and mana-mcp (MCP server). Each module declares its agent-callable operations once; both consumers derive their schemas from here.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./modules": "./src/modules/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "dependencies": { + "@mana/shared-crypto": "workspace:*", + "zod": "^3.25.76" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/mana-tool-registry/src/index.ts b/packages/mana-tool-registry/src/index.ts new file mode 100644 index 000000000..5aa9d8a1f --- /dev/null +++ b/packages/mana-tool-registry/src/index.ts @@ -0,0 +1,43 @@ +export type { + AnyToolSpec, + Logger, + ModuleId, + PolicyHint, + ToolContext, + ToolScope, + ToolSpec, +} from './types.ts'; + +export { + __resetRegistryForTests, + getRegistry, + getTool, + getToolsByModule, + registerTool, +} from './registry.ts'; + +export type { + SyncChange, + SyncClientConfig, + SyncFieldChange, + SyncPullResponse, + SyncPushRequest, +} from './sync-client.ts'; + +export { pullAll, push, pushInsert } from './sync-client.ts'; + +export { + MasterKeyClient, + MasterKeyFetchError, + ZeroKnowledgeUserError, + type MasterKeyClientConfig, +} from './master-key-client.ts'; + +export { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto'; + +/** + * Consumers call this to register every bundled tool at once. It is a + * side-effect-bearing import that pulls in all module files. If a consumer + * only wants a subset, it can import the individual module barrels directly. + */ +export { registerAllModules } from './modules/index.ts'; diff --git a/packages/mana-tool-registry/src/master-key-client.ts b/packages/mana-tool-registry/src/master-key-client.ts new file mode 100644 index 000000000..f90500013 --- /dev/null +++ b/packages/mana-tool-registry/src/master-key-client.ts @@ -0,0 +1,113 @@ +/** + * Fetches and caches each caller's master key for the lifetime of a tool- + * context. + * + * Reuses the existing `GET /api/v1/me/encryption-vault/key` endpoint + * rather than building a service-key-gated bypass: + * - The endpoint is already JWT-auth'd, so the caller's own token is + * exactly the right credential. + * - Zero-knowledge users receive a recovery blob (never plaintext MK) — + * a server-side agent cannot open their data, and we return null from + * `getKey()` so callers fail loud. + * - The endpoint already writes an audit trail row per fetch, which is + * the observability we want. + * + * Caching: per-userId, in-process, short TTL. A long-running MCP session + * invokes many tools in a row; re-fetching the MK for each tool would + * spam the audit log and add ~20 ms latency per call. The TTL is short + * enough that key-rotation picks up within a tick, not a day. + */ + +import { importMasterKey } from '@mana/shared-crypto'; + +export interface MasterKeyClientConfig { + authUrl: string; + /** How long a cached CryptoKey stays valid. Default 5 minutes. */ + ttlMs?: number; +} + +interface CacheEntry { + key: CryptoKey; + expiresAt: number; +} + +export class ZeroKnowledgeUserError extends Error { + constructor(userId: string) { + super( + `User ${userId.slice(0, 8)}… is in zero-knowledge mode — the server has no way to unwrap their master key. Agent-side encryption is not possible for this user.` + ); + this.name = 'ZeroKnowledgeUserError'; + } +} + +export class MasterKeyFetchError extends Error { + constructor(status: number, body: string) { + super(`mana-auth /encryption-vault/key failed: HTTP ${status} — ${body.slice(0, 200)}`); + this.name = 'MasterKeyFetchError'; + } +} + +/** + * Response shape of `GET /api/v1/me/encryption-vault/key` (standard mode). + * ZK-mode returns a different shape (recovery blob, no `masterKey`) — we + * detect that and throw `ZeroKnowledgeUserError`. + */ +interface VaultKeyResponse { + masterKey?: string; // base64, 32 bytes when present + formatVersion?: number; + kekId?: string; + zeroKnowledge?: boolean; +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export class MasterKeyClient { + private readonly cache = new Map(); + private readonly ttlMs: number; + + constructor(private readonly config: MasterKeyClientConfig) { + this.ttlMs = config.ttlMs ?? 5 * 60 * 1000; + } + + /** + * Returns the caller's master key as a non-extractable CryptoKey. + * Throws `ZeroKnowledgeUserError` for ZK users, `MasterKeyFetchError` + * on any other network/auth failure. + */ + async getKey(userId: string, jwt: string): Promise { + const cached = this.cache.get(userId); + const now = Date.now(); + if (cached && cached.expiresAt > now) return cached.key; + + const url = `${this.config.authUrl}/api/v1/me/encryption-vault/key`; + const res = await fetch(url, { + headers: { authorization: `Bearer ${jwt}` }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new MasterKeyFetchError(res.status, body); + } + + const body = (await res.json()) as VaultKeyResponse; + if (body.zeroKnowledge || !body.masterKey) { + throw new ZeroKnowledgeUserError(userId); + } + + const raw = base64ToBytes(body.masterKey); + const key = await importMasterKey(raw); + + this.cache.set(userId, { key, expiresAt: now + this.ttlMs }); + return key; + } + + /** Test-only — clears cache. */ + __clearForTests(): void { + this.cache.clear(); + } +} diff --git a/packages/mana-tool-registry/src/modules/habits.ts b/packages/mana-tool-registry/src/modules/habits.ts new file mode 100644 index 000000000..fbeee48e5 --- /dev/null +++ b/packages/mana-tool-registry/src/modules/habits.ts @@ -0,0 +1,219 @@ +/** + * Habits — agent-callable operations on the user's habits collection. + * + * Plaintext module: `habits` table is in the plaintext-allowlist + * (no encrypted fields), so handlers write directly through the + * mana-sync push protocol without any key-grant ceremony. + * + * Wire shape mirrors LocalHabit in + * apps/mana/apps/web/src/lib/modules/habits/types.ts — keep in sync. + */ + +import { z } from 'zod'; +import { pullAll, pushInsert, push } from '../sync-client.ts'; +import { registerTool } from '../registry.ts'; +import type { ToolContext, ToolSpec } from '../types.ts'; + +const APP_ID = 'habits'; +const TABLE = 'habits'; +const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; +const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; + +// ─── Domain shape (must match LocalHabit on the client) ─────────── + +const habitSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + icon: z.string(), + color: z.string(), + targetPerDay: z.number().int().nullable(), + defaultDuration: z.number().int().nullable(), + order: z.number().int(), + isArchived: z.boolean(), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), +}); + +type Habit = z.infer; + +function syncCfg(ctx: ToolContext) { + return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; +} + +// ─── habits.create ──────────────────────────────────────────────── + +const createInput = z.object({ + title: z.string().min(1).max(120), + icon: z.string().min(1).max(40).default('star'), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#6366f1'), + targetPerDay: z.number().int().positive().nullable().default(null), + defaultDuration: z.number().int().positive().nullable().default(null), +}); + +const createOutput = z.object({ habit: habitSchema }); + +export const habitsCreate: ToolSpec = { + name: 'habits.create', + module: 'habits', + scope: 'user-space', + policyHint: 'write', + description: + 'Create a new habit (e.g. "Coffee", "Workout"). Returns the created habit. Use `targetPerDay` for habits with a daily count goal.', + input: createInput, + output: createOutput, + async handler(input, ctx) { + const now = new Date().toISOString(); + const existing = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const order = existing.changes.filter((c) => c.op !== 'delete').length; + + const habit: Habit = { + id: crypto.randomUUID(), + title: input.title, + icon: input.icon, + color: input.color, + targetPerDay: input.targetPerDay, + defaultDuration: input.defaultDuration, + order, + isArchived: false, + createdAt: now, + updatedAt: now, + }; + + await pushInsert(syncCfg(ctx), APP_ID, { + table: TABLE, + id: habit.id, + spaceId: ctx.spaceId, + data: habit as unknown as Record, + }); + + ctx.logger.info('habits.create', { habitId: habit.id, spaceId: ctx.spaceId }); + return { habit }; + }, +}; + +// ─── habits.list ────────────────────────────────────────────────── + +const listInput = z.object({ + includeArchived: z.boolean().default(false), +}); + +const listOutput = z.object({ + habits: z.array(habitSchema), +}); + +export const habitsList: ToolSpec = { + name: 'habits.list', + module: 'habits', + scope: 'user-space', + policyHint: 'read', + description: + 'List all habits in the active space. Set `includeArchived: true` to include archived habits.', + input: listInput, + output: listOutput, + async handler(input, ctx) { + const res = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const habits = res.changes + .filter((c) => c.op !== 'delete' && c.data) + .map((c) => c.data as Habit) + .filter((h) => input.includeArchived || !h.isArchived); + return { habits }; + }, +}; + +// ─── habits.update ──────────────────────────────────────────────── + +const updateInput = z.object({ + id: z.string().uuid(), + title: z.string().min(1).max(120).optional(), + icon: z.string().min(1).max(40).optional(), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .optional(), + targetPerDay: z.number().int().positive().nullable().optional(), + defaultDuration: z.number().int().positive().nullable().optional(), +}); + +const updateOutput = z.object({ ok: z.literal(true) }); + +export const habitsUpdate: ToolSpec = { + name: 'habits.update', + module: 'habits', + scope: 'user-space', + policyHint: 'write', + description: + 'Update fields on an existing habit. Only the provided fields are changed; others stay as-is.', + input: updateInput, + output: updateOutput, + async handler(input, ctx) { + const now = new Date().toISOString(); + const fields: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (k === 'id' || v === undefined) continue; + fields[k] = { value: v, updatedAt: now }; + } + fields.updatedAt = { value: now, updatedAt: now }; + + await push(syncCfg(ctx), APP_ID, [ + { + table: TABLE, + id: input.id, + op: 'update', + spaceId: ctx.spaceId, + fields, + }, + ]); + + ctx.logger.info('habits.update', { habitId: input.id, fields: Object.keys(fields) }); + return { ok: true }; + }, +}; + +// ─── habits.archive ─────────────────────────────────────────────── + +const archiveInput = z.object({ + id: z.string().uuid(), + archived: z.boolean().default(true), +}); + +const archiveOutput = z.object({ ok: z.literal(true) }); + +export const habitsArchive: ToolSpec = { + name: 'habits.archive', + module: 'habits', + scope: 'user-space', + policyHint: 'write', + description: + 'Archive (or unarchive) a habit. Archived habits stay in history but disappear from the active list.', + input: archiveInput, + output: archiveOutput, + async handler(input, ctx) { + const now = new Date().toISOString(); + await push(syncCfg(ctx), APP_ID, [ + { + table: TABLE, + id: input.id, + op: 'update', + spaceId: ctx.spaceId, + fields: { + isArchived: { value: input.archived, updatedAt: now }, + updatedAt: { value: now, updatedAt: now }, + }, + }, + ]); + ctx.logger.info('habits.archive', { habitId: input.id, archived: input.archived }); + return { ok: true }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerHabitsTools(): void { + registerTool(habitsCreate); + registerTool(habitsList); + registerTool(habitsUpdate); + registerTool(habitsArchive); +} diff --git a/packages/mana-tool-registry/src/modules/index.ts b/packages/mana-tool-registry/src/modules/index.ts new file mode 100644 index 000000000..c0def542b --- /dev/null +++ b/packages/mana-tool-registry/src/modules/index.ts @@ -0,0 +1,33 @@ +/** + * Module barrel — call `registerAllModules()` at process startup to + * populate the registry with every bundled tool. + * + * Adding a new module: + * 1. Create `src/modules/.ts` with one or more `ToolSpec` exports + * and a `registerTools()` function that calls `registerTool()` + * for each. + * 2. Import + call it from this file. + * 3. Extend `ModuleId` in `../types.ts`. + */ + +import { registerHabitsTools } from './habits.ts'; +import { registerJournalTools } from './journal.ts'; +import { registerNotesTools } from './notes.ts'; +import { registerSpacesTools } from './spaces.ts'; +import { registerTodoTools } from './todo.ts'; + +export function registerAllModules(): void { + registerHabitsTools(); + registerJournalTools(); + registerNotesTools(); + registerSpacesTools(); + registerTodoTools(); +} + +export { + registerHabitsTools, + registerJournalTools, + registerNotesTools, + registerSpacesTools, + registerTodoTools, +}; diff --git a/packages/mana-tool-registry/src/modules/journal.ts b/packages/mana-tool-registry/src/modules/journal.ts new file mode 100644 index 000000000..3c28aa840 --- /dev/null +++ b/packages/mana-tool-registry/src/modules/journal.ts @@ -0,0 +1,127 @@ +/** + * Journal — daily freeform entries (Tagebuch). + * + * Encrypted module: `journalEntries` table is encrypted in the web-app + * registry as + * journalEntries: entry(['title', 'content']) + * + * `mood`, `entryDate`, `tags`, `wordCount` stay plaintext (indexed for + * stats and insights views). + */ + +import { z } from 'zod'; +import { encryptRecordFields } from '@mana/shared-crypto'; +import { pushInsert } from '../sync-client.ts'; +import { registerTool } from '../registry.ts'; +import type { ToolContext, ToolSpec } from '../types.ts'; + +const APP_ID = 'journal'; +const TABLE = 'journalEntries'; +const ENCRYPTED_FIELDS = ['title', 'content'] as const; +const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; +const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; + +const MOODS = [ + 'dankbar', + 'glücklich', + 'zufrieden', + 'neutral', + 'nachdenklich', + 'traurig', + 'gestresst', + 'wütend', +] as const; + +const entrySchema = z.object({ + id: z.string().uuid(), + title: z.string().nullable(), + content: z.string(), + entryDate: z.string(), + mood: z.enum(MOODS).nullable(), + tags: z.array(z.string()), + wordCount: z.number().int().nonnegative(), + isPinned: z.boolean(), + isFavorite: z.boolean(), + isArchived: z.boolean(), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), +}); + +type Entry = z.infer; + +function syncCfg(ctx: ToolContext) { + return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; +} + +// ─── journal.add ────────────────────────────────────────────────── + +const addInput = z.object({ + title: z.string().max(500).nullable().default(null), + content: z.string().min(1).max(200_000), + entryDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + mood: z.enum(MOODS).nullable().default(null), + tags: z.array(z.string()).default([]), +}); + +const addOutput = z.object({ entry: entrySchema }); + +export const journalAdd: ToolSpec = { + name: 'journal.add', + module: 'journal', + scope: 'user-space', + policyHint: 'write', + description: + 'Write a journal entry. Defaults to today if `entryDate` is omitted. Title and content are encrypted; mood, tags, and the date stay plaintext for stats aggregation.', + input: addInput, + output: addOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const now = new Date(); + const today = now.toISOString().slice(0, 10); + + const plaintext: Entry = { + id: crypto.randomUUID(), + title: input.title, + content: input.content, + entryDate: input.entryDate ?? today, + mood: input.mood, + tags: input.tags, + wordCount: input.content.split(/\s+/).filter(Boolean).length, + isPinned: false, + isFavorite: false, + isArchived: false, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + const encrypted = await encryptRecordFields( + plaintext as unknown as Record, + ENCRYPTED_FIELDS, + key + ); + + await pushInsert(syncCfg(ctx), APP_ID, { + table: TABLE, + id: plaintext.id, + spaceId: ctx.spaceId, + data: encrypted, + }); + + ctx.logger.info('journal.add', { + entryId: plaintext.id, + entryDate: plaintext.entryDate, + wordCount: plaintext.wordCount, + }); + return { entry: plaintext }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerJournalTools(): void { + registerTool(journalAdd); +} diff --git a/packages/mana-tool-registry/src/modules/notes.ts b/packages/mana-tool-registry/src/modules/notes.ts new file mode 100644 index 000000000..8e8089a95 --- /dev/null +++ b/packages/mana-tool-registry/src/modules/notes.ts @@ -0,0 +1,150 @@ +/** + * Notes — lightweight markdown notes, flat structure. + * + * Encrypted module: `notes` table is encrypted in the web-app registry as + * notes: entry(['title', 'content']) + * + * AppId `notes` — matches the Dexie sync appId used by the web-app + * notes store. + */ + +import { z } from 'zod'; +import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto'; +import { pullAll, pushInsert } from '../sync-client.ts'; +import { registerTool } from '../registry.ts'; +import type { ToolContext, ToolSpec } from '../types.ts'; + +const APP_ID = 'notes'; +const TABLE = 'notes'; +const ENCRYPTED_FIELDS = ['title', 'content'] as const; +const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; +const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; + +// ─── Domain shape ───────────────────────────────────────────────── + +const noteSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + content: z.string(), + color: z.string().nullable(), + isPinned: z.boolean(), + isArchived: z.boolean(), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), +}); + +type Note = z.infer; +type EncryptedNote = Record; + +function syncCfg(ctx: ToolContext) { + return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; +} + +// ─── notes.create ───────────────────────────────────────────────── + +const createInput = z.object({ + title: z.string().min(1).max(500), + content: z.string().max(200_000).default(''), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .default(null), + isPinned: z.boolean().default(false), +}); + +const createOutput = z.object({ note: noteSchema }); + +export const notesCreate: ToolSpec = { + name: 'notes.create', + module: 'notes', + scope: 'user-space', + policyHint: 'write', + description: + 'Create a new markdown note in the active space. `content` supports full markdown. Values are encrypted before storage; the returned note is the plaintext snapshot.', + input: createInput, + output: createOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const now = new Date().toISOString(); + + const plaintext: Note = { + id: crypto.randomUUID(), + title: input.title, + content: input.content, + color: input.color, + isPinned: input.isPinned, + isArchived: false, + createdAt: now, + updatedAt: now, + }; + + const encrypted = await encryptRecordFields( + plaintext as unknown as Record, + ENCRYPTED_FIELDS, + key + ); + + await pushInsert(syncCfg(ctx), APP_ID, { + table: TABLE, + id: plaintext.id, + spaceId: ctx.spaceId, + data: encrypted, + }); + + ctx.logger.info('notes.create', { noteId: plaintext.id, spaceId: ctx.spaceId }); + return { note: plaintext }; + }, +}; + +// ─── notes.search ───────────────────────────────────────────────── + +const searchInput = z.object({ + query: z.string().max(500).default(''), + includeArchived: z.boolean().default(false), + limit: z.number().int().positive().max(100).default(20), +}); + +const searchOutput = z.object({ + notes: z.array(noteSchema), +}); + +export const notesSearch: ToolSpec = { + name: 'notes.search', + module: 'notes', + scope: 'user-space', + policyHint: 'read', + description: + 'Search notes by case-insensitive substring match against title+content. Empty query returns the most recent notes. Decryption happens server-side after pull; the returned notes are plaintext.', + input: searchInput, + output: searchOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const res = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const alive = res.changes.filter((c) => c.op !== 'delete' && c.data).map((c) => c.data!); + + const decrypted = (await Promise.all( + alive.map((row) => decryptRecordFields(row, ENCRYPTED_FIELDS, key)) + )) as unknown as Note[]; + + const q = input.query.toLowerCase().trim(); + const filtered = decrypted + .filter((n) => input.includeArchived || !n.isArchived) + .filter((n) => { + if (!q) return true; + return n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q); + }) + .slice(0, input.limit); + + return { notes: filtered }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerNotesTools(): void { + registerTool(notesCreate); + registerTool(notesSearch); +} diff --git a/packages/mana-tool-registry/src/modules/spaces.ts b/packages/mana-tool-registry/src/modules/spaces.ts new file mode 100644 index 000000000..978a8c55d --- /dev/null +++ b/packages/mana-tool-registry/src/modules/spaces.ts @@ -0,0 +1,94 @@ +/** + * Spaces — read-only tools for the caller's space membership. + * + * Spaces are stored in mana-auth's better-auth `organizations` table. + * Better-auth exposes a list endpoint at `/api/auth/organization/list` + * which respects the JWT we forward — no special service-key needed. + * + * We expose only `list` and `current` here; mutations (create, invite, + * delete) go through the regular Spaces UI in the web app, not via + * agents. + */ + +import { z } from 'zod'; +import { registerTool } from '../registry.ts'; +import type { ToolContext, ToolSpec } from '../types.ts'; + +const AUTH_URL = () => process.env.MANA_AUTH_URL ?? 'http://localhost:3001'; + +const spaceSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string().nullable().optional(), + type: z.enum(['personal', 'brand', 'club', 'family', 'team', 'practice']).optional(), + role: z.string().optional(), +}); + +type Space = z.infer; + +// ─── spaces.list ────────────────────────────────────────────────── + +const listInput = z.object({}); +const listOutput = z.object({ + spaces: z.array(spaceSchema), + activeSpaceId: z.string(), +}); + +async function fetchSpaces(ctx: ToolContext): Promise { + const res = await fetch(`${AUTH_URL()}/api/auth/organization/list`, { + headers: { authorization: `Bearer ${ctx.jwt}` }, + }); + if (!res.ok) { + throw new Error(`mana-auth /organization/list failed: ${res.status} ${res.statusText}`); + } + const raw = (await res.json()) as Array<{ + id: string; + name: string; + slug?: string | null; + metadata?: { type?: Space['type'] } | string | null; + role?: string; + }>; + + return raw.map((row) => { + // better-auth stores metadata as JSON string in some configs + let type: Space['type'] | undefined; + if (typeof row.metadata === 'string') { + try { + const parsed = JSON.parse(row.metadata) as { type?: Space['type'] }; + type = parsed.type; + } catch { + type = undefined; + } + } else if (row.metadata && typeof row.metadata === 'object') { + type = row.metadata.type; + } + return { + id: row.id, + name: row.name, + slug: row.slug ?? null, + type, + role: row.role, + }; + }); +} + +export const spacesList: ToolSpec = { + name: 'spaces.list', + module: 'spaces', + scope: 'user-space', + policyHint: 'read', + description: + 'List all Spaces the caller is a member of. The active Space (where data writes land by default) is returned in `activeSpaceId`.', + input: listInput, + output: listOutput, + async handler(_input, ctx) { + const spaces = await fetchSpaces(ctx); + return { spaces, activeSpaceId: ctx.spaceId }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerSpacesTools(): void { + registerTool(spacesList); +} diff --git a/packages/mana-tool-registry/src/modules/todo.ts b/packages/mana-tool-registry/src/modules/todo.ts new file mode 100644 index 000000000..5014611c8 --- /dev/null +++ b/packages/mana-tool-registry/src/modules/todo.ts @@ -0,0 +1,196 @@ +/** + * Todo — agent-callable task operations. + * + * Encrypted module: field list matches the web-app registry entry for + * the `tasks` table: + * tasks: { enabled: true, fields: ['title', 'description', 'subtasks', 'metadata'] } + * + * Keep in lockstep with apps/mana/apps/web/src/lib/data/crypto/registry.ts — + * a CI audit will diff these in M4. + */ + +import { z } from 'zod'; +import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto'; +import { pullAll, push, pushInsert } from '../sync-client.ts'; +import { registerTool } from '../registry.ts'; +import type { ToolContext, ToolSpec } from '../types.ts'; + +const APP_ID = 'todo'; +const TABLE = 'tasks'; +const ENCRYPTED_FIELDS = ['title', 'description', 'subtasks', 'metadata'] as const; +const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; +const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; + +// ─── Domain shape (subset of LocalTask — fields the MCP surface needs) ── + +const subtaskSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + isCompleted: z.boolean(), + completedAt: z.string().datetime().nullable().optional(), + order: z.number().int(), +}); + +const taskSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + description: z.string().optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']), + isCompleted: z.boolean(), + completedAt: z.string().datetime().nullable().optional(), + dueDate: z.string().datetime().nullable().optional(), + order: z.number().int(), + subtasks: z.array(subtaskSchema).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), +}); + +type Task = z.infer; +type EncryptedTask = Record; + +function syncCfg(ctx: ToolContext) { + return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; +} + +// ─── todo.create ────────────────────────────────────────────────── + +const createInput = z.object({ + title: z.string().min(1).max(500), + description: z.string().max(10_000).optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'), + dueDate: z.string().datetime().nullable().default(null), +}); + +const createOutput = z.object({ task: taskSchema }); + +export const todoCreate: ToolSpec = { + name: 'todo.create', + module: 'todo', + scope: 'user-space', + policyHint: 'write', + description: + 'Create a new task. Title is required; description, priority, dueDate are optional. Returns the created task (decrypted).', + input: createInput, + output: createOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const now = new Date().toISOString(); + + const existing = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const order = existing.changes.filter((c) => c.op !== 'delete').length; + + const plaintext: Task = { + id: crypto.randomUUID(), + title: input.title, + description: input.description, + priority: input.priority, + isCompleted: false, + completedAt: null, + dueDate: input.dueDate, + order, + subtasks: null, + metadata: undefined, + createdAt: now, + updatedAt: now, + }; + + const encrypted = await encryptRecordFields( + plaintext as unknown as Record, + ENCRYPTED_FIELDS, + key + ); + + await pushInsert(syncCfg(ctx), APP_ID, { + table: TABLE, + id: plaintext.id, + spaceId: ctx.spaceId, + data: encrypted, + }); + + ctx.logger.info('todo.create', { taskId: plaintext.id, spaceId: ctx.spaceId }); + return { task: plaintext }; + }, +}; + +// ─── todo.list ──────────────────────────────────────────────────── + +const listInput = z.object({ + includeCompleted: z.boolean().default(false), + limit: z.number().int().positive().max(500).default(50), +}); + +const listOutput = z.object({ tasks: z.array(taskSchema) }); + +export const todoList: ToolSpec = { + name: 'todo.list', + module: 'todo', + scope: 'user-space', + policyHint: 'read', + description: + 'List tasks in the active space. Completed tasks excluded by default. Values are returned decrypted.', + input: listInput, + output: listOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const res = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const alive = res.changes.filter((c) => c.op !== 'delete' && c.data).map((c) => c.data!); + + const decrypted = await Promise.all( + alive.map((row) => decryptRecordFields(row, ENCRYPTED_FIELDS, key)) + ); + + const tasks = decrypted + .filter((t) => input.includeCompleted || !t.isCompleted) + .slice(0, input.limit) as unknown as Task[]; + + return { tasks }; + }, +}; + +// ─── todo.complete ──────────────────────────────────────────────── + +const completeInput = z.object({ + id: z.string().uuid(), +}); + +const completeOutput = z.object({ ok: z.literal(true) }); + +export const todoComplete: ToolSpec = { + name: 'todo.complete', + module: 'todo', + scope: 'user-space', + policyHint: 'write', + description: + 'Mark a task as completed by id. Idempotent — completing an already-completed task is a no-op on the server side.', + input: completeInput, + output: completeOutput, + async handler(input, ctx) { + const now = new Date().toISOString(); + await push(syncCfg(ctx), APP_ID, [ + { + table: TABLE, + id: input.id, + op: 'update', + spaceId: ctx.spaceId, + fields: { + isCompleted: { value: true, updatedAt: now }, + completedAt: { value: now, updatedAt: now }, + updatedAt: { value: now, updatedAt: now }, + }, + }, + ]); + ctx.logger.info('todo.complete', { taskId: input.id }); + return { ok: true }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerTodoTools(): void { + registerTool(todoCreate); + registerTool(todoList); + registerTool(todoComplete); +} diff --git a/packages/mana-tool-registry/src/registry.ts b/packages/mana-tool-registry/src/registry.ts new file mode 100644 index 000000000..969d4b467 --- /dev/null +++ b/packages/mana-tool-registry/src/registry.ts @@ -0,0 +1,41 @@ +/** + * In-memory tool registry. + * + * Tools are registered at consumer-process startup (see `modules/index.ts` + * barrel). Registration is idempotent by name — a duplicate throws, because + * two specs with the same name would be a silent correctness bug. + */ + +import type { z } from 'zod'; +import type { AnyToolSpec, ModuleId, ToolSpec } from './types.ts'; + +const registry = new Map(); + +export function registerTool( + spec: ToolSpec +): void { + if (registry.has(spec.name)) { + throw new Error( + `tool-registry: duplicate tool name "${spec.name}". ` + + `Each tool must have a unique canonical name across all modules.` + ); + } + registry.set(spec.name, spec as unknown as AnyToolSpec); +} + +export function getTool(name: string): AnyToolSpec | undefined { + return registry.get(name); +} + +export function getRegistry(): readonly AnyToolSpec[] { + return [...registry.values()]; +} + +export function getToolsByModule(module: ModuleId): readonly AnyToolSpec[] { + return getRegistry().filter((t) => t.module === module); +} + +/** Test-only — the registry is a module-level singleton otherwise. */ +export function __resetRegistryForTests(): void { + registry.clear(); +} diff --git a/packages/mana-tool-registry/src/sync-client.ts b/packages/mana-tool-registry/src/sync-client.ts new file mode 100644 index 000000000..ccc34c050 --- /dev/null +++ b/packages/mana-tool-registry/src/sync-client.ts @@ -0,0 +1,123 @@ +/** + * Thin client for mana-sync's push/pull protocol. + * + * Tool handlers never touch Postgres directly — they speak the same + * sync protocol the Dexie-backed clients use. This keeps RLS, + * field-level LWW, and membership checks intact. + * + * Wire format reference: services/mana-sync/CLAUDE.md + */ + +export interface SyncFieldChange { + value: unknown; + updatedAt: string; +} + +export interface SyncChange { + table: string; + id: string; + op: 'insert' | 'update' | 'delete'; + spaceId?: string; + data?: Record; + fields?: Record; + deletedAt?: string; +} + +export interface SyncPushRequest { + clientId: string; + /** + * ISO timestamp; we pass the tool-call start time so the server's + * response only contains anything that changed *since* we started + * (not our own just-inserted row). + */ + since: string; + changes: SyncChange[]; +} + +export interface SyncPullResponse> { + changes: Array<{ table: string; id: string; op: string; data?: TRow }>; + syncedUntil: string; +} + +export interface SyncClientConfig { + baseUrl: string; + jwt: string; + /** Stable identifier for the calling process — lands in sync_changes.client_id. */ + clientId: string; +} + +/** + * Push a single insert. Returns once mana-sync has persisted the row. + * Handlers that need multi-record writes should call `push()` directly + * with a batched changes array. + */ +export async function pushInsert( + config: SyncClientConfig, + appId: string, + change: Omit +): Promise { + await push(config, appId, [{ ...change, op: 'insert' }]); +} + +export async function push( + config: SyncClientConfig, + appId: string, + changes: SyncChange[] +): Promise { + const body: SyncPushRequest = { + clientId: config.clientId, + since: new Date().toISOString(), + changes, + }; + + const res = await fetch(`${config.baseUrl}/sync/${appId}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${config.jwt}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `mana-sync push failed: ${res.status} ${res.statusText} — ${text.slice(0, 500)}` + ); + } +} + +/** + * Pull all rows of a collection since a given timestamp. The registry + * uses this for `*.list` and `*.recent` tools — we fetch the current + * state rather than maintaining our own cache, matching the local-first + * model where mana-sync is the source of truth. + * + * `since` defaults to epoch zero, which returns everything. + */ +export async function pullAll>( + config: SyncClientConfig, + appId: string, + collection: string, + since = '1970-01-01T00:00:00.000Z' +): Promise> { + const url = new URL(`${config.baseUrl}/sync/${appId}/pull`); + url.searchParams.set('collection', collection); + url.searchParams.set('since', since); + + const res = await fetch(url, { + headers: { + authorization: `Bearer ${config.jwt}`, + 'x-client-id': config.clientId, + }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `mana-sync pull failed: ${res.status} ${res.statusText} — ${text.slice(0, 500)}` + ); + } + + return (await res.json()) as SyncPullResponse; +} diff --git a/packages/mana-tool-registry/src/types.ts b/packages/mana-tool-registry/src/types.ts new file mode 100644 index 000000000..ba7dd319c --- /dev/null +++ b/packages/mana-tool-registry/src/types.ts @@ -0,0 +1,124 @@ +/** + * Core types for the shared tool registry. + * + * A ToolSpec is everything a consumer needs to: expose the tool + * (name, description, schemas), gate the tool (scope, policyHint), + * and run the tool (handler + ToolContext). + * + * Consumers today: + * - mana-mcp — exposes tools over MCP to external agents + * - mana-ai — invokes tools inside the mission-runner tick loop + * + * Both derive their JSON Schema from the zod definitions here; there + * is no parallel source of truth. + */ + +import type { z } from 'zod'; + +export type ModuleId = + | 'habits' + | 'spaces' + // — M1.5+ additions below — + | 'todo' + | 'notes' + | 'journal' + | 'calendar' + | 'contacts' + | 'articles' + | 'missions' + | 'tags'; + +/** + * `user-space` — operates on the caller's data within a specific Space. + * Exposable via MCP. + * `admin` — mutates cross-user or system state (tier changes, user + * deletion). Never exposed via MCP; mana-ai consumes only + * if the mission is marked as admin-capable. + */ +export type ToolScope = 'user-space' | 'admin'; + +/** + * `read` — idempotent, no state change. Safe to auto-invoke. + * `write` — creates or updates user data. Requires explicit policy + * allowance in the consumer. + * `destructive` — deletes or archives irreversibly. MCP refuses to expose + * these tools entirely; mana-ai requires explicit mission + * policy `auto: true` for the specific tool. + */ +export type PolicyHint = 'read' | 'write' | 'destructive'; + +/** + * Runtime context handed to every tool handler. + * + * Constructed by the consumer from the incoming request: + * - mana-mcp decodes the JWT and pulls spaceId from `X-Mana-Space` + * (defaulting to the user's active personal space) + * - mana-ai builds it from the mission's userId + spaceId + */ +export interface ToolContext { + userId: string; + spaceId: string; + /** Forwarded to downstream service calls (mana-sync etc.). */ + jwt: string; + logger: Logger; + /** Identifies the invoker for audit trail rows (persona_actions etc.). */ + invoker: 'mcp' | 'mana-ai' | 'admin'; + /** + * Lazy master-key fetcher for tools that encrypt/decrypt user-visible + * fields. Throws `ZeroKnowledgeUserError` for ZK users (the server + * cannot open their vault) and `MasterKeyFetchError` on any other + * failure. Tools that only touch plaintext tables should never call + * this. + */ + getMasterKey(): Promise; +} + +export interface Logger { + debug(msg: string, meta?: Record): void; + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; +} + +/** + * Single tool definition. Generic in the zod schemas so handlers receive + * the parsed (post-defaults) input type and return the validated output + * type. Schema-first generics keep `.default()`, `.transform()`, and + * `.refine()` working without manual Input/Output juggling. + * + * Registry storage uses `AnyToolSpec` (see registry.ts). + */ +export interface ToolSpec< + InputSchema extends z.ZodTypeAny = z.ZodTypeAny, + OutputSchema extends z.ZodTypeAny = z.ZodTypeAny, +> { + /** Canonical dot-name, e.g. `habits.create`. Unique across registry. */ + readonly name: string; + /** Human-facing description — used by MCP, LLM system prompts. */ + readonly description: string; + readonly module: ModuleId; + readonly scope: ToolScope; + readonly policyHint: PolicyHint; + readonly input: InputSchema; + readonly output: OutputSchema; + /** + * Declares which record fields the tool encrypts on write and decrypts + * on read. Set to the exact field list from + * `apps/mana/apps/web/src/lib/data/crypto/registry.ts` for the same + * table. Omit entirely for plaintext-only tools. + * + * Declarative rather than handler-internal so a consistency check can + * diff this against the web-app registry at build time (future CI + * audit). + */ + readonly encryptedFields?: { + readonly table: string; + readonly fields: readonly string[]; + }; + readonly handler: ( + input: z.output, + ctx: ToolContext + ) => Promise>; +} + +export type AnyToolSpec = ToolSpec; diff --git a/packages/mana-tool-registry/tsconfig.json b/packages/mana-tool-registry/tsconfig.json new file mode 100644 index 000000000..65e789b30 --- /dev/null +++ b/packages/mana-tool-registry/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-crypto/package.json b/packages/shared-crypto/package.json new file mode 100644 index 000000000..dfae393e9 --- /dev/null +++ b/packages/shared-crypto/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mana/shared-crypto", + "version": "0.1.0", + "private": true, + "type": "module", + "sideEffects": false, + "description": "Runtime-agnostic AES-GCM-256 primitives used by the web app, mana-mcp tool handlers, and any future agent-side consumer. Uses Web Crypto API only — works in Bun, Node 20+, and all modern browsers.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/shared-crypto/src/aes.ts b/packages/shared-crypto/src/aes.ts new file mode 100644 index 000000000..503d075fd --- /dev/null +++ b/packages/shared-crypto/src/aes.ts @@ -0,0 +1,220 @@ +/** + * AES-GCM-256 wrap/unwrap primitives — runtime-agnostic. + * + * Pure crypto layer with no state, no Dexie dependency, no module registry. + * Web app + mana-mcp tool handlers + any future agent-side consumer share + * this exact wire format so round-tripping between writers/readers is safe. + * + * Wire format + * `enc:${VERSION}:${base64(iv)}.${base64(ct)}` + * + * The string-prefix format (rather than a JSON envelope) is deliberate: + * - One scan to detect "is this encrypted?" — `value.startsWith('enc:1:')` + * - Survives JSON.stringify when records flow through the sync wire + * - Compact: ~1.4× the original byte length, vs ~2× for a JSON envelope + * - Trivial to bump VERSION for future format migrations + * + * Authenticated encryption: AES-GCM provides both confidentiality and + * tamper-detection. A modified ciphertext fails decryption with an + * OperationError instead of returning silent garbage — `unwrapValue` + * surfaces that as a thrown error so callers can react. + * + * Value types: anything JSON-serialisable. The plaintext is JSON.stringified + * before encryption, JSON.parsed after decryption. `null` and `undefined` + * pass through unchanged so callers can blindly wrap optional fields + * without checking each one first. + */ + +/** Bumped if the wire format ever changes. Old blobs stay readable as long + * as `unwrapValue` knows how to handle their version prefix. */ +export const ENCRYPTION_VERSION = 1; + +/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */ +export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`; + +/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not + * recommended by NIST and would only burn entropy. */ +const IV_LENGTH = 12; + +// ─── Base64 helpers ─────────────────────────────────────────── +// +// We avoid `btoa(String.fromCharCode(...bytes))` because the spread operator +// hits the JS argument limit (~65k) for large records. The manual loop is +// O(n) and works for any size. + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** + * TypeScript 5.7+ parameterised Uint8Array with the underlying buffer + * type, which now includes SharedArrayBuffer. Web Crypto's `BufferSource` + * type still expects a plain ArrayBuffer-backed view, so we need to copy + * the bytes through a fresh ArrayBuffer to satisfy the strict type check. + * + * This is a TypeScript-only annoyance — at runtime the call would have + * worked fine with the original Uint8Array. The copy is O(n) and + * negligible for the field sizes we encrypt (< 100 KB typical). + */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +} + +// ─── Public API ─────────────────────────────────────────────── + +/** + * Returns true iff `value` is a string carrying the encryption prefix. + * + * Cheap synchronous detection — no decryption attempted. Use this to + * decide whether a field needs to be unwrapped on read, or whether a + * value coming back from a backend pull is already encrypted. + */ +export function isEncrypted(value: unknown): boolean { + return typeof value === 'string' && value.startsWith(ENC_PREFIX); +} + +/** + * Encrypts `value` with `key` and returns the wire-format string. Pass- + * through for `null` / `undefined` so optional-field call sites stay + * concise: + * + * record.title = await wrapValue(record.title, key); + * record.notes = await wrapValue(record.notes, key); // safe even if null + * + * Throws if `key` is unusable (wrong algorithm, wrong usages). Each call + * generates a fresh random IV — never reuse one for the same key. + */ +export async function wrapValue(value: unknown, key: CryptoKey): Promise { + if (value === null || value === undefined) return value; + + const json = JSON.stringify(value); + const plaintext = new TextEncoder().encode(json); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + key, + toBufferSource(plaintext) + ); + + return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct)); +} + +/** + * Decrypts a wire-format string back to its original JS value. Pass- + * through for non-strings, `null`/`undefined`, and any string that + * doesn't carry the encryption prefix — that way `unwrapValue` is safe + * to apply unconditionally to mixed records. + * + * Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed + * blobs, or wrong key. Callers should treat the throw as data corruption + * — there's no soft-recovery path. + */ +export async function unwrapValue(blob: unknown, key: CryptoKey): Promise { + if (!isEncrypted(blob)) return blob; + + const body = (blob as string).slice(ENC_PREFIX.length); + const dotIndex = body.indexOf('.'); + if (dotIndex === -1) { + throw new Error('mana-crypto: malformed encrypted blob (missing iv/ct separator)'); + } + + const iv = base64ToBytes(body.slice(0, dotIndex)); + const ct = base64ToBytes(body.slice(dotIndex + 1)); + + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + key, + toBufferSource(ct) + ); + + const json = new TextDecoder().decode(plaintext); + return JSON.parse(json); +} + +/** + * Generates a fresh AES-GCM-256 key. Used at vault initialisation time + * (Phase 2: server-side; tests: in-memory) to mint the per-user master + * key. The key is `extractable: true` so the server can wrap it with + * the KEK before storing — set to `false` for client-side derived keys + * that should never leave the browser. + */ +export async function generateMasterKey(extractable = true): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, extractable, [ + 'encrypt', + 'decrypt', + ]); +} + +/** + * Imports a raw 32-byte buffer as an AES-GCM-256 key. Used by the + * Phase 3 client to take the bytes the vault endpoint returns and turn + * them into a non-extractable CryptoKey instance for runtime use. + */ +export async function importMasterKey(rawBytes: Uint8Array): Promise { + if (rawBytes.length !== 32) { + throw new Error(`mana-crypto: expected 32-byte master key, got ${rawBytes.length}`); + } + return crypto.subtle.importKey( + 'raw', + toBufferSource(rawBytes), + { name: 'AES-GCM', length: 256 }, + false, // non-extractable: once it's in the browser, it stays there + ['encrypt', 'decrypt'] + ); +} + +/** + * Exports a key back to its raw 32 bytes. Only works on extractable + * keys; non-extractable keys throw. Used by tests and the Phase 2 + * server-side wrap path. + */ +export async function exportMasterKey(key: CryptoKey): Promise { + const raw = await crypto.subtle.exportKey('raw', key); + return new Uint8Array(raw); +} + +// ─── Batch helpers (new in M1.5) ────────────────────────────── +// +// Tool handlers work record-at-a-time and need an explicit field list +// (the web app uses a registry lookup, but we keep shared-crypto registry- +// free to avoid a cyclic import with the module type definitions). The +// caller passes the list — typically derived from the authoritative +// registry in apps/mana/apps/web/src/lib/data/crypto/registry.ts. + +/** Shallow-copy `record` with the named fields wrap-encrypted. Nullish fields stay nullish. */ +export async function encryptRecordFields>( + record: T, + fields: readonly (keyof T & string)[], + key: CryptoKey +): Promise { + const out = { ...record }; + for (const field of fields) { + out[field] = (await wrapValue(out[field], key)) as T[typeof field]; + } + return out; +} + +/** Shallow-copy `record` with the named fields decrypted. Pass-through for non-encrypted values. */ +export async function decryptRecordFields>( + record: T, + fields: readonly (keyof T & string)[], + key: CryptoKey +): Promise { + const out = { ...record }; + for (const field of fields) { + out[field] = (await unwrapValue(out[field], key)) as T[typeof field]; + } + return out; +} diff --git a/packages/shared-crypto/src/index.ts b/packages/shared-crypto/src/index.ts new file mode 100644 index 000000000..200c698f8 --- /dev/null +++ b/packages/shared-crypto/src/index.ts @@ -0,0 +1,12 @@ +export { + ENC_PREFIX, + ENCRYPTION_VERSION, + decryptRecordFields, + encryptRecordFields, + exportMasterKey, + generateMasterKey, + importMasterKey, + isEncrypted, + unwrapValue, + wrapValue, +} from './aes.ts'; diff --git a/packages/shared-crypto/tsconfig.json b/packages/shared-crypto/tsconfig.json new file mode 100644 index 000000000..1d91f40f2 --- /dev/null +++ b/packages/shared-crypto/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73b0fd234..5649bfcec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ importers: version: 6.0.154(zod@3.25.76) drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -108,7 +108,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.12 + version: 1.3.13 drizzle-kit: specifier: ^0.30.0 version: 0.30.6 @@ -138,14 +138,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) @@ -154,13 +154,13 @@ importers: version: 20.19.39 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) + version: 1.6.0(eslint@9.39.4(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.8.1 @@ -253,10 +253,10 @@ importers: version: 3.7.2 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) astro: specifier: ^5.16.11 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) @@ -525,6 +525,9 @@ importers: '@mana/shared-branding': specifier: workspace:* version: link:../../../../packages/shared-branding + '@mana/shared-crypto': + specifier: workspace:* + version: link:../../../../packages/shared-crypto '@mana/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking @@ -1628,7 +1631,7 @@ importers: version: link:../../../../packages/shared-hono drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) + version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) hono: specifier: ^4.7.0 version: 4.12.12 @@ -1650,7 +1653,7 @@ importers: devDependencies: drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) + version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) postgres: specifier: ^3.4.7 version: 3.4.9 @@ -1680,7 +1683,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.12 + version: 1.3.13 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -1924,6 +1927,19 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/mana-tool-registry: + dependencies: + '@mana/shared-crypto': + specifier: workspace:* + version: link:../shared-crypto + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/notify-client: devDependencies: '@nestjs/common': @@ -2051,6 +2067,12 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/shared-crypto: + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/shared-drizzle-config: dependencies: drizzle-kit: @@ -2093,7 +2115,7 @@ importers: version: link:../shared-logger drizzle-orm: specifier: ^0.45.1 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2106,7 +2128,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.12 + version: 1.3.13 '@types/node': specifier: ^24.10.1 version: 24.12.2 @@ -2568,7 +2590,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.12 + version: 1.3.13 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2580,7 +2602,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2619,10 +2641,10 @@ importers: version: 3.0.3 better-auth: specifier: ^1.4.3 - version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3) + version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3) drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2661,7 +2683,7 @@ importers: version: 3.0.3 drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2692,7 +2714,7 @@ importers: dependencies: drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2788,7 +2810,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2812,6 +2834,34 @@ importers: specifier: ^5.9.3 version: 5.9.3 + services/mana-mcp: + dependencies: + '@mana/shared-hono': + specifier: workspace:* + version: link:../../packages/shared-hono + '@mana/tool-registry': + specifier: workspace:* + version: link:../../packages/mana-tool-registry + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) + hono: + specifier: ^4.7.0 + version: 4.12.12 + jose: + specifier: ^6.1.2 + version: 6.2.2 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/bun': + specifier: ^1.1.16 + version: 1.3.13 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + services/mana-media: {} services/mana-media/apps/api: @@ -2821,7 +2871,7 @@ importers: version: 5.73.0 drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) exifr: specifier: ^7.1.3 version: 7.1.3 @@ -2881,7 +2931,7 @@ importers: version: link:../../packages/shared-research drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2914,7 +2964,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2947,7 +2997,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2975,7 +3025,7 @@ importers: version: link:../../packages/shared-rss drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -8001,8 +8051,8 @@ packages: '@types/bun@1.3.11': resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} - '@types/bun@1.3.12': - resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==} + '@types/bun@1.3.13': + resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -9080,8 +9130,8 @@ packages: bun-types@1.3.11: resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} - bun-types@1.3.12: - resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==} + bun-types@1.3.13: + resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} @@ -16951,6 +17001,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + autoprefixer: 10.4.27(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -16971,16 +17031,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - autoprefixer: 10.4.27(postcss@8.5.8) - postcss: 8.5.8 - postcss-load-config: 4.0.2(postcss@8.5.8) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -18525,12 +18575,12 @@ snapshots: nanostores: 1.2.0 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))': + '@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))': dependencies: '@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 optionalDependencies: - drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) '@better-auth/kysely-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)': dependencies: @@ -19140,6 +19190,11 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -23136,9 +23191,9 @@ snapshots: dependencies: bun-types: 1.3.11 - '@types/bun@1.3.12': + '@types/bun@1.3.13': dependencies: - bun-types: 1.3.12 + bun-types: 1.3.13 '@types/chai@5.2.3': dependencies: @@ -24062,6 +24117,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.7.0 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) + vfile: 6.0.3 + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -24266,108 +24423,6 @@ snapshots: - uploadthing - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 4.0.0 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.16.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.7.0 - diff: 8.0.4 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.27.7 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.2 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.4 - shiki: 3.23.0 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) - vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -24703,10 +24758,10 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3): + better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3): dependencies: '@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)) + '@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)) '@better-auth/kysely-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15) '@better-auth/memory-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0) @@ -24725,7 +24780,7 @@ snapshots: optionalDependencies: '@sveltejs/kit': 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) drizzle-kit: 0.30.6 - drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) svelte: 5.55.1 @@ -24896,7 +24951,7 @@ snapshots: dependencies: '@types/node': 22.19.17 - bun-types@1.3.12: + bun-types@1.3.13: dependencies: '@types/node': 22.19.17 @@ -25656,30 +25711,30 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0): + drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.6.1 '@types/react': 19.2.14 - bun-types: 1.3.12 + bun-types: 1.3.13 kysely: 0.28.15 postgres: 3.4.9 react: 19.2.0 - drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.6.1 - bun-types: 1.3.12 + bun-types: 1.3.13 gel: 2.2.0 kysely: 0.28.15 postgres: 3.4.9 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.6.1 - bun-types: 1.3.12 + bun-types: 1.3.13 gel: 2.2.0 kysely: 0.28.15 postgres: 3.4.9 @@ -26187,6 +26242,11 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26196,6 +26256,10 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26240,6 +26304,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.0 + astro-eslint-parser: 1.4.0 + eslint: 9.39.4(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -26413,6 +26491,47 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -33490,6 +33609,23 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.1 + vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -33524,23 +33660,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.12.2 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.32.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -33558,6 +33677,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -33566,10 +33689,6 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) diff --git a/services/mana-mcp/CLAUDE.md b/services/mana-mcp/CLAUDE.md new file mode 100644 index 000000000..79b1789af --- /dev/null +++ b/services/mana-mcp/CLAUDE.md @@ -0,0 +1,148 @@ +# mana-mcp + +MCP (Model Context Protocol) gateway for Mana. External agents — Claude Desktop, Claude Code, the persona-runner — connect here to drive Mana modules over a single, JWT-authed protocol. + +**Plan:** [`docs/plans/mana-mcp-and-personas.md`](../../docs/plans/mana-mcp-and-personas.md) + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Runtime** | Bun | +| **Framework** | Hono | +| **Transport** | MCP Streamable HTTP (`@modelcontextprotocol/sdk`) | +| **Auth** | JWT verify via JWKS from mana-auth (no service-key path) | +| **Tools** | `@mana/tool-registry` (shared SSOT — also consumed by mana-ai) | + +## Port: 3069 + +## Quick Start + +```bash +# Requires: mana-auth (3001) and mana-sync (3050) running +pnpm --filter @mana/mcp-service dev +``` + +Health check: `curl localhost:3069/health` + +## Architecture + +``` +External Agent (Claude Desktop, persona-runner, …) + │ + │ POST /mcp Authorization: Bearer + │ GET /mcp X-Mana-Space: + │ DELETE /mcp Mcp-Session-Id: + ▼ + ┌─────────────────────────────────┐ + │ src/index.ts (Hono :3069) │ + │ ├── CORS │ + │ ├── /health, /metrics │ + │ └── /mcp │ + │ │ │ + │ ▼ authenticateRequest │ + │ │ src/auth.ts │ + │ │ (verify JWT via JWKS, │ + │ │ pull X-Mana-Space) │ + │ ▼ │ + │ handleMcpRequest │ + │ src/transport.ts │ + │ (per-user MCP session, │ + │ scoped, no cross-user) │ + │ │ │ + │ ▼ createMcpServerForUser │ + │ src/mcp-adapter.ts │ + │ (registry → MCP tools) │ + └────────────────┬────────────────┘ + │ + ▼ + @mana/tool-registry handlers + │ + ┌───────────┼────────────┐ + ▼ ▼ ▼ + mana-sync mana-auth (other services + (push/pull) (org list) via tool handlers) +``` + +## Auth model + +Every MCP request must carry: + +| Header | Required | Purpose | +|---|---|---| +| `Authorization: Bearer ` | yes | EdDSA JWT issued by mana-auth, verified via JWKS | +| `X-Mana-Space: ` | yes | Active Space — every tool write lands here | +| `Mcp-Session-Id: ` | after init | Session tracking; absent on first `POST /mcp` | + +**No service-key path.** Personas, the persona-runner, and any future agent client all hold real user JWTs. There is no admin bypass — admin-scoped tools (`scope: 'admin'`) are silently filtered out before being registered with the MCP server. + +**Per-user session isolation.** A session is created against a specific user. If a request later arrives with a session ID that belongs to a different user, the gateway returns 403. This is defense-in-depth against session-id collisions or a leaked session header. + +## Adding a tool + +Tools are defined in `packages/mana-tool-registry/src/modules/.ts`, **never** in this service. Steps: + +1. Open the relevant module file (or create a new one). +2. Define the `ToolSpec`: name (`module.verb`), zod input/output schemas, `scope`, `policyHint`, handler. +3. Add it to the module's `registerTools()` function. +4. If new module: extend `ModuleId` in `packages/mana-tool-registry/src/types.ts` and call the new register function from `modules/index.ts`. + +The MCP server picks up the tool on next restart — no service code change needed. + +**Policy gating reminder:** `scope: 'admin'` tools never reach MCP clients. `policyHint: 'destructive'` tools are exposed but should be rare; prefer `policyHint: 'write'` with a soft-delete semantic. + +## Local smoke test (M1 exit gate) + +Manual end-to-end check that proves: external client → MCP → mana-sync → Postgres. + +```bash +# 1. Start the stack +pnpm docker:up # Postgres, Redis, MinIO +pnpm dev:auth # mana-auth on 3001 +pnpm dev:sync # mana-sync on 3050 +pnpm --filter @mana/mcp-service dev # mana-mcp on 3069 + +# 2. Get a dev-user JWT +pnpm setup:dev-user # creates dev@mana.test +# Then login to get a JWT — easiest path is via the web app dev-tools +# panel, or use a curl against /api/v1/auth/sign-in/email. + +# 3. Fetch the user's active Space ID +curl -H "Authorization: Bearer $JWT" \ + http://localhost:3001/api/auth/organization/list + +# 4. Configure Claude Code (.mcp.json in repo root or ~/.claude.json) +{ + "mcpServers": { + "mana": { + "type": "http", + "url": "http://localhost:3069/mcp", + "headers": { + "Authorization": "Bearer ", + "X-Mana-Space": "" + } + } + } +} + +# 5. In Claude Code, ask: "List my mana habits, then create one called 'Spazieren'" +# Verify a row appears in mana_sync.sync_changes for table='habits'. +``` + +## Environment Variables + +```env +PORT=3069 +MANA_AUTH_URL=http://localhost:3001 +MANA_SYNC_URL=http://localhost:3050 +JWT_AUDIENCE=mana +CORS_ORIGINS=http://localhost:5173 +``` + +No provider keys, no DB connection — this service is stateless and forwards everything through tool handlers to other services. + +## Why a separate service (not folded into apps/api) + +`apps/api/src/mcp/server.ts` already exists and exposes `AI_TOOL_CATALOG` over MCP. Per the plan (M4), that file and `AI_TOOL_CATALOG` get deleted once the new `@mana/tool-registry` covers all 67+ tools currently in `mana-ai`. Until then this service is the new path; the old endpoint stays for compatibility but is not extended. + +Keeping mana-mcp standalone lets it be deployed independently, scaled separately (sessions are stateful in memory), and reasoned about as the single agent-facing entrypoint. diff --git a/services/mana-mcp/package.json b/services/mana-mcp/package.json new file mode 100644 index 000000000..bc7ba2d10 --- /dev/null +++ b/services/mana-mcp/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mana/mcp-service", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "MCP (Model Context Protocol) gateway for Mana. Exposes the shared @mana/tool-registry over Streamable HTTP so external agents (Claude Desktop, persona-runner) can drive the user's modules through their normal JWT-authed paths.", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@mana/shared-hono": "workspace:*", + "@mana/tool-registry": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "hono": "^4.7.0", + "jose": "^6.1.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/bun": "^1.1.16", + "typescript": "^5.9.3" + } +} diff --git a/services/mana-mcp/src/auth.ts b/services/mana-mcp/src/auth.ts new file mode 100644 index 000000000..c53beceb2 --- /dev/null +++ b/services/mana-mcp/src/auth.ts @@ -0,0 +1,93 @@ +/** + * JWT verification for MCP requests. + * + * Mirrors the pattern in services/mana-research/src/middleware/jwt-auth.ts — + * JWKS-cached verification against mana-auth, audience pinned to "mana". + * + * Returns the resolved user context (or throws 401) so the MCP transport + * handler can hand it directly to the registry adapter. + */ + +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +export interface VerifiedUser { + userId: string; + email: string; + role: string; + tier: string; + /** Active Space at the moment of the request. May be overridden by `X-Mana-Space`. */ + spaceId: string; + /** The raw JWT, forwarded to downstream services in tool handlers. */ + jwt: string; +} + +let cachedJwks: ReturnType | null = null; +let cachedJwksUrl: string | null = null; + +function getJwks(authUrl: string): ReturnType { + const url = `${authUrl}/api/auth/jwks`; + if (cachedJwks && cachedJwksUrl === url) return cachedJwks; + cachedJwks = createRemoteJWKSet(new URL(url)); + cachedJwksUrl = url; + return cachedJwks; +} + +export class UnauthorizedError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnauthorizedError'; + } +} + +export async function verifyJwt( + token: string, + authUrl: string, + audience: string +): Promise> { + try { + const { payload } = await jwtVerify(token, getJwks(authUrl), { audience }); + const userId = (payload.sub as string | undefined) ?? ''; + if (!userId) throw new UnauthorizedError('Token has no `sub` claim'); + return { + userId, + email: (payload.email as string | undefined) ?? '', + role: (payload.role as string | undefined) ?? 'user', + tier: (payload.tier as string | undefined) ?? 'public', + }; + } catch (err) { + if (err instanceof UnauthorizedError) throw err; + throw new UnauthorizedError( + err instanceof Error ? `Invalid token: ${err.message}` : 'Invalid token' + ); + } +} + +/** + * Pull `Authorization: Bearer ...` and `X-Mana-Space: ...` out of an + * incoming Request, verify the token, and return the assembled user + * context. Throws UnauthorizedError on any auth failure. + */ +export async function authenticateRequest( + req: Request, + authUrl: string, + audience: string +): Promise { + const header = req.headers.get('authorization'); + if (!header || !header.startsWith('Bearer ')) { + throw new UnauthorizedError('Missing or malformed Authorization header'); + } + const token = header.slice('Bearer '.length).trim(); + const verified = await verifyJwt(token, authUrl, audience); + + const spaceHeader = req.headers.get('x-mana-space'); + const spaceId = spaceHeader && spaceHeader.length > 0 ? spaceHeader : ''; + if (!spaceId) { + // We *could* default to the user's personal Space, but resolving that + // requires another round-trip to mana-auth. For M1 we require the + // caller to set X-Mana-Space explicitly — Persona-Runner and Claude + // Desktop both set it from `spaces.list` results. + throw new UnauthorizedError('Missing X-Mana-Space header (set to the active Space ID)'); + } + + return { ...verified, spaceId, jwt: token }; +} diff --git a/services/mana-mcp/src/config.ts b/services/mana-mcp/src/config.ts new file mode 100644 index 000000000..273bf6d69 --- /dev/null +++ b/services/mana-mcp/src/config.ts @@ -0,0 +1,35 @@ +/** + * Service configuration. All values come from env. Defaults match local + * dev (`pnpm setup:env` writes the same values into .env files). + */ + +export interface Config { + port: number; + authUrl: string; + jwtAudience: string; + manaSyncUrl: string; + corsOrigins: string[]; +} + +function intEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`${name} must be a positive integer, got "${raw}"`); + } + return n; +} + +export function loadConfig(): Config { + return { + port: intEnv('PORT', 3069), + authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001', + jwtAudience: process.env.JWT_AUDIENCE ?? 'mana', + manaSyncUrl: process.env.MANA_SYNC_URL ?? 'http://localhost:3050', + corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }; +} diff --git a/services/mana-mcp/src/index.ts b/services/mana-mcp/src/index.ts new file mode 100644 index 000000000..77bb6d637 --- /dev/null +++ b/services/mana-mcp/src/index.ts @@ -0,0 +1,69 @@ +/** + * mana-mcp — MCP gateway service. + * + * Exposes `@mana/tool-registry` over Streamable HTTP, JWT-authed via + * mana-auth's JWKS. Per-user sessions; admin-scoped tools never reach + * the wire. + * + * Port: 3069. See services/mana-mcp/CLAUDE.md. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { registerAllModules } from '@mana/tool-registry'; +import { loadConfig } from './config.ts'; +import { authenticateRequest, UnauthorizedError } from './auth.ts'; +import { handleMcpRequest } from './transport.ts'; + +// ─── Bootstrap ──────────────────────────────────────────────────── + +const config = loadConfig(); +registerAllModules(); + +const app = new Hono(); + +app.use( + '*', + cors({ + origin: config.corsOrigins, + allowHeaders: ['authorization', 'content-type', 'x-mana-space', 'mcp-session-id'], + exposeHeaders: ['mcp-session-id'], + credentials: true, + }) +); + +// ─── Health / metrics ───────────────────────────────────────────── + +app.get('/health', (c) => + c.json({ + status: 'ok', + service: 'mana-mcp', + registry: { loaded: true }, + }) +); + +app.get('/metrics', (c) => + c.text('# mana-mcp metrics stub — populated alongside Persona-Runner observability\n') +); + +// ─── MCP endpoint ───────────────────────────────────────────────── + +app.all('/mcp', async (c) => { + let user; + try { + user = await authenticateRequest(c.req.raw, config.authUrl, config.jwtAudience); + } catch (err) { + const msg = err instanceof UnauthorizedError ? err.message : 'Unauthorized'; + return c.json({ error: msg }, 401); + } + return handleMcpRequest(c.req.raw, user); +}); + +// ─── Server ─────────────────────────────────────────────────────── + +console.info(`[mana-mcp] listening on :${config.port} (auth=${config.authUrl})`); + +export default { + port: config.port, + fetch: app.fetch, +}; diff --git a/services/mana-mcp/src/mcp-adapter.ts b/services/mana-mcp/src/mcp-adapter.ts new file mode 100644 index 000000000..c674eae3a --- /dev/null +++ b/services/mana-mcp/src/mcp-adapter.ts @@ -0,0 +1,127 @@ +/** + * Bridges `@mana/tool-registry` → MCP `McpServer`. + * + * For each tool in the registry, we register an MCP tool whose: + * - name comes verbatim from the spec + * - description comes verbatim from the spec + * - input shape is the registry's zod schema (already typed) + * - handler invokes the registry handler with a fully-built ToolContext + * + * MCP's tool-output convention is `{ content: [{ type: 'text', text }] }`, + * so we serialize the registry handler's parsed output to JSON and wrap. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z, type ZodObject, type ZodRawShape } from 'zod'; +import { + MasterKeyClient, + getRegistry, + type AnyToolSpec, + type Logger, + type ToolContext, +} from '@mana/tool-registry'; +import type { VerifiedUser } from './auth.ts'; + +/** + * Shared across all sessions — the client caches MKs per userId with a + * short TTL so a single MCP session invoking N encrypted tools fetches + * the vault at most once per TTL window. + */ +const masterKeyClient = new MasterKeyClient({ + authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001', +}); + +/** Tools with `scope: 'admin'` are never exposed to MCP clients. */ +function isExposable(spec: AnyToolSpec): boolean { + return spec.scope === 'user-space'; +} + +/** + * Extract the raw shape from a zod object schema. The MCP SDK's + * `server.tool()` API expects `Record`, not a wrapping + * `ZodObject`. Tools that don't use a ZodObject input (uncommon — most are + * objects) get registered without parameters. + */ +function shapeOf(schema: AnyToolSpec['input']): ZodRawShape | null { + if (schema instanceof z.ZodObject) { + return (schema as ZodObject).shape; + } + return null; +} + +function makeLogger(prefix: string): Logger { + const fmt = (level: string, msg: string, meta?: Record): string => + meta && Object.keys(meta).length > 0 + ? `[${level}] ${prefix} ${msg} ${JSON.stringify(meta)}` + : `[${level}] ${prefix} ${msg}`; + return { + debug: (msg, meta) => console.debug(fmt('debug', msg, meta)), + info: (msg, meta) => console.info(fmt('info', msg, meta)), + warn: (msg, meta) => console.warn(fmt('warn', msg, meta)), + error: (msg, meta) => console.error(fmt('error', msg, meta)), + }; +} + +/** + * Build an MCP server bound to a single user/session. Each MCP session gets + * its own server instance — userId and JWT are captured in closures so tools + * can never leak across sessions. + */ +export function createMcpServerForUser(user: VerifiedUser): McpServer { + const server = new McpServer({ name: 'mana', version: '0.1.0' }, { capabilities: { tools: {} } }); + + const baseCtx: Omit = { + userId: user.userId, + spaceId: user.spaceId, + jwt: user.jwt, + invoker: 'mcp', + getMasterKey: () => masterKeyClient.getKey(user.userId, user.jwt), + }; + + for (const spec of getRegistry()) { + if (!isExposable(spec)) continue; + + const shape = shapeOf(spec.input); + const ctxFor = (toolName: string): ToolContext => ({ + ...baseCtx, + logger: makeLogger(`tool=${toolName} user=${user.userId.slice(0, 8)}`), + }); + + const invoke = async (rawArgs: unknown) => { + let parsed: unknown; + try { + parsed = spec.input.parse(rawArgs); + } catch (err) { + const msg = + err instanceof z.ZodError + ? err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') + : String(err); + return { + isError: true, + content: [{ type: 'text' as const, text: `Invalid input for ${spec.name}: ${msg}` }], + }; + } + + try { + const result = await spec.handler(parsed, ctxFor(spec.name)); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: 'text' as const, text: `Tool ${spec.name} failed: ${msg}` }], + }; + } + }; + + if (shape && Object.keys(shape).length > 0) { + server.tool(spec.name, spec.description, shape, invoke); + } else { + server.tool(spec.name, spec.description, invoke); + } + } + + return server; +} diff --git a/services/mana-mcp/src/transport.ts b/services/mana-mcp/src/transport.ts new file mode 100644 index 000000000..9557477ea --- /dev/null +++ b/services/mana-mcp/src/transport.ts @@ -0,0 +1,75 @@ +/** + * Streamable HTTP transport handler. + * + * Pattern lifted from apps/api/src/mcp/server.ts (the Mana-internal MCP + * endpoint), but with per-request auth — every session is created against + * a verified user, and sessions are scoped to that user for their lifetime. + * + * Lifecycle: + * POST /mcp (no session id) → initialize, returns Mcp-Session-Id header + * POST /mcp (with session id) → JSON-RPC message in + * GET /mcp (with session id) → SSE stream out + * DELETE /mcp (with session id) → close + */ + +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { createMcpServerForUser } from './mcp-adapter.ts'; +import type { VerifiedUser } from './auth.ts'; + +interface SessionEntry { + transport: WebStandardStreamableHTTPServerTransport; + userId: string; +} + +const sessions = new Map(); + +export async function handleMcpRequest(req: Request, user: VerifiedUser): Promise { + const sessionId = req.headers.get('mcp-session-id'); + + // Existing session — must belong to the same user. + if (sessionId && sessions.has(sessionId)) { + const entry = sessions.get(sessionId)!; + if (entry.userId !== user.userId) { + return new Response(JSON.stringify({ error: 'Session belongs to a different user' }), { + status: 403, + headers: { 'content-type': 'application/json' }, + }); + } + return entry.transport.handleRequest(req); + } + + // New session: only POST without session id is a valid initialization. + if (req.method === 'POST' && !sessionId) { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { transport, userId: user.userId }); + }, + onsessionclosed: (id) => { + sessions.delete(id); + }, + }); + + const server = createMcpServerForUser(user); + await server.connect(transport); + + return transport.handleRequest(req); + } + + if (sessionId && !sessions.has(sessionId)) { + return new Response(JSON.stringify({ error: 'Session not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ error: 'Bad request' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }); +} + +/** Test-only — sessions accumulate across requests otherwise. */ +export function __resetSessionsForTests(): void { + sessions.clear(); +} diff --git a/services/mana-mcp/tsconfig.json b/services/mana-mcp/tsconfig.json new file mode 100644 index 000000000..549aa70bd --- /dev/null +++ b/services/mana-mcp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "lib": ["ES2022"], + "types": ["bun"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}