import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { getPairingAdapter } from "../channels/plugins/pairing.js"; import type { ChannelPairingAdapter } from "../channels/plugins/pairing.types.js"; import { withFileLock as withPathLock } from "../infra/file-lock.js"; import { readJsonFileWithFallback, writeJsonFileAtomically } from "../plugin-sdk/json-store.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, normalizeNullableString, normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; import { clearAllowFromFileReadCacheForNamespace, dedupePreserveOrder, readAllowFromFileSyncWithExists, readAllowFromFileWithExists, resolveAllowFromAccountId, resolveAllowFromFilePath, resolvePairingCredentialsDir, safeChannelKey, setAllowFromFileReadCache, shouldIncludeLegacyAllowFromEntries, type AllowFromStore, } from "./allow-from-store-file.js"; import type { PairingChannel } from "./pairing-store.types.js"; export type { PairingChannel } from "./pairing-store.types.js"; const PAIRING_CODE_LENGTH = 8; const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const PAIRING_CODE_MAX_ATTEMPTS = 500; 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; const PAIRING_ALLOW_FROM_CACHE_NAMESPACE = "pairing-store"; export type PairingRequest = { id: string; code: string; createdAt: string; lastSeenAt: string; meta?: Record; }; type PairingStore = { version: 1; requests: PairingRequest[]; }; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string { return path.join(resolvePairingCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`); } export function resolveChannelAllowFromPath( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, accountId?: string, ): string { return resolveAllowFromFilePath(channel, env, accountId); } async function readJsonFile( filePath: string, fallback: T, ): Promise<{ value: T; exists: boolean }> { return await readJsonFileWithFallback(filePath, fallback); } async function writeJsonFile(filePath: string, value: unknown): Promise { await writeJsonFileAtomically(filePath, value); } async function readPairingRequests(filePath: string): Promise { const { value } = await readJsonFile(filePath, { version: 1, requests: [], }); if (!Array.isArray(value.requests)) { return []; } return value.requests.flatMap((request) => { const normalized = normalizePersistedPairingRequest(request); return normalized ? [normalized] : []; }); } async function readPrunedPairingRequests(filePath: string): Promise<{ requests: PairingRequest[]; removed: boolean; }> { return pruneExpiredRequests(await readPairingRequests(filePath), Date.now()); } async function ensureJsonFile(filePath: string, fallback: unknown) { try { await fs.promises.access(filePath); } catch { await writeJsonFile(filePath, fallback); } } async function withFileLock( filePath: string, fallback: unknown, fn: () => Promise, ): Promise { await ensureJsonFile(filePath, fallback); return await withPathLock(filePath, PAIRING_STORE_LOCK_OPTIONS, async () => { return await fn(); }); } 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 normalizePersistedPairingMeta(value: unknown): Record | undefined { if (!isRecord(value)) { return undefined; } const out: Record = {}; for (const [key, entry] of Object.entries(value)) { const normalized = normalizeOptionalString(entry); if (normalized) { out[key] = normalized; } } return Object.keys(out).length > 0 ? out : undefined; } function normalizePersistedPairingRequest(value: unknown): PairingRequest | undefined { if (!isRecord(value)) { return undefined; } const id = normalizeOptionalString(value.id); const code = normalizeOptionalString(value.code); const createdAt = normalizeOptionalString(value.createdAt); const lastSeenAt = normalizeOptionalString(value.lastSeenAt) ?? createdAt; if ( !id || !code || !createdAt || !lastSeenAt || parseTimestamp(createdAt) === null || parseTimestamp(lastSeenAt) === null ) { return undefined; } const meta = normalizePersistedPairingMeta(value.meta); return { id, code, createdAt, lastSeenAt, ...(meta ? { meta } : {}), }; } 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 resolvePairingRequestAccountId(entry: PairingRequest): string { return normalizePairingAccountId(entry.meta?.accountId) || DEFAULT_ACCOUNT_ID; } function pruneExcessRequestsByAccount(reqs: PairingRequest[], maxPending: number) { if (maxPending <= 0 || reqs.length <= maxPending) { return { requests: reqs, removed: false }; } const grouped = new Map(); for (const [index, entry] of reqs.entries()) { const accountId = resolvePairingRequestAccountId(entry); const current = grouped.get(accountId); if (current) { current.push(index); continue; } grouped.set(accountId, [index]); } const droppedIndexes = new Set(); for (const indexes of grouped.values()) { if (indexes.length <= maxPending) { continue; } const sortedIndexes = indexes .slice() .toSorted((left, right) => resolveLastSeenAt(reqs[left]) - resolveLastSeenAt(reqs[right])); for (const index of sortedIndexes.slice(0, sortedIndexes.length - maxPending)) { droppedIndexes.add(index); } } if (droppedIndexes.size === 0) { return { requests: reqs, removed: false }; } return { requests: reqs.filter((_, index) => !droppedIndexes.has(index)), removed: true, }; } function randomCode(): string { // Human-friendly: 8 chars, upper, no ambiguous chars (0O1I). 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 { for (let attempt = 0; attempt < PAIRING_CODE_MAX_ATTEMPTS; attempt += 1) { const code = randomCode(); if (!existing.has(code)) { return code; } } throw new Error( `failed to generate unique pairing code after ${PAIRING_CODE_MAX_ATTEMPTS} attempts; existing code count: ${existing.size}`, ); } function normalizePairingAccountId(accountId?: string): string { return normalizeLowercaseStringOrEmpty(accountId); } function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: string): boolean { if (!normalizedAccountId) { return true; } return resolvePairingRequestAccountId(entry) === normalizedAccountId; } function normalizeId(value: string | number): string { return normalizeStringifiedOptionalString(value) ?? ""; } 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 normalizeOptionalString(normalized) ?? ""; } function normalizeAllowFromList(channel: PairingChannel, store: AllowFromStore): string[] { const list = Array.isArray(store.allowFrom) ? store.allowFrom : []; return dedupePreserveOrder(list.map((v) => normalizeAllowEntry(channel, v)).filter(Boolean)); } function normalizeAllowFromInput(channel: PairingChannel, entry: string | number): string { return normalizeAllowEntry(channel, normalizeId(entry)); } async function readAllowFromStateForPath( channel: PairingChannel, filePath: string, ): Promise { return (await readAllowFromStateForPathWithExists(channel, filePath)).entries; } async function readAllowFromStateForPathWithExists( channel: PairingChannel, filePath: string, ): Promise<{ entries: string[]; exists: boolean }> { return await readAllowFromFileWithExists({ cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, filePath, normalizeStore: (store) => normalizeAllowFromList(channel, store), }); } function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] { return readAllowFromStateForPathSyncWithExists(channel, filePath).entries; } function readAllowFromStateForPathSyncWithExists( channel: PairingChannel, filePath: string, ): { entries: string[]; exists: boolean } { return readAllowFromFileSyncWithExists({ cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, filePath, normalizeStore: (store) => normalizeAllowFromList(channel, store), }); } async function readAllowFromState(params: { channel: PairingChannel; entry: string | number; filePath: string; }): Promise<{ current: string[]; normalized: string | null }> { const { value } = await readJsonFile(params.filePath, { version: 1, allowFrom: [], }); const current = normalizeAllowFromList(params.channel, value); const normalized = normalizeAllowFromInput(params.channel, params.entry); return { current, normalized: normalized || null }; } async function writeAllowFromState(filePath: string, allowFrom: string[]): Promise { await writeJsonFile(filePath, { version: 1, allowFrom, } satisfies AllowFromStore); let stat: Awaited> | null = null; try { stat = await fs.promises.stat(filePath); } catch (err) { const code = (err as { code?: string }).code; if (code !== "ENOENT") { throw err; } } setAllowFromFileReadCache({ cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, filePath, entry: { exists: true, mtimeMs: stat?.mtimeMs ?? null, size: stat?.size ?? null, entries: allowFrom.slice(), }, }); } async function readNonDefaultAccountAllowFrom(params: { channel: PairingChannel; env: NodeJS.ProcessEnv; accountId: string; }): Promise { const scopedPath = resolveAllowFromFilePath(params.channel, params.env, params.accountId); return await readAllowFromStateForPath(params.channel, scopedPath); } function readNonDefaultAccountAllowFromSync(params: { channel: PairingChannel; env: NodeJS.ProcessEnv; accountId: string; }): string[] { const scopedPath = resolveAllowFromFilePath(params.channel, params.env, params.accountId); return readAllowFromStateForPathSync(params.channel, scopedPath); } async function updateAllowFromStoreEntry(params: { channel: PairingChannel; entry: string | number; accountId?: string; env?: NodeJS.ProcessEnv; apply: (current: string[], normalized: string) => string[] | null; }): Promise<{ changed: boolean; allowFrom: string[] }> { const env = params.env ?? process.env; const filePath = resolveAllowFromFilePath(params.channel, env, params.accountId); return await withFileLock( filePath, { version: 1, allowFrom: [] } satisfies AllowFromStore, async () => { const { current, normalized } = await readAllowFromState({ channel: params.channel, entry: params.entry, filePath, }); if (!normalized) { return { changed: false, allowFrom: current }; } const next = params.apply(current, normalized); if (!next) { return { changed: false, allowFrom: current }; } await writeAllowFromState(filePath, next); return { changed: true, allowFrom: next }; }, ); } export async function readLegacyChannelAllowFromStore( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): Promise { const filePath = resolveAllowFromFilePath(channel, env); return await readAllowFromStateForPath(channel, filePath); } export async function readChannelAllowFromStore( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, accountId?: string, ): Promise { const resolvedAccountId = resolveAllowFromAccountId(accountId); if (!shouldIncludeLegacyAllowFromEntries(resolvedAccountId)) { return await readNonDefaultAccountAllowFrom({ channel, env, accountId: resolvedAccountId, }); } const scopedPath = resolveAllowFromFilePath(channel, env, resolvedAccountId); const scopedEntries = await readAllowFromStateForPath(channel, scopedPath); // Backward compatibility: legacy channel-level allowFrom store was unscoped. // Keep honoring it for default account to prevent re-pair prompts after upgrades. const legacyPath = resolveAllowFromFilePath(channel, env); const legacyEntries = await readAllowFromStateForPath(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } export function readLegacyChannelAllowFromStoreSync( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): string[] { const filePath = resolveAllowFromFilePath(channel, env); return readAllowFromStateForPathSync(channel, filePath); } export function readChannelAllowFromStoreSync( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, accountId?: string, ): string[] { const resolvedAccountId = resolveAllowFromAccountId(accountId); if (!shouldIncludeLegacyAllowFromEntries(resolvedAccountId)) { return readNonDefaultAccountAllowFromSync({ channel, env, accountId: resolvedAccountId, }); } const scopedPath = resolveAllowFromFilePath(channel, env, resolvedAccountId); const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath); const legacyPath = resolveAllowFromFilePath(channel, env); const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } export function clearPairingAllowFromReadCacheForTest(): void { clearAllowFromFileReadCacheForNamespace(PAIRING_ALLOW_FROM_CACHE_NAMESPACE); } type AllowFromStoreEntryUpdateParams = { channel: PairingChannel; entry: string | number; accountId?: string; env?: NodeJS.ProcessEnv; }; type ChannelAllowFromStoreEntryMutation = ( current: string[], normalized: string, ) => string[] | null; async function updateChannelAllowFromStore( params: { apply: ChannelAllowFromStoreEntryMutation; } & AllowFromStoreEntryUpdateParams, ): Promise<{ changed: boolean; allowFrom: string[] }> { return await updateAllowFromStoreEntry({ channel: params.channel, entry: params.entry, accountId: params.accountId, env: params.env, apply: params.apply, }); } async function mutateChannelAllowFromStoreEntry( params: AllowFromStoreEntryUpdateParams, apply: ChannelAllowFromStoreEntryMutation, ): Promise<{ changed: boolean; allowFrom: string[] }> { return await updateChannelAllowFromStore({ ...params, apply, }); } export async function addChannelAllowFromStoreEntry( params: AllowFromStoreEntryUpdateParams, ): Promise<{ changed: boolean; allowFrom: string[] }> { return await mutateChannelAllowFromStoreEntry(params, (current, normalized) => { if (current.includes(normalized)) { return null; } return [...current, normalized]; }); } export async function removeChannelAllowFromStoreEntry( params: AllowFromStoreEntryUpdateParams, ): Promise<{ changed: boolean; allowFrom: string[] }> { return await mutateChannelAllowFromStoreEntry(params, (current, normalized) => { const next = current.filter((entry) => entry !== normalized); if (next.length === current.length) { return null; } return next; }); } export async function listChannelPairingRequests( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, accountId?: string, ): Promise { const filePath = resolvePairingPath(channel, env); return await withFileLock( filePath, { version: 1, requests: [] } satisfies PairingStore, async () => { const { requests: prunedExpired, removed: expiredRemoved } = await readPrunedPairingRequests(filePath); const { requests: pruned, removed: cappedRemoved } = pruneExcessRequestsByAccount( prunedExpired, PAIRING_PENDING_MAX, ); if (expiredRemoved || cappedRemoved) { await writeJsonFile(filePath, { version: 1, requests: pruned, } satisfies PairingStore); } const normalizedAccountId = normalizePairingAccountId(accountId); const filtered = normalizedAccountId ? pruned.filter((entry) => requestMatchesAccountId(entry, normalizedAccountId)) : pruned; return filtered .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; accountId: string; meta?: Record; 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 now = new Date().toISOString(); const nowMs = Date.now(); const id = normalizeId(params.id); const normalizedAccountId = normalizePairingAccountId(params.accountId) || DEFAULT_ACCOUNT_ID; const baseMeta = params.meta && typeof params.meta === "object" ? Object.fromEntries( Object.entries(params.meta) .map(([k, v]) => [k, normalizeOptionalString(v) ?? ""] as const) .filter(([_, v]) => Boolean(v)), ) : undefined; const meta = { ...baseMeta, accountId: normalizedAccountId }; let reqs = await readPairingRequests(filePath); const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests( reqs, nowMs, ); reqs = prunedExpired; const normalizedMatchingAccountId = normalizedAccountId; const existingIdx = reqs.findIndex((r) => { if (r.id !== id) { return false; } return requestMatchesAccountId(r, normalizedMatchingAccountId); }); const existingCodes = new Set( reqs.map((req) => (normalizeOptionalString(req.code) ?? "").toUpperCase()), ); if (existingIdx >= 0) { const existing = reqs[existingIdx]; const existingCode = normalizeOptionalString(existing?.code) ?? ""; 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 } = pruneExcessRequestsByAccount(reqs, PAIRING_PENDING_MAX); await writeJsonFile(filePath, { version: 1, requests: capped, } satisfies PairingStore); return { code, created: false }; } const { requests: capped, removed: cappedRemoved } = pruneExcessRequestsByAccount( reqs, PAIRING_PENDING_MAX, ); reqs = capped; const accountRequestCount = reqs.filter((r) => requestMatchesAccountId(r, normalizedMatchingAccountId), ).length; if (PAIRING_PENDING_MAX > 0 && accountRequestCount >= 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; accountId?: string; env?: NodeJS.ProcessEnv; }): Promise<{ id: string; entry?: PairingRequest } | null> { const env = params.env ?? process.env; const code = (normalizeNullableString(params.code) ?? "").toUpperCase(); if (!code) { return null; } const filePath = resolvePairingPath(params.channel, env); return await withFileLock( filePath, { version: 1, requests: [] } satisfies PairingStore, async () => { const { requests: pruned, removed } = await readPrunedPairingRequests(filePath); const normalizedAccountId = normalizePairingAccountId(params.accountId); const idx = pruned.findIndex((r) => { if (r.code.toUpperCase() !== code) { return false; } return requestMatchesAccountId(r, normalizedAccountId); }); 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); const entryAccountId = normalizeOptionalString(entry.meta?.accountId); await addChannelAllowFromStoreEntry({ channel: params.channel, entry: entry.id, accountId: normalizeOptionalString(params.accountId) ?? entryAccountId, env, }); return { id: entry.id, entry }; }, ); }