feat: remote pairing approval via API (#301)

This commit is contained in:
Cameron
2026-02-13 17:35:56 -08:00
committed by GitHub
parent 560380d721
commit c083638be1
4 changed files with 133 additions and 2 deletions

View File

@@ -172,7 +172,7 @@ This uses a manifest to pre-configure:
Each channel supports three DM policies: Each channel supports three DM policies:
- **`pairing`** (recommended): Users get a code, you approve via `lettabot pairing approve <channel> <code>` - **`pairing`** (recommended): Users get a code, you approve via CLI (`lettabot pairing approve <channel> <code>`) or API (`POST /api/v1/pairing/<channel>/approve`)
- **`allowlist`**: Only specified user IDs can message - **`allowlist`**: Only specified user IDs can message
- **`open`**: Anyone can message (not recommended) - **`open`**: Anyone can message (not recommended)

View File

@@ -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. 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 Limitations
| Channel | Railway Support | Notes | | Channel | Railway Support | Notes |

View File

@@ -6,7 +6,8 @@
import * as http from 'http'; import * as http from 'http';
import * as fs from 'fs'; import * as fs from 'fs';
import { validateApiKey } from './auth.js'; 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 { parseMultipart } from './multipart.js';
import type { AgentRouter } from '../core/interfaces.js'; import type { AgentRouter } from '../core/interfaces.js';
import type { ChannelId } from '../core/types.js'; import type { ChannelId } from '../core/types.js';
@@ -216,6 +217,92 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
return; 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 // Route: 404 Not Found
sendError(res, 404, 'Not found'); sendError(res, 404, 'Not found');
}); });
@@ -241,6 +328,7 @@ function readBody(req: http.IncomingMessage, maxSize: number): Promise<string> {
req.on('data', (chunk: Buffer) => { req.on('data', (chunk: Buffer) => {
size += chunk.length; size += chunk.length;
if (size > maxSize) { if (size > maxSize) {
req.destroy();
reject(new Error(`Request body too large (max ${maxSize} bytes)`)); reject(new Error(`Request body too large (max ${maxSize} bytes)`));
return; return;
} }

View File

@@ -2,6 +2,8 @@
* Request/response types for LettaBot HTTP API * Request/response types for LettaBot HTTP API
*/ */
import type { PairingRequest } from '../pairing/types.js';
export interface SendMessageRequest { export interface SendMessageRequest {
channel: string; channel: string;
chatId: string; chatId: string;
@@ -46,3 +48,23 @@ export interface ChatResponse {
agentName?: string; agentName?: string;
error?: 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;
}