diff --git a/.gitignore b/.gitignore index 4a71931..a298fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ dist .pnp.* .DS_Store + +# Deploy secrets +deploy/.env diff --git a/README.md b/README.md index 49a0a5e..36acb62 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,93 @@ node dist/mcplets/media-pool/sns/index.js --- +## Production Deployment (a2a-demo.mcplet.ai) + +Demo is deployed on a VPS (153.126.215.188) with nginx + Let's Encrypt SSL. + +### Architecture + +``` +External Users + ↓ HTTPS +nginx (443, Let's Encrypt auto-renew) + ├── / → Dashboard :4000 + ├── /a2a/* → A2A External Endpoint :4001 + └── /passkey-api/* → Passkey API :8443 +MCPletA2A (systemd service) + ├── Mock Service :5100 (internal only) + └── Platform Host → 8 MCPlet servers (stdio) +``` + +### Access + +- URL: https://a2a-demo.mcplet.ai +- Basic Auth: `admin` / `m2a69988` +- A2A API endpoint (`/a2a/*`) does not require Basic Auth (uses Bearer token) + +### Service Management + +```bash +# Status / logs +sudo systemctl status mcplet-a2a +journalctl -u mcplet-a2a -f + +# Restart / stop +sudo systemctl restart mcplet-a2a +sudo systemctl stop mcplet-a2a +``` + +### Key Files on VPS + +| Path | Description | +|------|-------------| +| `/home/ubuntu/MCPletA2A/` | Project root | +| `/home/ubuntu/MCPletA2A/deploy/.env` | API keys (OPENROUTER_API_KEY) | +| `/home/ubuntu/MCPletA2A/deploy/start-vps.sh` | Startup script (used by systemd) | +| `/etc/systemd/system/mcplet-a2a.service` | systemd unit file | +| `/etc/nginx/sites-available/a2a-demo.conf` | nginx reverse proxy config | +| `/etc/letsencrypt/live/a2a-demo.mcplet.ai/` | SSL certificate (auto-renews) | + +### Updating + +```bash +# On local machine: push changes, then on VPS: +cd /home/ubuntu/MCPletA2A +git pull +cd platform_impl && npm run build +cd ../reference_impl && npm run build +sudo systemctl restart mcplet-a2a +``` + +Or from local machine via rsync: + +```bash +rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.logs' --exclude='.pids' --exclude='.env' \ + -e ssh . ubuntu@153.126.215.188:/home/ubuntu/MCPletA2A/ +ssh ubuntu@153.126.215.188 "cd /home/ubuntu/MCPletA2A/platform_impl && npm run build && cd ../reference_impl && npm run build && sudo systemctl restart mcplet-a2a" +``` + +### Passkey Modes + +| Mode | Config | Behavior | +|------|--------|----------| +| `https` | Local dev | Opens browser on server machine | +| `remote` | Production | Approval via Dashboard web UI notification | +| `demo` | Testing | Auto-approves all passkey ceremonies | + +Current production config: `reference_impl/config/reference.yaml` → `passkey.mode: remote` + +### SSL Certificate + +Managed by certbot. Auto-renews before expiry. To manually check: + +```bash +sudo certbot certificates +sudo certbot renew --dry-run +``` + +--- + ## Spec Compliance | Requirement | Implementation | diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..0425a51 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,6 @@ +# MCPletA2A — Environment Variables +# Copy to .env and fill in values: cp .env.example .env + +# LLM API Key (choose one) +# ANTHROPIC_API_KEY=sk-ant-... +OPENROUTER_API_KEY=sk-or-... diff --git a/deploy/mcplet-a2a.service b/deploy/mcplet-a2a.service new file mode 100644 index 0000000..ce4e85b --- /dev/null +++ b/deploy/mcplet-a2a.service @@ -0,0 +1,19 @@ +[Unit] +Description=MCPletA2A Platform Host +After=network.target + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/home/ubuntu/MCPletA2A +EnvironmentFile=/home/ubuntu/MCPletA2A/deploy/.env +Environment=REF_IMPL_DIST=/home/ubuntu/MCPletA2A/reference_impl/dist +Environment=MCPLET_CONFIG=/home/ubuntu/MCPletA2A/reference_impl/config/reference.yaml +Environment=MCPLET_AGENT_MODULE=file:///home/ubuntu/MCPletA2A/reference_impl/dist/agents/register.js +Environment=NODE_ENV=production +ExecStart=/bin/bash /home/ubuntu/MCPletA2A/deploy/start-vps.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-a2a-demo.conf b/deploy/nginx-a2a-demo.conf new file mode 100644 index 0000000..f8fedad --- /dev/null +++ b/deploy/nginx-a2a-demo.conf @@ -0,0 +1,58 @@ +# MCPletA2A — nginx reverse proxy for a2a-demo.mcplet.ai +# Install: sudo cp deploy/nginx-a2a-demo.conf /etc/nginx/sites-available/a2a-demo.conf +# sudo ln -s /etc/nginx/sites-available/a2a-demo.conf /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx + +server { + listen 80; + listen [::]:80; + server_name a2a-demo.mcplet.ai; + + # Certbot will add HTTPS redirect here + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name a2a-demo.mcplet.ai; + + # Certbot will fill these in + # ssl_certificate /etc/letsencrypt/live/a2a-demo.mcplet.ai/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/a2a-demo.mcplet.ai/privkey.pem; + + # A2A External Endpoint + location /a2a/ { + proxy_pass http://127.0.0.1:4001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Passkey REST API (proxied, strip prefix) + location /passkey-api/ { + rewrite ^/passkey-api(/.*)$ $1 break; + proxy_pass http://127.0.0.1:8443; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Dashboard (default) + location / { + proxy_pass http://127.0.0.1:4000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Security headers + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; +} diff --git a/deploy/start-vps.sh b/deploy/start-vps.sh new file mode 100644 index 0000000..771076b --- /dev/null +++ b/deploy/start-vps.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# MCPletA2A — VPS startup script (used by systemd) +set -euo pipefail + +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REF_DIR="$BASE_DIR/reference_impl" +PLATFORM_DIR="$BASE_DIR/platform_impl" + +export REF_IMPL_DIST="$REF_DIR/dist" +export MCPLET_CONFIG="$REF_DIR/config/reference.yaml" +export MCPLET_AGENT_MODULE="file://$REF_DIR/dist/agents/register.js" + +# Start Mock Service in background +node "$REF_DIR/dist/mock-services/server.js" & +MOCK_PID=$! + +# Wait until mock service is ready (max 10s) +for i in $(seq 1 20); do + if node -e " + const net = require('net'); + const s = net.createConnection(5100, '127.0.0.1'); + s.on('connect', () => { s.destroy(); process.exit(0); }); + s.on('error', () => { s.destroy(); process.exit(1); }); + " 2>/dev/null; then + echo "[vps] Mock Service ready (pid $MOCK_PID)" + break + fi + sleep 0.5 + if [ "$i" -eq 20 ]; then + echo "[vps] ERROR: Mock Service did not start" >&2 + exit 1 + fi +done + +# Start Platform Host (foreground — systemd monitors this) +exec node "$PLATFORM_DIR/dist/index.js" diff --git a/platform_impl/src/agents/base-agent.ts b/platform_impl/src/agents/base-agent.ts index 6510f6f..26e51f3 100644 --- a/platform_impl/src/agents/base-agent.ts +++ b/platform_impl/src/agents/base-agent.ts @@ -5,18 +5,25 @@ import type { MCPletTool, MCPletToolResult, MCPletErrorCode, + MCPletAuthPayload, } from '../types/index.js'; import type { PoolRegistry } from '../pools/pool-registry.js'; import type { LLMAdapter, LLMMessage, LLMToolDef, LLMToolCall } from '../llm/llm-adapter.js'; -import type { PasskeyServer, PasskeyPlatformService } from '../passkey/index.js'; +import type { PasskeyPlatformService } from '../passkey/index.js'; import type { MCPletRouter } from '../host/mcplet-router.js'; import { AuditLog } from '../host/audit-log.js'; +/** Common interface for all passkey server variants (local, remote, demo). */ +export interface IPasskeyCeremonyServer { + startCeremony(promptMessage: string, username?: string): Promise; + getPlatformService(): PasskeyPlatformService; +} + export interface AgentDeps { poolRegistry: PoolRegistry; mcpRouter: MCPletRouter; llm: LLMAdapter; - passkeyServer?: PasskeyServer; + passkeyServer?: IPasskeyCeremonyServer; passkeyPlatformService?: PasskeyPlatformService; auditLog: AuditLog; } diff --git a/platform_impl/src/dashboard/dashboard-server.ts b/platform_impl/src/dashboard/dashboard-server.ts index f26adb8..de26daa 100644 --- a/platform_impl/src/dashboard/dashboard-server.ts +++ b/platform_impl/src/dashboard/dashboard-server.ts @@ -6,6 +6,8 @@ import type { PoolRegistry } from '../pools/pool-registry.js'; import type { AuditLog } from '../host/audit-log.js'; import type { A2ALocalBus } from '../a2a/local-bus.js'; import type { DirectorAgent } from '../agents/director-agent.js'; +import type { RemotePasskeyServer } from '../passkey/remote-passkey-server.js'; +import type { MCPletAuthPayload } from '../types/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,11 +19,12 @@ export class DashboardServer { private readonly auditLog: AuditLog, private readonly localBus: A2ALocalBus, private readonly directorAgent?: DirectorAgent, + private readonly remotePasskeyServer?: RemotePasskeyServer, ) {} start(port: number): void { this.server = http.createServer((req, res) => { - this.handle(req, res); + void this.handle(req, res); }); this.server.listen(port, () => { console.log(`[dashboard] Listening on http://localhost:${port}`); @@ -32,7 +35,7 @@ export class DashboardServer { this.server?.close(); } - private handle(req: http.IncomingMessage, res: http.ServerResponse): void { + private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url ?? '/', `http://localhost`); if (url.pathname === '/api/tools') { @@ -58,12 +61,52 @@ export class DashboardServer { sendJson(res, 202, { message: 'Director cycle triggered. Watch /api/audit for progress.' }); return; } + // ── Remote Passkey ceremony routes ── + if (url.pathname === '/api/passkey/pending') { + if (!this.remotePasskeyServer) { + sendJson(res, 404, { error: 'Remote passkey not configured' }); + return; + } + sendJson(res, 200, this.remotePasskeyServer.getPendingCeremonies()); + return; + } + if (req.method === 'POST' && url.pathname === '/api/passkey/complete') { + if (!this.remotePasskeyServer) { + sendJson(res, 404, { error: 'Remote passkey not configured' }); + return; + } + let body: { token: string; assertion: MCPletAuthPayload }; + try { + body = await parseJsonBody(req); + } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const ok = this.remotePasskeyServer.completeCeremony(body.token, body.assertion); + sendJson(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'Token not found or expired' }); + return; + } + if (url.pathname === '/passkey/ceremony') { + if (!this.remotePasskeyServer) { + res.writeHead(404); + res.end('Remote passkey not configured'); + return; + } + const token = url.searchParams.get('token') ?? ''; + servePasskeyCeremonyPage( + res, token, + this.remotePasskeyServer.getFido2ServerUrl(), + this.remotePasskeyServer.getRpId(), + ); + return; + } + if (url.pathname === '/favicon.ico') { serveFavicon(res); return; } if (url.pathname === '/' || url.pathname === '/index.html') { - serveDashboardPage(res); + serveDashboardPage(res, !!this.remotePasskeyServer); return; } @@ -93,7 +136,7 @@ function serveFavicon(res: http.ServerResponse): void { } } -function serveDashboardPage(res: http.ServerResponse): void { +function serveDashboardPage(res: http.ServerResponse, passkeyEnabled: boolean): void { const staticPath = path.resolve(__dirname, '../../public/dashboard/index.html'); if (fs.existsSync(staticPath)) { const html = fs.readFileSync(staticPath, 'utf-8'); @@ -101,11 +144,23 @@ function serveDashboardPage(res: http.ServerResponse): void { res.end(html); } else { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(inlineDashboard()); + res.end(inlineDashboard(passkeyEnabled)); } } -function inlineDashboard(): string { +function parseJsonBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk: Buffer) => { data += chunk.toString(); }); + req.on('end', () => { + try { resolve(JSON.parse(data) as T); } + catch { reject(new Error('Invalid JSON')); } + }); + req.on('error', reject); + }); +} + +function inlineDashboard(passkeyEnabled: boolean): string { return ` @@ -238,6 +293,29 @@ function inlineDashboard(): string { .audit-scroll::-webkit-scrollbar { width: 6px; } .audit-scroll::-webkit-scrollbar-thumb { background: rgba(99,102,241,.3); border-radius: 3px; } + /* ── Passkey banner ── */ + .passkey-banner { + background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.35); + border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; + display: none; + } + .passkey-banner.visible { display: block; } + .passkey-banner h3 { margin: 0 0 10px; font-size: 14px; color: #fbbf24; font-weight: 600; } + .passkey-item { + display: flex; align-items: center; gap: 12px; padding: 8px 0; + border-bottom: 1px solid rgba(251,191,36,.12); + } + .passkey-item:last-child { border-bottom: none; } + .passkey-item .prompt { flex: 1; font-size: 13px; color: #e2e8f0; } + .passkey-item .ts { font-size: 11px; } + .passkey-approve-btn { + padding: 6px 16px; border: none; border-radius: 6px; font-size: 12px; + font-weight: 600; cursor: pointer; color: #000; + background: linear-gradient(135deg, #fbbf24, #f59e0b); + transition: transform .1s; + } + .passkey-approve-btn:hover { transform: translateY(-1px); } + /* ── Footer ── */ .footer { text-align: center; padding: 20px; font-size: 12px; color: #475569; } @@ -265,6 +343,11 @@ function inlineDashboard(): string { +
+

