Download src/pairing/pairing-store.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 16.6 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/pairing/pairing-store.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/pairing/pairing-store.ts
-
curl -L -o pairing-store.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/pairing/pairing-store.ts
16.6 kB
| // Persists pairing challenges and approved channel account bindings in shared SQLite state. | |
| import crypto from "node:crypto"; | |
| import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; | |
| import { | |
| normalizeLowercaseStringOrEmpty, | |
| normalizeNullableString, | |
| normalizeOptionalString, | |
| normalizeStringifiedOptionalString, | |
| } from "@openclaw/normalization-core/string-coerce"; | |
| import { getPairingAdapter } from "../channels/plugins/pairing.js"; | |
| import type { ChannelPairingAdapter } from "../channels/plugins/pairing.types.js"; | |
| import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; | |
| import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; | |
| import { resolveAllowFromAccountId } from "./pairing-store-keys.js"; | |
| import { | |
| readChannelPairingState, | |
| readChannelPairingStateFromDatabase, | |
| resolvePairingRequestAccountId, | |
| sqliteOptionsForEnv, | |
| writeChannelPairingStateToDatabase, | |
| } from "./pairing-store-sqlite.js"; | |
| import type { PairingChannel, PairingRequestRecord } from "./pairing-store.types.js"; | |
| const PAIRING_CODE_LENGTH = 8; | |
| const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; | |
| const PAIRING_CODE_MAX_ATTEMPTS = 500; | |
| export const CHANNEL_PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; | |
| export const CHANNEL_PAIRING_PENDING_MAX = 3; | |
| export type PairingRequest = PairingRequestRecord; | |
| /** Stable opaque id for approving a request without exposing its human pairing code. */ | |
| export function resolveChannelPairingRequestId( | |
| channel: PairingChannel, | |
| request: PairingRequest, | |
| ): string { | |
| const accountId = resolvePairingRequestAccountId(request); | |
| return crypto | |
| .createHash("sha256") | |
| .update(`${channel}\0${accountId}\0${request.id}\0${request.createdAt}`) | |
| .digest("base64url") | |
| .slice(0, 32); | |
| } | |
| function isExpired(entry: PairingRequest, nowMs: number): boolean { | |
| const createdAt = parseDateStringTimestampMs(entry.createdAt); | |
| return createdAt === undefined || nowMs - createdAt > CHANNEL_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 ( | |
| parseDateStringTimestampMs(entry.lastSeenAt) ?? parseDateStringTimestampMs(entry.createdAt) ?? 0 | |
| ); | |
| } | |
| function normalizePairingAccountId(accountId?: string): string { | |
| return normalizeLowercaseStringOrEmpty(accountId); | |
| } | |
| function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: string): boolean { | |
| return !normalizedAccountId || resolvePairingRequestAccountId(entry) === normalizedAccountId; | |
| } | |
| function pruneExcessRequestsByAccount(reqs: PairingRequest[], maxPending: number) { | |
| if (maxPending <= 0 || reqs.length <= maxPending) { | |
| return { requests: reqs, removed: false }; | |
| } | |
| const grouped = new Map<string, Array<{ index: number; request: PairingRequest }>>(); | |
| for (const [index, entry] of reqs.entries()) { | |
| const accountId = resolvePairingRequestAccountId(entry); | |
| const current = grouped.get(accountId); | |
| if (current) { | |
| current.push({ index, request: entry }); | |
| } else { | |
| grouped.set(accountId, [{ index, request: entry }]); | |
| } | |
| } | |
| const droppedIndexes = new Set<number>(); | |
| for (const entries of grouped.values()) { | |
| if (entries.length <= maxPending) { | |
| continue; | |
| } | |
| const sorted = entries.toSorted( | |
| (left, right) => resolveLastSeenAt(left.request) - resolveLastSeenAt(right.request), | |
| ); | |
| for (const { index } of sorted.slice(0, sorted.length - maxPending)) { | |
| droppedIndexes.add(index); | |
| } | |
| } | |
| return droppedIndexes.size === 0 | |
| ? { requests: reqs, removed: false } | |
| : { 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++) { | |
| out += PAIRING_CODE_ALPHABET[crypto.randomInt(0, PAIRING_CODE_ALPHABET.length)]; | |
| } | |
| return out; | |
| } | |
| function generateUniqueCode(existing: Set<string>): 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 normalizeId(value: string | number): string { | |
| return normalizeStringifiedOptionalString(value) ?? ""; | |
| } | |
| function resolvePairingAdapter( | |
| channel: PairingChannel, | |
| pairingAdapter?: ChannelPairingAdapter, | |
| ): ChannelPairingAdapter | undefined { | |
| return pairingAdapter ?? getPairingAdapter(channel) ?? undefined; | |
| } | |
| function normalizeAllowEntry( | |
| channel: PairingChannel, | |
| entry: string, | |
| pairingAdapter?: ChannelPairingAdapter, | |
| ): string { | |
| const trimmed = entry.trim(); | |
| if (!trimmed || trimmed === "*") { | |
| return ""; | |
| } | |
| const adapter = resolvePairingAdapter(channel, pairingAdapter); | |
| const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed; | |
| const normalizedEntry = normalizeOptionalString(normalized) ?? ""; | |
| return normalizedEntry === "*" ? "" : normalizedEntry; | |
| } | |
| function normalizeAllowFromInput( | |
| channel: PairingChannel, | |
| entry: string | number, | |
| pairingAdapter?: ChannelPairingAdapter, | |
| ): string { | |
| return normalizeAllowEntry(channel, normalizeId(entry), pairingAdapter); | |
| } | |
| function readAllowFromState(channel: PairingChannel, env: NodeJS.ProcessEnv, accountId?: string) { | |
| const resolvedAccountId = resolveAllowFromAccountId(accountId); | |
| return (readChannelPairingState(channel, env).allowFrom?.[resolvedAccountId] ?? []).slice(); | |
| } | |
| async function updateAllowFromStoreEntry(params: { | |
| channel: PairingChannel; | |
| entry: string | number; | |
| accountId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| pairingAdapter?: ChannelPairingAdapter; | |
| apply: (current: string[], normalized: string) => string[] | null; | |
| }): Promise<{ changed: boolean; allowFrom: string[] }> { | |
| const env = params.env ?? process.env; | |
| const accountId = resolveAllowFromAccountId(params.accountId); | |
| const normalized = normalizeAllowFromInput(params.channel, params.entry, params.pairingAdapter); | |
| return runOpenClawStateWriteTransaction((database) => { | |
| const state = readChannelPairingStateFromDatabase(database, params.channel); | |
| const current = (state.allowFrom?.[accountId] ?? []).slice(); | |
| if (!normalized) { | |
| return { changed: false, allowFrom: current }; | |
| } | |
| const next = params.apply(current, normalized); | |
| if (!next) { | |
| return { changed: false, allowFrom: current }; | |
| } | |
| state.allowFrom ??= {}; | |
| state.allowFrom[accountId] = next; | |
| writeChannelPairingStateToDatabase(database, params.channel, state); | |
| return { changed: true, allowFrom: next }; | |
| }, sqliteOptionsForEnv(env)); | |
| } | |
| export async function readChannelAllowFromStore( | |
| channel: PairingChannel, | |
| env: NodeJS.ProcessEnv = process.env, | |
| accountId?: string, | |
| ): Promise<string[]> { | |
| return readAllowFromState(channel, env, accountId); | |
| } | |
| export function readChannelAllowFromStoreSync( | |
| channel: PairingChannel, | |
| env: NodeJS.ProcessEnv = process.env, | |
| accountId?: string, | |
| ): string[] { | |
| return readAllowFromState(channel, env, accountId); | |
| } | |
| type AllowFromStoreEntryUpdateParams = { | |
| channel: PairingChannel; | |
| entry: string | number; | |
| accountId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| pairingAdapter?: ChannelPairingAdapter; | |
| }; | |
| export async function addChannelAllowFromStoreEntry( | |
| params: AllowFromStoreEntryUpdateParams, | |
| ): Promise<{ changed: boolean; allowFrom: string[] }> { | |
| return updateAllowFromStoreEntry({ | |
| ...params, | |
| apply: (current, normalized) => | |
| current.includes(normalized) ? null : [...current, normalized], | |
| }); | |
| } | |
| export async function removeChannelAllowFromStoreEntry( | |
| params: AllowFromStoreEntryUpdateParams, | |
| ): Promise<{ changed: boolean; allowFrom: string[] }> { | |
| return updateAllowFromStoreEntry({ | |
| ...params, | |
| apply: (current, normalized) => { | |
| const next = current.filter((entry) => entry !== normalized); | |
| return next.length === current.length ? null : next; | |
| }, | |
| }); | |
| } | |
| export async function listChannelPairingRequests( | |
| channel: PairingChannel, | |
| env: NodeJS.ProcessEnv = process.env, | |
| accountId?: string, | |
| ): Promise<PairingRequest[]> { | |
| return runOpenClawStateWriteTransaction((database) => { | |
| const state = readChannelPairingStateFromDatabase(database, channel); | |
| const expired = pruneExpiredRequests(state.requests, Date.now()); | |
| const capped = pruneExcessRequestsByAccount(expired.requests, CHANNEL_PAIRING_PENDING_MAX); | |
| if (expired.removed || capped.removed) { | |
| state.requests = capped.requests; | |
| writeChannelPairingStateToDatabase(database, channel, state); | |
| } | |
| const normalizedAccountId = normalizePairingAccountId(accountId); | |
| return capped.requests | |
| .filter((entry) => requestMatchesAccountId(entry, normalizedAccountId)) | |
| .toSorted((left, right) => { | |
| const createdOrder = left.createdAt.localeCompare(right.createdAt); | |
| if (createdOrder !== 0) { | |
| return createdOrder; | |
| } | |
| const accountOrder = resolvePairingRequestAccountId(left).localeCompare( | |
| resolvePairingRequestAccountId(right), | |
| ); | |
| return accountOrder || left.id.localeCompare(right.id); | |
| }); | |
| }, sqliteOptionsForEnv(env)); | |
| } | |
| export async function upsertChannelPairingRequest(params: { | |
| channel: PairingChannel; | |
| id: string | number; | |
| accountId: string; | |
| 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; | |
| return runOpenClawStateWriteTransaction((database) => { | |
| const now = new Date().toISOString(); | |
| const id = normalizeId(params.id); | |
| const accountId = normalizePairingAccountId(params.accountId) || DEFAULT_ACCOUNT_ID; | |
| const baseMeta = params.meta | |
| ? Object.fromEntries( | |
| Object.entries(params.meta) | |
| .map(([key, value]) => [key, normalizeOptionalString(value) ?? ""] as const) | |
| .filter(([, value]) => Boolean(value)), | |
| ) | |
| : undefined; | |
| const meta = { ...baseMeta, accountId }; | |
| const state = readChannelPairingStateFromDatabase(database, params.channel); | |
| const expired = pruneExpiredRequests(state.requests, Date.now()); | |
| let requests = expired.requests; | |
| const existingIndex = requests.findIndex( | |
| (request) => request.id === id && requestMatchesAccountId(request, accountId), | |
| ); | |
| const existingCodes = new Set( | |
| requests.map((request) => (normalizeOptionalString(request.code) ?? "").toUpperCase()), | |
| ); | |
| if (existingIndex >= 0) { | |
| const existing = requests[existingIndex]; | |
| const code = normalizeOptionalString(existing?.code) || generateUniqueCode(existingCodes); | |
| requests[existingIndex] = { | |
| id, | |
| code, | |
| createdAt: existing?.createdAt ?? now, | |
| lastSeenAt: now, | |
| meta, | |
| }; | |
| state.requests = pruneExcessRequestsByAccount(requests, CHANNEL_PAIRING_PENDING_MAX).requests; | |
| writeChannelPairingStateToDatabase(database, params.channel, state); | |
| return { code, created: false }; | |
| } | |
| const capped = pruneExcessRequestsByAccount(requests, CHANNEL_PAIRING_PENDING_MAX); | |
| requests = capped.requests; | |
| const accountRequestCount = requests.filter((request) => | |
| requestMatchesAccountId(request, accountId), | |
| ).length; | |
| if (CHANNEL_PAIRING_PENDING_MAX > 0 && accountRequestCount >= CHANNEL_PAIRING_PENDING_MAX) { | |
| if (expired.removed || capped.removed) { | |
| state.requests = requests; | |
| writeChannelPairingStateToDatabase(database, params.channel, state); | |
| } | |
| return { code: "", created: false }; | |
| } | |
| const code = generateUniqueCode(existingCodes); | |
| state.requests = [...requests, { id, code, createdAt: now, lastSeenAt: now, meta }]; | |
| writeChannelPairingStateToDatabase(database, params.channel, state); | |
| return { code, created: true }; | |
| }, sqliteOptionsForEnv(env)); | |
| } | |
| type ResolvePairingRequestParams = { | |
| channel: PairingChannel; | |
| accountId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| pairingAdapter?: ChannelPairingAdapter; | |
| matches: (request: PairingRequest) => boolean; | |
| approve: boolean; | |
| }; | |
| async function resolveChannelPairingRequest( | |
| params: ResolvePairingRequestParams, | |
| ): Promise<{ id: string; entry: PairingRequest } | null> { | |
| const env = params.env ?? process.env; | |
| return runOpenClawStateWriteTransaction((database) => { | |
| const state = readChannelPairingStateFromDatabase(database, params.channel); | |
| const pruned = pruneExpiredRequests(state.requests, Date.now()); | |
| const accountId = normalizePairingAccountId(params.accountId); | |
| const index = pruned.requests.findIndex( | |
| (request) => requestMatchesAccountId(request, accountId) && params.matches(request), | |
| ); | |
| if (index < 0) { | |
| if (pruned.removed) { | |
| state.requests = pruned.requests; | |
| writeChannelPairingStateToDatabase(database, params.channel, state); | |
| } | |
| return null; | |
| } | |
| const entry = pruned.requests[index]; | |
| if (!entry) { | |
| return null; | |
| } | |
| pruned.requests.splice(index, 1); | |
| state.requests = pruned.requests; | |
| if (params.approve) { | |
| const allowAccountId = resolveAllowFromAccountId( | |
| normalizeOptionalString(params.accountId) ?? normalizeOptionalString(entry.meta?.accountId), | |
| ); | |
| const currentAllow = state.allowFrom?.[allowAccountId] ?? []; | |
| const adapter = resolvePairingAdapter(params.channel, params.pairingAdapter); | |
| // Channels with key-bound handoffs can persist an opaque approval token | |
| // derived from request metadata instead of a durable sender allowlist id. | |
| const approvalEntry = adapter?.resolveApprovalStoreEntry | |
| ? adapter.resolveApprovalStoreEntry({ | |
| id: entry.id, | |
| ...(entry.meta ? { meta: entry.meta } : {}), | |
| }) | |
| : entry.id; | |
| const normalizedAllow = | |
| approvalEntry == null | |
| ? "" | |
| : normalizeAllowFromInput(params.channel, approvalEntry, adapter); | |
| if (normalizedAllow && !currentAllow.includes(normalizedAllow)) { | |
| state.allowFrom ??= {}; | |
| state.allowFrom[allowAccountId] = [...currentAllow, normalizedAllow]; | |
| } | |
| } | |
| writeChannelPairingStateToDatabase(database, params.channel, state); | |
| return { id: entry.id, entry }; | |
| }, sqliteOptionsForEnv(env)); | |
| } | |
| export async function approveChannelPairingCode(params: { | |
| channel: PairingChannel; | |
| code: string; | |
| accountId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| pairingAdapter?: ChannelPairingAdapter; | |
| }): Promise<{ id: string; entry: PairingRequest } | null> { | |
| const code = (normalizeNullableString(params.code) ?? "").toUpperCase(); | |
| if (!code) { | |
| return null; | |
| } | |
| return resolveChannelPairingRequest({ | |
| ...params, | |
| matches: (request) => request.code.toUpperCase() === code, | |
| approve: true, | |
| }); | |
| } | |
| /** Approves a pending request by opaque id without exposing its pairing code. */ | |
| export async function approveChannelPairingRequest(params: { | |
| channel: PairingChannel; | |
| requestId: string; | |
| accountId: string; | |
| env?: NodeJS.ProcessEnv; | |
| pairingAdapter?: ChannelPairingAdapter; | |
| }): Promise<{ id: string; entry: PairingRequest } | null> { | |
| const requestId = normalizeOptionalString(params.requestId); | |
| if (!requestId) { | |
| return null; | |
| } | |
| return resolveChannelPairingRequest({ | |
| ...params, | |
| matches: (request) => resolveChannelPairingRequestId(params.channel, request) === requestId, | |
| approve: true, | |
| }); | |
| } | |
| /** Dismisses a pending request without blocking the sender from requesting again. */ | |
| export async function dismissChannelPairingRequest(params: { | |
| channel: PairingChannel; | |
| requestId: string; | |
| accountId: string; | |
| env?: NodeJS.ProcessEnv; | |
| }): Promise<{ id: string; entry: PairingRequest } | null> { | |
| const requestId = normalizeOptionalString(params.requestId); | |
| if (!requestId) { | |
| return null; | |
| } | |
| return resolveChannelPairingRequest({ | |
| ...params, | |
| matches: (request) => resolveChannelPairingRequestId(params.channel, request) === requestId, | |
| approve: false, | |
| }); | |
| } | |