| import crypto from "node:crypto"; |
| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
|
|
| import lockfile from "proper-lockfile"; |
| import { getPairingAdapter } from "../channels/plugins/pairing.js"; |
| import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js"; |
| import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; |
|
|
| const PAIRING_CODE_LENGTH = 8; |
| const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; |
| const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; |
| const PAIRING_PENDING_MAX = 3; |
| const PAIRING_STORE_LOCK_OPTIONS = { |
| retries: { |
| retries: 10, |
| factor: 2, |
| minTimeout: 100, |
| maxTimeout: 10_000, |
| randomize: true, |
| }, |
| stale: 30_000, |
| } as const; |
|
|
| export type PairingChannel = ChannelId; |
|
|
| export type PairingRequest = { |
| id: string; |
| code: string; |
| createdAt: string; |
| lastSeenAt: string; |
| meta?: Record<string, string>; |
| }; |
|
|
| type PairingStore = { |
| version: 1; |
| requests: PairingRequest[]; |
| }; |
|
|
| type AllowFromStore = { |
| version: 1; |
| allowFrom: string[]; |
| }; |
|
|
| function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { |
| const stateDir = resolveStateDir(env, os.homedir); |
| return resolveOAuthDir(env, stateDir); |
| } |
|
|
| |
| function safeChannelKey(channel: PairingChannel): string { |
| const raw = String(channel).trim().toLowerCase(); |
| if (!raw) { |
| throw new Error("invalid pairing channel"); |
| } |
| const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); |
| if (!safe || safe === "_") { |
| throw new Error("invalid pairing channel"); |
| } |
| return safe; |
| } |
|
|
| function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string { |
| return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`); |
| } |
|
|
| function resolveAllowFromPath( |
| channel: PairingChannel, |
| env: NodeJS.ProcessEnv = process.env, |
| ): string { |
| return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-allowFrom.json`); |
| } |
|
|
| function safeParseJson<T>(raw: string): T | null { |
| try { |
| return JSON.parse(raw) as T; |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function readJsonFile<T>( |
| filePath: string, |
| fallback: T, |
| ): Promise<{ value: T; exists: boolean }> { |
| try { |
| const raw = await fs.promises.readFile(filePath, "utf-8"); |
| const parsed = safeParseJson<T>(raw); |
| if (parsed == null) { |
| return { value: fallback, exists: true }; |
| } |
| return { value: parsed, exists: true }; |
| } catch (err) { |
| const code = (err as { code?: string }).code; |
| if (code === "ENOENT") { |
| return { value: fallback, exists: false }; |
| } |
| return { value: fallback, exists: false }; |
| } |
| } |
|
|
| async function writeJsonFile(filePath: string, value: unknown): Promise<void> { |
| const dir = path.dirname(filePath); |
| await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); |
| const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); |
| await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { |
| encoding: "utf-8", |
| }); |
| await fs.promises.chmod(tmp, 0o600); |
| await fs.promises.rename(tmp, filePath); |
| } |
|
|
| async function ensureJsonFile(filePath: string, fallback: unknown) { |
| try { |
| await fs.promises.access(filePath); |
| } catch { |
| await writeJsonFile(filePath, fallback); |
| } |
| } |
|
|
| async function withFileLock<T>( |
| filePath: string, |
| fallback: unknown, |
| fn: () => Promise<T>, |
| ): Promise<T> { |
| await ensureJsonFile(filePath, fallback); |
| let release: (() => Promise<void>) | undefined; |
| try { |
| release = await lockfile.lock(filePath, PAIRING_STORE_LOCK_OPTIONS); |
| return await fn(); |
| } finally { |
| if (release) { |
| try { |
| await release(); |
| } catch { |
| |
| } |
| } |
| } |
| } |
|
|
| function parseTimestamp(value: string | undefined): number | null { |
| if (!value) { |
| return null; |
| } |
| const parsed = Date.parse(value); |
| if (!Number.isFinite(parsed)) { |
| return null; |
| } |
| return parsed; |
| } |
|
|
| function isExpired(entry: PairingRequest, nowMs: number): boolean { |
| const createdAt = parseTimestamp(entry.createdAt); |
| if (!createdAt) { |
| return true; |
| } |
| return nowMs - createdAt > PAIRING_PENDING_TTL_MS; |
| } |
|
|
| function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) { |
| const kept: PairingRequest[] = []; |
| let removed = false; |
| for (const req of reqs) { |
| if (isExpired(req, nowMs)) { |
| removed = true; |
| continue; |
| } |
| kept.push(req); |
| } |
| return { requests: kept, removed }; |
| } |
|
|
| function resolveLastSeenAt(entry: PairingRequest): number { |
| return parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0; |
| } |
|
|
| function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) { |
| if (maxPending <= 0 || reqs.length <= maxPending) { |
| return { requests: reqs, removed: false }; |
| } |
| const sorted = reqs.slice().toSorted((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b)); |
| return { requests: sorted.slice(-maxPending), removed: true }; |
| } |
|
|
| function randomCode(): string { |
| |
| let out = ""; |
| for (let i = 0; i < PAIRING_CODE_LENGTH; i++) { |
| const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length); |
| out += PAIRING_CODE_ALPHABET[idx]; |
| } |
| return out; |
| } |
|
|
| function generateUniqueCode(existing: Set<string>): string { |
| for (let attempt = 0; attempt < 500; attempt += 1) { |
| const code = randomCode(); |
| if (!existing.has(code)) { |
| return code; |
| } |
| } |
| throw new Error("failed to generate unique pairing code"); |
| } |
|
|
| function normalizeId(value: string | number): string { |
| return String(value).trim(); |
| } |
|
|
| function normalizeAllowEntry(channel: PairingChannel, entry: string): string { |
| const trimmed = entry.trim(); |
| if (!trimmed) { |
| return ""; |
| } |
| if (trimmed === "*") { |
| return ""; |
| } |
| const adapter = getPairingAdapter(channel); |
| const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed; |
| return String(normalized).trim(); |
| } |
|
|
| export async function readChannelAllowFromStore( |
| channel: PairingChannel, |
| env: NodeJS.ProcessEnv = process.env, |
| ): Promise<string[]> { |
| const filePath = resolveAllowFromPath(channel, env); |
| const { value } = await readJsonFile<AllowFromStore>(filePath, { |
| version: 1, |
| allowFrom: [], |
| }); |
| const list = Array.isArray(value.allowFrom) ? value.allowFrom : []; |
| return list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean); |
| } |
|
|
| export async function addChannelAllowFromStoreEntry(params: { |
| channel: PairingChannel; |
| entry: string | number; |
| env?: NodeJS.ProcessEnv; |
| }): Promise<{ changed: boolean; allowFrom: string[] }> { |
| const env = params.env ?? process.env; |
| const filePath = resolveAllowFromPath(params.channel, env); |
| return await withFileLock( |
| filePath, |
| { version: 1, allowFrom: [] } satisfies AllowFromStore, |
| async () => { |
| const { value } = await readJsonFile<AllowFromStore>(filePath, { |
| version: 1, |
| allowFrom: [], |
| }); |
| const current = (Array.isArray(value.allowFrom) ? value.allowFrom : []) |
| .map((v) => normalizeAllowEntry(params.channel, String(v))) |
| .filter(Boolean); |
| const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry)); |
| if (!normalized) { |
| return { changed: false, allowFrom: current }; |
| } |
| if (current.includes(normalized)) { |
| return { changed: false, allowFrom: current }; |
| } |
| const next = [...current, normalized]; |
| await writeJsonFile(filePath, { |
| version: 1, |
| allowFrom: next, |
| } satisfies AllowFromStore); |
| return { changed: true, allowFrom: next }; |
| }, |
| ); |
| } |
|
|
| export async function removeChannelAllowFromStoreEntry(params: { |
| channel: PairingChannel; |
| entry: string | number; |
| env?: NodeJS.ProcessEnv; |
| }): Promise<{ changed: boolean; allowFrom: string[] }> { |
| const env = params.env ?? process.env; |
| const filePath = resolveAllowFromPath(params.channel, env); |
| return await withFileLock( |
| filePath, |
| { version: 1, allowFrom: [] } satisfies AllowFromStore, |
| async () => { |
| const { value } = await readJsonFile<AllowFromStore>(filePath, { |
| version: 1, |
| allowFrom: [], |
| }); |
| const current = (Array.isArray(value.allowFrom) ? value.allowFrom : []) |
| .map((v) => normalizeAllowEntry(params.channel, String(v))) |
| .filter(Boolean); |
| const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry)); |
| if (!normalized) { |
| return { changed: false, allowFrom: current }; |
| } |
| const next = current.filter((entry) => entry !== normalized); |
| if (next.length === current.length) { |
| return { changed: false, allowFrom: current }; |
| } |
| await writeJsonFile(filePath, { |
| version: 1, |
| allowFrom: next, |
| } satisfies AllowFromStore); |
| return { changed: true, allowFrom: next }; |
| }, |
| ); |
| } |
|
|
| export async function listChannelPairingRequests( |
| channel: PairingChannel, |
| env: NodeJS.ProcessEnv = process.env, |
| ): Promise<PairingRequest[]> { |
| const filePath = resolvePairingPath(channel, env); |
| return await withFileLock( |
| filePath, |
| { version: 1, requests: [] } satisfies PairingStore, |
| async () => { |
| const { value } = await readJsonFile<PairingStore>(filePath, { |
| version: 1, |
| requests: [], |
| }); |
| const reqs = Array.isArray(value.requests) ? value.requests : []; |
| const nowMs = Date.now(); |
| const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests( |
| reqs, |
| nowMs, |
| ); |
| const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests( |
| prunedExpired, |
| PAIRING_PENDING_MAX, |
| ); |
| if (expiredRemoved || cappedRemoved) { |
| await writeJsonFile(filePath, { |
| version: 1, |
| requests: pruned, |
| } satisfies PairingStore); |
| } |
| return pruned |
| .filter( |
| (r) => |
| r && |
| typeof r.id === "string" && |
| typeof r.code === "string" && |
| typeof r.createdAt === "string", |
| ) |
| .slice() |
| .toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| }, |
| ); |
| } |
|
|
| export async function upsertChannelPairingRequest(params: { |
| channel: PairingChannel; |
| id: string | number; |
| meta?: Record<string, string | undefined | null>; |
| env?: NodeJS.ProcessEnv; |
| /** Extension channels can pass their adapter directly to bypass registry lookup. */ |
| pairingAdapter?: ChannelPairingAdapter; |
| }): Promise<{ code: string; created: boolean }> { |
| const env = params.env ?? process.env; |
| const filePath = resolvePairingPath(params.channel, env); |
| return await withFileLock( |
| filePath, |
| { version: 1, requests: [] } satisfies PairingStore, |
| async () => { |
| const { value } = await readJsonFile<PairingStore>(filePath, { |
| version: 1, |
| requests: [], |
| }); |
| const now = new Date().toISOString(); |
| const nowMs = Date.now(); |
| const id = normalizeId(params.id); |
| const meta = |
| params.meta && typeof params.meta === "object" |
| ? Object.fromEntries( |
| Object.entries(params.meta) |
| .map(([k, v]) => [k, String(v ?? "").trim()] as const) |
| .filter(([_, v]) => Boolean(v)), |
| ) |
| : undefined; |
|
|
| let reqs = Array.isArray(value.requests) ? value.requests : []; |
| const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests( |
| reqs, |
| nowMs, |
| ); |
| reqs = prunedExpired; |
| const existingIdx = reqs.findIndex((r) => r.id === id); |
| const existingCodes = new Set( |
| reqs.map((req) => |
| String(req.code ?? "") |
| .trim() |
| .toUpperCase(), |
| ), |
| ); |
|
|
| if (existingIdx >= 0) { |
| const existing = reqs[existingIdx]; |
| const existingCode = |
| existing && typeof existing.code === "string" ? existing.code.trim() : ""; |
| const code = existingCode || generateUniqueCode(existingCodes); |
| const next: PairingRequest = { |
| id, |
| code, |
| createdAt: existing?.createdAt ?? now, |
| lastSeenAt: now, |
| meta: meta ?? existing?.meta, |
| }; |
| reqs[existingIdx] = next; |
| const { requests: capped } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX); |
| await writeJsonFile(filePath, { |
| version: 1, |
| requests: capped, |
| } satisfies PairingStore); |
| return { code, created: false }; |
| } |
|
|
| const { requests: capped, removed: cappedRemoved } = pruneExcessRequests( |
| reqs, |
| PAIRING_PENDING_MAX, |
| ); |
| reqs = capped; |
| if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) { |
| if (expiredRemoved || cappedRemoved) { |
| await writeJsonFile(filePath, { |
| version: 1, |
| requests: reqs, |
| } satisfies PairingStore); |
| } |
| return { code: "", created: false }; |
| } |
| const code = generateUniqueCode(existingCodes); |
| const next: PairingRequest = { |
| id, |
| code, |
| createdAt: now, |
| lastSeenAt: now, |
| ...(meta ? { meta } : {}), |
| }; |
| await writeJsonFile(filePath, { |
| version: 1, |
| requests: [...reqs, next], |
| } satisfies PairingStore); |
| return { code, created: true }; |
| }, |
| ); |
| } |
|
|
| export async function approveChannelPairingCode(params: { |
| channel: PairingChannel; |
| code: string; |
| env?: NodeJS.ProcessEnv; |
| }): Promise<{ id: string; entry?: PairingRequest } | null> { |
| const env = params.env ?? process.env; |
| const code = params.code.trim().toUpperCase(); |
| if (!code) { |
| return null; |
| } |
|
|
| const filePath = resolvePairingPath(params.channel, env); |
| return await withFileLock( |
| filePath, |
| { version: 1, requests: [] } satisfies PairingStore, |
| async () => { |
| const { value } = await readJsonFile<PairingStore>(filePath, { |
| version: 1, |
| requests: [], |
| }); |
| const reqs = Array.isArray(value.requests) ? value.requests : []; |
| const nowMs = Date.now(); |
| const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs); |
| const idx = pruned.findIndex((r) => String(r.code ?? "").toUpperCase() === code); |
| if (idx < 0) { |
| if (removed) { |
| await writeJsonFile(filePath, { |
| version: 1, |
| requests: pruned, |
| } satisfies PairingStore); |
| } |
| return null; |
| } |
| const entry = pruned[idx]; |
| if (!entry) { |
| return null; |
| } |
| pruned.splice(idx, 1); |
| await writeJsonFile(filePath, { |
| version: 1, |
| requests: pruned, |
| } satisfies PairingStore); |
| await addChannelAllowFromStoreEntry({ |
| channel: params.channel, |
| entry: entry.id, |
| env, |
| }); |
| return { id: entry.id, entry }; |
| }, |
| ); |
| } |
|
|