mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(mcp): M1+M1.5 MCP gateway + tool-registry + shared-crypto
Foundation for autonomous Claude-driven testing. Plan:
docs/plans/mana-mcp-and-personas.md.
New packages
- @mana/tool-registry — schema-first ToolSpec<InputSchema, OutputSchema>
with zod generics, scope ('user-space' | 'admin') and policyHint
('read' | 'write' | 'destructive'). sync-client helpers speak the
mana-sync push/pull protocol directly so RLS and field-level LWW are
preserved. MasterKeyClient fetches per-user MKs via the existing
mana-auth GET /api/v1/me/encryption-vault/key endpoint (JWT-gated,
ZK-aware, already audited) — no new service-key endpoint built.
ZeroKnowledgeUserError surfaced as a typed throw.
- @mana/shared-crypto — AES-GCM-256 primitives extracted from the web
app's $lib/data/crypto/aes.ts so the server-side tool handlers and the
browser produce byte-for-byte identical wire format
(enc:1:{b64(iv)}.{b64(ct)}). Web app aes.ts now re-exports from
shared-crypto — 5 existing importers unchanged, svelte-check stays
green.
New service
- services/mana-mcp (:3069, Bun/Hono) — MCP Streamable HTTP gateway.
JWKS auth against mana-auth, per-user session isolation (session-id
belongs to the user who opened it — cross-user access returns 403),
admin-scoped tools filtered out before registration. MasterKeyClient
cached per process with a 5-minute TTL.
11 tools registered
- habits.{create,list,update,archive}, spaces.list (plaintext, M1)
- todo.{create,list,complete}, notes.{create,search}, journal.add
(encrypted — field lists match
apps/mana/apps/web/src/lib/data/crypto/registry.ts verbatim)
Infra
- Port 3069 added to docs/PORT_SCHEMA.md
- services/mana-mcp/CLAUDE.md with architecture, auth model,
tool-authoring recipe, local smoke-test steps
- Root CLAUDE.md services list updated
Type-check green across shared-crypto, mana-tool-registry, mana-mcp.
svelte-check on apps/mana/apps/web stays at 0 errors / 0 warnings.
Boot smoke verified: /health returns registry.loaded=true, unauthed
/mcp → 401, invalid-JWT /mcp → 401 with descriptive message.
Decisions locked in for later milestones (per plan D1–D10):
- Personas will be real mana-auth users (users.kind='persona'), no
service-key bypass (D1, D2)
- Tool-registry is the SSOT; mana-ai and the legacy
apps/api/src/mcp/server.ts get merged into it in M4 (three current
parallel tool catalogs collapse to one)
- Persona-runner (:3070) will be a separate service using the Claude
Agent SDK + MCP client (D5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f719d1768f
commit
16c8818338
31 changed files with 2958 additions and 360 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
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<unknown> {
|
||||
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<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
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<Uint8Array> {
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
474
docs/plans/mana-mcp-and-personas.md
Normal file
474
docs/plans/mana-mcp-and-personas.md
Normal file
|
|
@ -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.<name>@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<Input, Output> {
|
||||
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<Input>;
|
||||
output: ZodSchema<Output>;
|
||||
handler: (input: Input, ctx: ToolContext) => Promise<Output>;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
jwt: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function registerTool(spec: ToolSpec<any, any>): void;
|
||||
export function getRegistry(): ToolSpec<any, any>[];
|
||||
export function getToolsByModule(module: ModuleId): ToolSpec<any, any>[];
|
||||
```
|
||||
|
||||
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 <jwt>`.
|
||||
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__/
|
||||
└── <test>-<persona>-<viewport>.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<T>()` 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 |
|
||||
| --- | --- | --- |
|
||||
| — | — | — |
|
||||
25
packages/mana-tool-registry/package.json
Normal file
25
packages/mana-tool-registry/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
43
packages/mana-tool-registry/src/index.ts
Normal file
43
packages/mana-tool-registry/src/index.ts
Normal file
|
|
@ -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';
|
||||
113
packages/mana-tool-registry/src/master-key-client.ts
Normal file
113
packages/mana-tool-registry/src/master-key-client.ts
Normal file
|
|
@ -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<string, CacheEntry>();
|
||||
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<CryptoKey> {
|
||||
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(() => '<unreadable>');
|
||||
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();
|
||||
}
|
||||
}
|
||||
219
packages/mana-tool-registry/src/modules/habits.ts
Normal file
219
packages/mana-tool-registry/src/modules/habits.ts
Normal file
|
|
@ -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<typeof habitSchema>;
|
||||
|
||||
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<typeof createInput, typeof createOutput> = {
|
||||
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<Habit>(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<string, unknown>,
|
||||
});
|
||||
|
||||
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<typeof listInput, typeof listOutput> = {
|
||||
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<Habit>(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<typeof updateInput, typeof updateOutput> = {
|
||||
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<string, { value: unknown; updatedAt: string }> = {};
|
||||
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<typeof archiveInput, typeof archiveOutput> = {
|
||||
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);
|
||||
}
|
||||
33
packages/mana-tool-registry/src/modules/index.ts
Normal file
33
packages/mana-tool-registry/src/modules/index.ts
Normal file
|
|
@ -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/<module>.ts` with one or more `ToolSpec` exports
|
||||
* and a `register<Module>Tools()` 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,
|
||||
};
|
||||
127
packages/mana-tool-registry/src/modules/journal.ts
Normal file
127
packages/mana-tool-registry/src/modules/journal.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Journal — daily freeform entries (Tagebuch).
|
||||
*
|
||||
* Encrypted module: `journalEntries` table is encrypted in the web-app
|
||||
* registry as
|
||||
* journalEntries: entry<LocalJournalEntry>(['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<typeof entrySchema>;
|
||||
|
||||
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<typeof addInput, typeof addOutput> = {
|
||||
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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
150
packages/mana-tool-registry/src/modules/notes.ts
Normal file
150
packages/mana-tool-registry/src/modules/notes.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Notes — lightweight markdown notes, flat structure.
|
||||
*
|
||||
* Encrypted module: `notes` table is encrypted in the web-app registry as
|
||||
* notes: entry<LocalNote>(['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<typeof noteSchema>;
|
||||
type EncryptedNote = Record<string, unknown>;
|
||||
|
||||
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<typeof createInput, typeof createOutput> = {
|
||||
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<string, unknown>,
|
||||
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<typeof searchInput, typeof searchOutput> = {
|
||||
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<EncryptedNote>(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);
|
||||
}
|
||||
94
packages/mana-tool-registry/src/modules/spaces.ts
Normal file
94
packages/mana-tool-registry/src/modules/spaces.ts
Normal file
|
|
@ -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<typeof spaceSchema>;
|
||||
|
||||
// ─── spaces.list ──────────────────────────────────────────────────
|
||||
|
||||
const listInput = z.object({});
|
||||
const listOutput = z.object({
|
||||
spaces: z.array(spaceSchema),
|
||||
activeSpaceId: z.string(),
|
||||
});
|
||||
|
||||
async function fetchSpaces(ctx: ToolContext): Promise<Space[]> {
|
||||
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<typeof listInput, typeof listOutput> = {
|
||||
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);
|
||||
}
|
||||
196
packages/mana-tool-registry/src/modules/todo.ts
Normal file
196
packages/mana-tool-registry/src/modules/todo.ts
Normal file
|
|
@ -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<typeof taskSchema>;
|
||||
type EncryptedTask = Record<string, unknown>;
|
||||
|
||||
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<typeof createInput, typeof createOutput> = {
|
||||
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<EncryptedTask>(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<string, unknown>,
|
||||
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<typeof listInput, typeof listOutput> = {
|
||||
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<EncryptedTask>(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<typeof completeInput, typeof completeOutput> = {
|
||||
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);
|
||||
}
|
||||
41
packages/mana-tool-registry/src/registry.ts
Normal file
41
packages/mana-tool-registry/src/registry.ts
Normal file
|
|
@ -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<string, AnyToolSpec>();
|
||||
|
||||
export function registerTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
|
||||
spec: ToolSpec<I, O>
|
||||
): 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();
|
||||
}
|
||||
123
packages/mana-tool-registry/src/sync-client.ts
Normal file
123
packages/mana-tool-registry/src/sync-client.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
fields?: Record<string, SyncFieldChange>;
|
||||
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<TRow = Record<string, unknown>> {
|
||||
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<SyncChange, 'op'>
|
||||
): Promise<void> {
|
||||
await push(config, appId, [{ ...change, op: 'insert' }]);
|
||||
}
|
||||
|
||||
export async function push(
|
||||
config: SyncClientConfig,
|
||||
appId: string,
|
||||
changes: SyncChange[]
|
||||
): Promise<void> {
|
||||
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(() => '<unreadable body>');
|
||||
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<TRow = Record<string, unknown>>(
|
||||
config: SyncClientConfig,
|
||||
appId: string,
|
||||
collection: string,
|
||||
since = '1970-01-01T00:00:00.000Z'
|
||||
): Promise<SyncPullResponse<TRow>> {
|
||||
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(() => '<unreadable body>');
|
||||
throw new Error(
|
||||
`mana-sync pull failed: ${res.status} ${res.statusText} — ${text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await res.json()) as SyncPullResponse<TRow>;
|
||||
}
|
||||
124
packages/mana-tool-registry/src/types.ts
Normal file
124
packages/mana-tool-registry/src/types.ts
Normal file
|
|
@ -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<CryptoKey>;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, meta?: Record<string, unknown>): void;
|
||||
info(msg: string, meta?: Record<string, unknown>): void;
|
||||
warn(msg: string, meta?: Record<string, unknown>): void;
|
||||
error(msg: string, meta?: Record<string, unknown>): 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<InputSchema>,
|
||||
ctx: ToolContext
|
||||
) => Promise<z.output<OutputSchema>>;
|
||||
}
|
||||
|
||||
export type AnyToolSpec = ToolSpec<z.ZodTypeAny, z.ZodTypeAny>;
|
||||
18
packages/mana-tool-registry/tsconfig.json
Normal file
18
packages/mana-tool-registry/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
20
packages/shared-crypto/package.json
Normal file
20
packages/shared-crypto/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
220
packages/shared-crypto/src/aes.ts
Normal file
220
packages/shared-crypto/src/aes.ts
Normal file
|
|
@ -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<unknown> {
|
||||
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<unknown> {
|
||||
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<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
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<Uint8Array> {
|
||||
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<T extends Record<string, unknown>>(
|
||||
record: T,
|
||||
fields: readonly (keyof T & string)[],
|
||||
key: CryptoKey
|
||||
): Promise<T> {
|
||||
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<T extends Record<string, unknown>>(
|
||||
record: T,
|
||||
fields: readonly (keyof T & string)[],
|
||||
key: CryptoKey
|
||||
): Promise<T> {
|
||||
const out = { ...record };
|
||||
for (const field of fields) {
|
||||
out[field] = (await unwrapValue(out[field], key)) as T[typeof field];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
12
packages/shared-crypto/src/index.ts
Normal file
12
packages/shared-crypto/src/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export {
|
||||
ENC_PREFIX,
|
||||
ENCRYPTION_VERSION,
|
||||
decryptRecordFields,
|
||||
encryptRecordFields,
|
||||
exportMasterKey,
|
||||
generateMasterKey,
|
||||
importMasterKey,
|
||||
isEncrypted,
|
||||
unwrapValue,
|
||||
wrapValue,
|
||||
} from './aes.ts';
|
||||
18
packages/shared-crypto/tsconfig.json
Normal file
18
packages/shared-crypto/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
473
pnpm-lock.yaml
generated
473
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
148
services/mana-mcp/CLAUDE.md
Normal file
148
services/mana-mcp/CLAUDE.md
Normal file
|
|
@ -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 <jwt>
|
||||
│ GET /mcp X-Mana-Space: <spaceId>
|
||||
│ DELETE /mcp Mcp-Session-Id: <session>
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ 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 <jwt>` | yes | EdDSA JWT issued by mana-auth, verified via JWKS |
|
||||
| `X-Mana-Space: <spaceId>` | yes | Active Space — every tool write lands here |
|
||||
| `Mcp-Session-Id: <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/<module>.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 `register<Module>Tools()` 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 <JWT>",
|
||||
"X-Mana-Space": "<SPACE_ID>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 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.
|
||||
24
services/mana-mcp/package.json
Normal file
24
services/mana-mcp/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
93
services/mana-mcp/src/auth.ts
Normal file
93
services/mana-mcp/src/auth.ts
Normal file
|
|
@ -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<typeof createRemoteJWKSet> | null = null;
|
||||
let cachedJwksUrl: string | null = null;
|
||||
|
||||
function getJwks(authUrl: string): ReturnType<typeof createRemoteJWKSet> {
|
||||
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<Omit<VerifiedUser, 'spaceId' | 'jwt'>> {
|
||||
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<VerifiedUser> {
|
||||
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 };
|
||||
}
|
||||
35
services/mana-mcp/src/config.ts
Normal file
35
services/mana-mcp/src/config.ts
Normal file
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
69
services/mana-mcp/src/index.ts
Normal file
69
services/mana-mcp/src/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
127
services/mana-mcp/src/mcp-adapter.ts
Normal file
127
services/mana-mcp/src/mcp-adapter.ts
Normal file
|
|
@ -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<string, ZodTypeAny>`, 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<ZodRawShape>).shape;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeLogger(prefix: string): Logger {
|
||||
const fmt = (level: string, msg: string, meta?: Record<string, unknown>): 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<ToolContext, 'logger'> = {
|
||||
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;
|
||||
}
|
||||
75
services/mana-mcp/src/transport.ts
Normal file
75
services/mana-mcp/src/transport.ts
Normal file
|
|
@ -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<string, SessionEntry>();
|
||||
|
||||
export async function handleMcpRequest(req: Request, user: VerifiedUser): Promise<Response> {
|
||||
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();
|
||||
}
|
||||
19
services/mana-mcp/tsconfig.json
Normal file
19
services/mana-mcp/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue