mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +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/`)
|
### 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
|
## Coding Guidelines
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"@mana/qr-export": "workspace:*",
|
"@mana/qr-export": "workspace:*",
|
||||||
"@mana/shared-ai": "workspace:*",
|
"@mana/shared-ai": "workspace:*",
|
||||||
"@mana/shared-auth": "workspace:*",
|
"@mana/shared-auth": "workspace:*",
|
||||||
|
"@mana/shared-crypto": "workspace:*",
|
||||||
"@mana/shared-auth-ui": "workspace:*",
|
"@mana/shared-auth-ui": "workspace:*",
|
||||||
"@mana/shared-branding": "workspace:*",
|
"@mana/shared-branding": "workspace:*",
|
||||||
"@mana/shared-error-tracking": "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
|
* The implementation moved to the shared package on 2026-04-22 as part of
|
||||||
* registry/key-provider modules use these to encrypt configured fields on
|
* M1.5 of the MCP/Personas plan — mana-mcp tool handlers need byte-for-byte
|
||||||
* the way into IndexedDB and decrypt them on the way out.
|
* identical wire format, so both the web app and server-side consumers
|
||||||
|
* import from the same source.
|
||||||
*
|
*
|
||||||
* Wire format
|
* All prior importers in this app keep working against `$lib/data/crypto/aes`
|
||||||
* `enc:${VERSION}:${base64(iv)}.${base64(ct)}`
|
* — the module surface is unchanged.
|
||||||
*
|
|
||||||
* 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
|
export {
|
||||||
* as `unwrapValue` knows how to handle their version prefix. */
|
ENC_PREFIX,
|
||||||
export const ENCRYPTION_VERSION = 1;
|
ENCRYPTION_VERSION,
|
||||||
|
exportMasterKey,
|
||||||
/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */
|
generateMasterKey,
|
||||||
export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`;
|
importMasterKey,
|
||||||
|
isEncrypted,
|
||||||
/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not
|
unwrapValue,
|
||||||
* recommended by NIST and would only burn entropy. */
|
wrapValue,
|
||||||
const IV_LENGTH = 12;
|
} from '@mana/shared-crypto';
|
||||||
|
|
||||||
// ─── 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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@
|
||||||
> - mana-sync `3050`
|
> - mana-sync `3050`
|
||||||
> - mana-credits `3061`, mana-user `3062`, mana-subscriptions `3063`,
|
> - mana-credits `3061`, mana-user `3062`, mana-subscriptions `3063`,
|
||||||
> mana-analytics `3064`, mana-events `3065`, mana-research `3068`
|
> 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
|
> **Not deployed:** `mana-voice-bot` (default port `3024`, no scheduled
|
||||||
> task, no cloudflared route, no launchd plist).
|
> 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)
|
version: 6.0.154(zod@3.25.76)
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.0
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -108,7 +108,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bun':
|
'@types/bun':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 1.3.12
|
version: 1.3.13
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.30.0
|
specifier: ^0.30.0
|
||||||
version: 0.30.6
|
version: 0.30.6
|
||||||
|
|
@ -138,14 +138,14 @@ importers:
|
||||||
version: link:../../../../packages/shared-landing-ui
|
version: link:../../../../packages/shared-landing-ui
|
||||||
astro:
|
astro:
|
||||||
specifier: ^5.16.0
|
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:
|
typescript:
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@astrojs/tailwind':
|
'@astrojs/tailwind':
|
||||||
specifier: ^6.0.2
|
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':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.18
|
specifier: ^0.5.18
|
||||||
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
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
|
version: 20.19.39
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.39.4(jiti@2.6.1)
|
version: 9.39.4(jiti@1.21.7)
|
||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
specifier: ^9.1.0
|
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:
|
eslint-plugin-astro:
|
||||||
specifier: ^1.0.0
|
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:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
|
|
@ -253,10 +253,10 @@ importers:
|
||||||
version: 3.7.2
|
version: 3.7.2
|
||||||
'@astrojs/tailwind':
|
'@astrojs/tailwind':
|
||||||
specifier: ^6.0.0
|
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:
|
astro:
|
||||||
specifier: ^5.16.11
|
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:
|
tailwindcss:
|
||||||
specifier: ^3.4.17
|
specifier: ^3.4.17
|
||||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
@ -525,6 +525,9 @@ importers:
|
||||||
'@mana/shared-branding':
|
'@mana/shared-branding':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-branding
|
version: link:../../../../packages/shared-branding
|
||||||
|
'@mana/shared-crypto':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-crypto
|
||||||
'@mana/shared-error-tracking':
|
'@mana/shared-error-tracking':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-error-tracking
|
version: link:../../../../packages/shared-error-tracking
|
||||||
|
|
@ -1628,7 +1631,7 @@ importers:
|
||||||
version: link:../../../../packages/shared-hono
|
version: link:../../../../packages/shared-hono
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.44.7
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -1650,7 +1653,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.44.7
|
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:
|
postgres:
|
||||||
specifier: ^3.4.7
|
specifier: ^3.4.7
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
|
|
@ -1680,7 +1683,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bun':
|
'@types/bun':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 1.3.12
|
version: 1.3.13
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
@ -1924,6 +1927,19 @@ importers:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 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:
|
packages/notify-client:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
|
|
@ -2051,6 +2067,12 @@ importers:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/shared-crypto:
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/shared-drizzle-config:
|
packages/shared-drizzle-config:
|
||||||
dependencies:
|
dependencies:
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
|
|
@ -2093,7 +2115,7 @@ importers:
|
||||||
version: link:../shared-logger
|
version: link:../shared-logger
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.1
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2106,7 +2128,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bun':
|
'@types/bun':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 1.3.12
|
version: 1.3.13
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.10.1
|
specifier: ^24.10.1
|
||||||
version: 24.12.2
|
version: 24.12.2
|
||||||
|
|
@ -2568,7 +2590,7 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bun':
|
'@types/bun':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 1.3.12
|
version: 1.3.13
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
@ -2580,7 +2602,7 @@ importers:
|
||||||
version: link:../../packages/shared-hono
|
version: link:../../packages/shared-hono
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2619,10 +2641,10 @@ importers:
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.3
|
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:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2661,7 +2683,7 @@ importers:
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2692,7 +2714,7 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2788,7 +2810,7 @@ importers:
|
||||||
version: link:../../packages/shared-hono
|
version: link:../../packages/shared-hono
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2812,6 +2834,34 @@ importers:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 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: {}
|
||||||
|
|
||||||
services/mana-media/apps/api:
|
services/mana-media/apps/api:
|
||||||
|
|
@ -2821,7 +2871,7 @@ importers:
|
||||||
version: 5.73.0
|
version: 5.73.0
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
exifr:
|
||||||
specifier: ^7.1.3
|
specifier: ^7.1.3
|
||||||
version: 7.1.3
|
version: 7.1.3
|
||||||
|
|
@ -2881,7 +2931,7 @@ importers:
|
||||||
version: link:../../packages/shared-research
|
version: link:../../packages/shared-research
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2914,7 +2964,7 @@ importers:
|
||||||
version: link:../../packages/shared-hono
|
version: link:../../packages/shared-hono
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2947,7 +2997,7 @@ importers:
|
||||||
version: link:../../packages/shared-hono
|
version: link:../../packages/shared-hono
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -2975,7 +3025,7 @@ importers:
|
||||||
version: link:../../packages/shared-rss
|
version: link:../../packages/shared-rss
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.3
|
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:
|
hono:
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.12.12
|
version: 4.12.12
|
||||||
|
|
@ -8001,8 +8051,8 @@ packages:
|
||||||
'@types/bun@1.3.11':
|
'@types/bun@1.3.11':
|
||||||
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
|
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
|
||||||
|
|
||||||
'@types/bun@1.3.12':
|
'@types/bun@1.3.13':
|
||||||
resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==}
|
resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
@ -9080,8 +9130,8 @@ packages:
|
||||||
bun-types@1.3.11:
|
bun-types@1.3.11:
|
||||||
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
|
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
|
||||||
|
|
||||||
bun-types@1.3.12:
|
bun-types@1.3.13:
|
||||||
resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==}
|
resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==}
|
||||||
|
|
||||||
bundle-require@5.1.0:
|
bundle-require@5.1.0:
|
||||||
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
||||||
|
|
@ -16951,6 +17001,16 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- ts-node
|
- 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))':
|
'@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:
|
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)
|
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:
|
transitivePeerDependencies:
|
||||||
- ts-node
|
- 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))':
|
'@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:
|
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)
|
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
|
nanostores: 1.2.0
|
||||||
zod: 4.3.6
|
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:
|
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/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/utils': 0.4.0
|
||||||
optionalDependencies:
|
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)':
|
'@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:
|
dependencies:
|
||||||
|
|
@ -19140,6 +19190,11 @@ snapshots:
|
||||||
'@esbuild/win32-x64@0.27.7':
|
'@esbuild/win32-x64@0.27.7':
|
||||||
optional: true
|
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))':
|
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -23136,9 +23191,9 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
bun-types: 1.3.11
|
bun-types: 1.3.11
|
||||||
|
|
||||||
'@types/bun@1.3.12':
|
'@types/bun@1.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
bun-types: 1.3.12
|
bun-types: 1.3.13
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -24062,6 +24117,108 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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):
|
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:
|
dependencies:
|
||||||
'@astrojs/compiler': 2.13.1
|
'@astrojs/compiler': 2.13.1
|
||||||
|
|
@ -24266,108 +24423,6 @@ snapshots:
|
||||||
- uploadthing
|
- uploadthing
|
||||||
- yaml
|
- 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):
|
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:
|
dependencies:
|
||||||
'@astrojs/compiler': 2.13.1
|
'@astrojs/compiler': 2.13.1
|
||||||
|
|
@ -24703,10 +24758,10 @@ snapshots:
|
||||||
|
|
||||||
bcryptjs@3.0.3: {}
|
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:
|
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/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/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/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)
|
'@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:
|
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))
|
'@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-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: 19.2.0
|
||||||
react-dom: 19.2.0(react@19.2.0)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
svelte: 5.55.1
|
svelte: 5.55.1
|
||||||
|
|
@ -24896,7 +24951,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
bun-types@1.3.12:
|
bun-types@1.3.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
|
|
@ -25656,30 +25711,30 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/pg': 8.6.1
|
'@types/pg': 8.6.1
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
bun-types: 1.3.12
|
bun-types: 1.3.13
|
||||||
kysely: 0.28.15
|
kysely: 0.28.15
|
||||||
postgres: 3.4.9
|
postgres: 3.4.9
|
||||||
react: 19.2.0
|
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:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/pg': 8.6.1
|
'@types/pg': 8.6.1
|
||||||
bun-types: 1.3.12
|
bun-types: 1.3.13
|
||||||
gel: 2.2.0
|
gel: 2.2.0
|
||||||
kysely: 0.28.15
|
kysely: 0.28.15
|
||||||
postgres: 3.4.9
|
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:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/pg': 8.6.1
|
'@types/pg': 8.6.1
|
||||||
bun-types: 1.3.12
|
bun-types: 1.3.13
|
||||||
gel: 2.2.0
|
gel: 2.2.0
|
||||||
kysely: 0.28.15
|
kysely: 0.28.15
|
||||||
postgres: 3.4.9
|
postgres: 3.4.9
|
||||||
|
|
@ -26187,6 +26242,11 @@ snapshots:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
semver: 7.7.4
|
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)):
|
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -26196,6 +26256,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
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)):
|
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -26240,6 +26304,20 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)):
|
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@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-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):
|
eslint@9.39.4(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||||
|
|
@ -33490,6 +33609,23 @@ snapshots:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
terser: 5.46.1
|
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):
|
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:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
|
|
@ -33524,23 +33660,6 @@ snapshots:
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.8.3
|
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):
|
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:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
|
|
@ -33558,6 +33677,10 @@ snapshots:
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.8.3
|
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)):
|
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:
|
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)
|
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:
|
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)
|
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)):
|
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:
|
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)
|
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