111 lines
3.8 KiB
TypeScript
111 lines
3.8 KiB
TypeScript
/**
|
|
* system-access.ts — Device authorization flow for system MCP tools
|
|
*
|
|
* Flow: Claude calls system_request_access → backend notifies browser via WS →
|
|
* user approves in /dev → Claude polls system_check_access → gets systemToken
|
|
*/
|
|
|
|
import { randomUUID } from 'crypto';
|
|
import { pushEvent } from './mcp/events.ts';
|
|
|
|
const REQUEST_TTL_MS = 5 * 60 * 1000; // 5 min to approve
|
|
const TOKEN_TTL_MS = 4 * 60 * 60 * 1000; // 4 hour token lifetime
|
|
|
|
export interface PendingRequest {
|
|
requestId: string;
|
|
userCode: string;
|
|
description: string;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface RequestEntry extends PendingRequest {
|
|
systemToken: string | null;
|
|
mcpKey?: string;
|
|
}
|
|
|
|
interface SystemTokenEntry {
|
|
requestId: string;
|
|
user: string;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const pendingMap = new Map<string, RequestEntry>();
|
|
const tokenMap = new Map<string, SystemTokenEntry>();
|
|
|
|
let notifyFn: ((req: PendingRequest) => void) | null = null;
|
|
|
|
export function setNotifyFn(fn: (req: PendingRequest) => void) {
|
|
notifyFn = fn;
|
|
}
|
|
|
|
function genUserCode(): string {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
const part = (n: number) => Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
|
return `${part(4)}-${part(4)}`;
|
|
}
|
|
|
|
export function createRequest(description: string, mcpKey?: string): PendingRequest {
|
|
const now = Date.now();
|
|
// Clean expired
|
|
for (const [id, req] of pendingMap)
|
|
if (req.expiresAt < now) pendingMap.delete(id);
|
|
|
|
const entry: RequestEntry = {
|
|
requestId: randomUUID(),
|
|
userCode: genUserCode(),
|
|
description,
|
|
createdAt: now,
|
|
expiresAt: now + REQUEST_TTL_MS,
|
|
systemToken: null,
|
|
mcpKey,
|
|
};
|
|
pendingMap.set(entry.requestId, entry);
|
|
|
|
const pub: PendingRequest = { requestId: entry.requestId, userCode: entry.userCode, description, createdAt: entry.createdAt, expiresAt: entry.expiresAt };
|
|
notifyFn?.(pub);
|
|
return pub;
|
|
}
|
|
|
|
export function getPendingRequests(): PendingRequest[] {
|
|
const now = Date.now();
|
|
const out: PendingRequest[] = [];
|
|
for (const req of pendingMap.values())
|
|
if (req.expiresAt > now && !req.systemToken)
|
|
out.push({ requestId: req.requestId, userCode: req.userCode, description: req.description, createdAt: req.createdAt, expiresAt: req.expiresAt });
|
|
return out;
|
|
}
|
|
|
|
export function approveRequest(requestId: string, user: string): string | null {
|
|
const req = pendingMap.get(requestId);
|
|
if (!req) return null;
|
|
if (req.expiresAt < Date.now()) { pendingMap.delete(requestId); return null; }
|
|
if (req.systemToken) return req.systemToken; // idempotent
|
|
const token = randomUUID();
|
|
// Push event to MCP subscriber
|
|
if (req.mcpKey) pushEvent(req.mcpKey, { type: "system_access_approved", data: { requestId, systemToken: token } });
|
|
req.systemToken = token;
|
|
tokenMap.set(token, { requestId, user, createdAt: Date.now(), expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
return token;
|
|
}
|
|
|
|
export function denyRequest(requestId: string) {
|
|
pendingMap.delete(requestId);
|
|
}
|
|
|
|
export function checkRequest(requestId: string): { status: 'pending' | 'approved' | 'expired' | 'denied'; systemToken?: string } {
|
|
const req = pendingMap.get(requestId);
|
|
if (!req) return { status: 'denied' };
|
|
if (req.expiresAt < Date.now()) { pendingMap.delete(requestId); return { status: 'expired' }; }
|
|
if (req.systemToken) return { status: 'approved', systemToken: req.systemToken };
|
|
return { status: 'pending' };
|
|
}
|
|
|
|
export function validateSystemToken(token: string): SystemTokenEntry | null {
|
|
const entry = tokenMap.get(token);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt < Date.now()) { tokenMap.delete(token); return null; }
|
|
return entry;
|
|
}
|