mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 13:43:37 +02:00
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>
93 lines
3 KiB
TypeScript
93 lines
3 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|