Passkey Approval Required

+
+
+

Registered MCPlet Tools

@@ -354,9 +437,311 @@ function inlineDashboard(): string { } } + // ── Passkey polling (remote mode) ── + var PASSKEY_ENABLED = ${passkeyEnabled}; + + async function loadPasskey() { + if (!PASSKEY_ENABLED) return; + try { + var pending = await fetch('/api/passkey/pending').then(function(r){ return r.json(); }); + var banner = document.getElementById('passkeyBanner'); + var list = document.getElementById('passkeyList'); + if (!pending.length) { + banner.className = 'passkey-banner'; + list.innerHTML = ''; + return; + } + banner.className = 'passkey-banner visible'; + list.innerHTML = pending.map(function(p) { + return '
' + + '' + p.promptMessage + '' + + '' + formatLocalTime(p.createdAt) + '' + + '' + + '
'; + }).join(''); + } catch(e) { /* ignore */ } + } + + function openCeremony(token) { + window.open('/passkey/ceremony?token=' + token, 'passkey_ceremony', + 'width=480,height=600,menubar=no,toolbar=no'); + } + load(); + loadPasskey(); setInterval(load, 10000); + setInterval(loadPasskey, 3000); `; } + +// ─── Remote Passkey Ceremony Page ────────────────────────────────────── + +function servePasskeyCeremonyPage( + res: http.ServerResponse, + token: string, + fido2ServerUrl: string, + rpId: string, +): void { + const html = buildCeremonyPage(token, fido2ServerUrl, rpId); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); +} + +function escapeHtml(str: string): string { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function buildCeremonyPage(token: string, fido2ServerUrl: string, rpId: string): string { + return ` + + + + Passkey Approval — MCPletA2A + + + +
+

