From c083638be1897a7156e4196c11f659d094aea5a7 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 13 Feb 2026 17:35:56 -0800 Subject: [PATCH] feat: remote pairing approval via API (#301) --- SKILL.md | 2 +- docs/railway-deploy.md | 21 ++++++++++ src/api/server.ts | 90 +++++++++++++++++++++++++++++++++++++++++- src/api/types.ts | 22 +++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 58df5c4..04a740f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -172,7 +172,7 @@ This uses a manifest to pre-configure: Each channel supports three DM policies: -- **`pairing`** (recommended): Users get a code, you approve via `lettabot pairing approve ` +- **`pairing`** (recommended): Users get a code, you approve via CLI (`lettabot pairing approve `) or API (`POST /api/v1/pairing//approve`) - **`allowlist`**: Only specified user IDs can message - **`open`**: Anyone can message (not recommended) diff --git a/docs/railway-deploy.md b/docs/railway-deploy.md index 2956cfb..73924ce 100644 --- a/docs/railway-deploy.md +++ b/docs/railway-deploy.md @@ -103,6 +103,27 @@ If you deploy manually from a fork instead of using the template, you'll need to LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persistent data. +## Remote Pairing Approval + +When using `pairing` DM policy on Railway, you can approve new users via the HTTP API instead of the CLI: + +```bash +# List pending pairing requests for a channel +curl -H "X-Api-Key: $LETTABOT_API_KEY" \ + https://your-app.railway.app/api/v1/pairing/telegram + +# Approve a pairing code +curl -X POST \ + -H "X-Api-Key: $LETTABOT_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"code": "ABCD1234"}' \ + https://your-app.railway.app/api/v1/pairing/telegram/approve +``` + +`LETTABOT_API_KEY` is auto-generated on first boot and printed in logs. Set it as a Railway variable for stable access across deploys. + +Alternatively, use `allowlist` DM policy and pre-configure allowed users in environment variables to skip pairing entirely. + ## Channel Limitations | Channel | Railway Support | Notes | diff --git a/src/api/server.ts b/src/api/server.ts index 3a0dfb5..7f916fb 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -6,7 +6,8 @@ import * as http from 'http'; import * as fs from 'fs'; import { validateApiKey } from './auth.js'; -import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse } from './types.js'; +import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js'; +import { listPairingRequests, approvePairingCode } from '../pairing/store.js'; import { parseMultipart } from './multipart.js'; import type { AgentRouter } from '../core/interfaces.js'; import type { ChannelId } from '../core/types.js'; @@ -216,6 +217,92 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): return; } + // Route: GET /api/v1/pairing/:channel - List pending pairing requests + const pairingListMatch = req.url?.match(/^\/api\/v1\/pairing\/([a-z0-9-]+)$/); + if (pairingListMatch && req.method === 'GET') { + try { + if (!validateApiKey(req.headers, options.apiKey)) { + sendError(res, 401, 'Unauthorized'); + return; + } + + const channel = pairingListMatch[1]; + if (!VALID_CHANNELS.includes(channel as ChannelId)) { + sendError(res, 400, `Invalid channel: ${channel}`, 'channel'); + return; + } + + const requests = await listPairingRequests(channel); + const response: PairingListResponse = { requests }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + } catch (error: any) { + console.error('[API] Pairing list error:', error); + sendError(res, 500, error.message || 'Internal server error'); + } + return; + } + + // Route: POST /api/v1/pairing/:channel/approve - Approve a pairing code + const pairingApproveMatch = req.url?.match(/^\/api\/v1\/pairing\/([a-z0-9-]+)\/approve$/); + if (pairingApproveMatch && req.method === 'POST') { + try { + if (!validateApiKey(req.headers, options.apiKey)) { + sendError(res, 401, 'Unauthorized'); + return; + } + + const channel = pairingApproveMatch[1]; + if (!VALID_CHANNELS.includes(channel as ChannelId)) { + sendError(res, 400, `Invalid channel: ${channel}`, 'channel'); + return; + } + + const contentType = req.headers['content-type'] || ''; + if (!contentType.includes('application/json')) { + sendError(res, 400, 'Content-Type must be application/json'); + return; + } + + const body = await readBody(req, MAX_BODY_SIZE); + let approveReq: PairingApproveRequest; + try { + approveReq = JSON.parse(body); + } catch { + sendError(res, 400, 'Invalid JSON body'); + return; + } + + if (!approveReq.code || typeof approveReq.code !== 'string') { + sendError(res, 400, 'Missing required field: code'); + return; + } + + const result = await approvePairingCode(channel, approveReq.code); + if (!result) { + const response: PairingApproveResponse = { + success: false, + error: 'Code not found or expired', + }; + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + console.log(`[API] Pairing approved: ${channel} user ${result.userId}`); + const response: PairingApproveResponse = { + success: true, + userId: result.userId, + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + } catch (error: any) { + console.error('[API] Pairing approve error:', error); + sendError(res, 500, error.message || 'Internal server error'); + } + return; + } + // Route: 404 Not Found sendError(res, 404, 'Not found'); }); @@ -241,6 +328,7 @@ function readBody(req: http.IncomingMessage, maxSize: number): Promise { req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > maxSize) { + req.destroy(); reject(new Error(`Request body too large (max ${maxSize} bytes)`)); return; } diff --git a/src/api/types.ts b/src/api/types.ts index b06e1b7..6a3bbbc 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -2,6 +2,8 @@ * Request/response types for LettaBot HTTP API */ +import type { PairingRequest } from '../pairing/types.js'; + export interface SendMessageRequest { channel: string; chatId: string; @@ -46,3 +48,23 @@ export interface ChatResponse { agentName?: string; error?: string; } + +/** + * GET /api/v1/pairing/:channel - List pending pairing requests + */ +export interface PairingListResponse { + requests: PairingRequest[]; +} + +/** + * POST /api/v1/pairing/:channel/approve - Approve a pairing code + */ +export interface PairingApproveRequest { + code: string; +} + +export interface PairingApproveResponse { + success: boolean; + userId?: string; + error?: string; +}