Passkey Approval

+

Authenticate with your passkey to approve the pending action.

+ +
+ + +
+ + +
+ +
+
+ + + +`; +} diff --git a/platform_impl/src/host/mcplet-host.ts b/platform_impl/src/host/mcplet-host.ts index c17dadc..1a8adc1 100644 --- a/platform_impl/src/host/mcplet-host.ts +++ b/platform_impl/src/host/mcplet-host.ts @@ -8,11 +8,11 @@ import { createLLMAdapter } from '../llm/claude-adapter.js'; import type { LLMAdapter } from '../llm/llm-adapter.js'; import { A2ALocalBus, type IAgent } from '../a2a/local-bus.js'; import { A2AExternalEndpoint } from '../a2a/external-endpoint.js'; -import { PasskeyServer, PasskeyAPIServer, PasskeyPlatformService } from '../passkey/index.js'; +import { PasskeyServer, PasskeyAPIServer, PasskeyPlatformService, RemotePasskeyServer, DemoPasskeyServer } from '../passkey/index.js'; import { DashboardServer } from '../dashboard/dashboard-server.js'; import { DirectorAgent } from '../agents/director-agent.js'; import { AuditLog } from './audit-log.js'; -import type { AgentDeps } from '../agents/base-agent.js'; +import type { AgentDeps, IPasskeyCeremonyServer } from '../agents/base-agent.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AgentConstructor = new (agentId: string, accessiblePools: string[], deps: any) => IAgent; @@ -29,9 +29,10 @@ export class MCPletHost { private llm!: LLMAdapter; private localBus!: A2ALocalBus; private auditLog!: AuditLog; - private passkeyServer?: PasskeyServer; + private passkeyServer?: IPasskeyCeremonyServer; private passkeyPlatformService?: PasskeyPlatformService; private passkeyAPIServer?: PasskeyAPIServer; + private remotePasskeyServer?: RemotePasskeyServer; private dashboardServer?: DashboardServer; private externalEndpoint?: A2AExternalEndpoint; private directorAgent?: DirectorAgent; @@ -49,13 +50,14 @@ export class MCPletHost { if (config.passkey) { const rpId = config.passkey.rpId || 'localhost'; const fido2ServerUrl = config.passkey.fido2ServerUrl || 'https://fido2.epk.amipro.me'; - const origin = config.passkey.mode === 'https' + const mode = config.passkey.mode; + const origin = (mode === 'https' || mode === 'remote') ? `https://${rpId}` : 'http://127.0.0.1'; // Create Passkey Platform Service (delegates to external FIDO2 server) this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl); - console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl})`); + console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl}, mode: ${mode})`); // Create and start Passkey API Server this.passkeyAPIServer = new PasskeyAPIServer( @@ -64,10 +66,22 @@ export class MCPletHost { origin, ); const apiPort = config.passkey.apiPort || 8443; - this.passkeyAPIServer.start(apiPort); + const apiHost = mode === 'remote' ? '0.0.0.0' : '127.0.0.1'; + this.passkeyAPIServer.start(apiPort, apiHost); - // PasskeyServer for interactive browser ceremonies - this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl); + // PasskeyServer — mode-dependent + if (mode === 'remote') { + const remote = new RemotePasskeyServer(rpId, fido2ServerUrl); + this.passkeyServer = remote; + this.remotePasskeyServer = remote; + console.log('[host] PasskeyServer: remote mode (approve via Dashboard)'); + } else if (mode === 'demo') { + this.passkeyServer = new DemoPasskeyServer(rpId, fido2ServerUrl); + console.log('[host] PasskeyServer: demo mode (auto-approve)'); + } else { + this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl); + console.log('[host] PasskeyServer: local browser mode'); + } } // Connect MCPlet servers declared in config (each is a stdio child process) @@ -100,6 +114,7 @@ export class MCPletHost { if (config.dashboard) { this.dashboardServer = new DashboardServer( this.poolRegistry, this.auditLog, this.localBus, this.directorAgent, + this.remotePasskeyServer, ); this.dashboardServer.start(config.dashboard.port); } diff --git a/platform_impl/src/passkey/api-server.ts b/platform_impl/src/passkey/api-server.ts index 7051d7b..addeea3 100644 --- a/platform_impl/src/passkey/api-server.ts +++ b/platform_impl/src/passkey/api-server.ts @@ -24,13 +24,13 @@ export class PasskeyAPIServer { private readonly origin: string, ) {} - start(port: number): void { + start(port: number, host: string = '127.0.0.1'): void { this.server = http.createServer((req, res) => { void this.handleRequest(req, res); }); - this.server.listen(port, '127.0.0.1', () => { - console.log(`[passkey-api] Server listening on http://127.0.0.1:${port}`); + this.server.listen(port, host, () => { + console.log(`[passkey-api] Server listening on http://${host}:${port}`); }); } diff --git a/platform_impl/src/passkey/index.ts b/platform_impl/src/passkey/index.ts index 0583123..cd4e89d 100644 --- a/platform_impl/src/passkey/index.ts +++ b/platform_impl/src/passkey/index.ts @@ -18,6 +18,8 @@ export { PasskeyPlatformService } from './platform-service.js'; // Servers export { PasskeyServer } from './passkey-server.js'; export { PasskeyAPIServer } from './api-server.js'; +export { RemotePasskeyServer, DemoPasskeyServer } from './remote-passkey-server.js'; +export type { PendingCeremonyInfo } from './remote-passkey-server.js'; // Storage (for local credential tracking if needed) export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js'; diff --git a/platform_impl/src/passkey/remote-passkey-server.ts b/platform_impl/src/passkey/remote-passkey-server.ts new file mode 100644 index 0000000..6f82f0f --- /dev/null +++ b/platform_impl/src/passkey/remote-passkey-server.ts @@ -0,0 +1,168 @@ +import { randomUUID } from 'node:crypto'; +import type { MCPletAuthPayload } from '../types/index.js'; +import { PasskeyPlatformService } from './platform-service.js'; + +/** + * Pending passkey ceremony — waiting for remote operator approval via Dashboard. + */ +export interface PendingCeremony { + token: string; + promptMessage: string; + username?: string; + createdAt: string; + resolve: (payload: MCPletAuthPayload) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +/** + * Serializable view of a pending ceremony (for Dashboard API). + */ +export interface PendingCeremonyInfo { + token: string; + promptMessage: string; + username?: string; + createdAt: string; +} + +/** + * RemotePasskeyServer — Passkey ceremony via Dashboard Web UI. + * + * Instead of opening a local browser, creates a pending ceremony that + * the Dashboard polls for. The operator completes the WebAuthn ceremony + * in their remote browser, and the Dashboard POSTs the assertion back. + */ +export class RemotePasskeyServer { + private readonly timeoutMs: number; + private readonly pending = new Map(); + private platformService: PasskeyPlatformService; + + constructor( + rpId: string = 'localhost', + fido2ServerUrl: string = 'https://fido2.epk.amipro.me', + timeoutMs = 180_000, + ) { + this.timeoutMs = timeoutMs; + this.platformService = new PasskeyPlatformService(rpId, fido2ServerUrl); + } + + getPlatformService(): PasskeyPlatformService { + return this.platformService; + } + + /** + * Start a passkey ceremony. Returns a Promise that resolves when + * the remote operator completes the WebAuthn flow via the Dashboard. + */ + async startCeremony( + promptMessage: string, + username?: string, + ): Promise { + const token = randomUUID(); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(token); + reject(new Error('Remote passkey ceremony timed out')); + }, this.timeoutMs); + + const ceremony: PendingCeremony = { + token, + promptMessage, + username, + createdAt: new Date().toISOString(), + resolve, + reject, + timer, + }; + + this.pending.set(token, ceremony); + console.log(`[passkey-remote] Ceremony pending: ${token} — "${promptMessage}"`); + console.log(`[passkey-remote] Approve via Dashboard: /passkey/ceremony?token=${token}`); + }); + } + + /** + * Get all pending ceremonies (for Dashboard polling). + */ + getPendingCeremonies(): PendingCeremonyInfo[] { + return Array.from(this.pending.values()).map((c) => ({ + token: c.token, + promptMessage: c.promptMessage, + username: c.username, + createdAt: c.createdAt, + })); + } + + /** + * Complete a pending ceremony with the WebAuthn assertion from the Dashboard. + */ + completeCeremony(token: string, payload: MCPletAuthPayload): boolean { + const ceremony = this.pending.get(token); + if (!ceremony) return false; + + clearTimeout(ceremony.timer); + this.pending.delete(token); + ceremony.resolve(payload); + console.log(`[passkey-remote] Ceremony completed: ${token}`); + return true; + } + + /** + * Cancel a pending ceremony. + */ + cancelCeremony(token: string): boolean { + const ceremony = this.pending.get(token); + if (!ceremony) return false; + + clearTimeout(ceremony.timer); + this.pending.delete(token); + ceremony.reject(new Error('Passkey ceremony cancelled by operator')); + console.log(`[passkey-remote] Ceremony cancelled: ${token}`); + return true; + } + + /** FIDO2 server URL (needed by the ceremony page). */ + getFido2ServerUrl(): string { + return this.platformService.getFido2ServerUrl(); + } + + /** RP ID (needed by the ceremony page). */ + getRpId(): string { + return this.platformService.getRpId(); + } +} + +/** + * DemoPasskeyServer — Auto-approves all passkey ceremonies with a mock assertion. + * Useful for CI testing or quick demos without real WebAuthn hardware. + */ +export class DemoPasskeyServer { + private platformService: PasskeyPlatformService; + + constructor( + rpId: string = 'localhost', + fido2ServerUrl: string = 'https://fido2.epk.amipro.me', + ) { + this.platformService = new PasskeyPlatformService(rpId, fido2ServerUrl); + } + + getPlatformService(): PasskeyPlatformService { + return this.platformService; + } + + async startCeremony( + promptMessage: string, + _username?: string, + ): Promise { + console.log(`[passkey-demo] Auto-approving: "${promptMessage}"`); + return { + type: 'passkey_assertion', + challenge: 'demo-challenge', + clientDataJSON: 'demo-clientDataJSON', + authenticatorData: 'demo-authenticatorData', + signature: 'demo-signature', + userHandle: 'demo-user', + }; + } +} diff --git a/platform_impl/src/types/config.ts b/platform_impl/src/types/config.ts index 31b06ef..d807378 100644 --- a/platform_impl/src/types/config.ts +++ b/platform_impl/src/types/config.ts @@ -36,7 +36,7 @@ export interface LLMConfig { } export interface PasskeyConfig { - mode: 'localhost' | 'https'; + mode: 'localhost' | 'https' | 'remote' | 'demo'; rpId: string; fido2ServerUrl?: string; apiPort?: number; // Port for REST API (default: 8443) diff --git a/reference_impl/config/reference.yaml b/reference_impl/config/reference.yaml index 92821b3..83f740a 100644 --- a/reference_impl/config/reference.yaml +++ b/reference_impl/config/reference.yaml @@ -95,7 +95,7 @@ directorAgent: externalAgents: [] passkey: - mode: https + mode: remote rpId: a2a-demo.mcplet.ai fido2ServerUrl: https://fido2.epk.amipro.me apiPort: 8443