Files
openclaw/src/infra/node-pairing-migration.ts
Peter Steinberger f5806d08e2 refactor(gateway): retire the standalone node pairing store (#103120)
* refactor(gateway): fold node pairing store into device records

* docs+cli: retire standalone node pairing store references

* test(infra): respec node pairing as device-backed surface approvals

* test(infra): assemble migration token fixtures dynamically

* fix(pairing): preserve node surface across device re-approval, strip legacy tokens in CLI JSON

* test(gateway): drop retired node token from pairing fixtures

* chore(protocol): regenerate Swift models, drop dead pairing-pending module
2026-07-09 22:58:03 +01:00

107 lines
3.9 KiB
TypeScript

// One-time migration of the retired standalone node pairing store.
// Older gateways kept approved node surfaces (and a per-node token) in
// <state>/nodes/{paired,pending}.json; the surface now lives on the paired
// device record. Runs at gateway startup: folds rows into device records,
// drops orphans that no longer map to a node-role device (they cannot pass
// the WS handshake anyway), and archives the legacy files so the migration
// never repeats. Pending rows are 5-minute transients and are not migrated;
// connecting nodes re-request their surface.
import fs from "node:fs/promises";
import { withPairedDeviceRecords, listApprovedPairedDeviceRoles } from "./device-pairing.js";
import {
coercePairingStateRecord,
readJsonIfExists,
resolvePairingPaths,
} from "./pairing-files.js";
type LegacyNodePairingRow = {
nodeId?: string;
displayName?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
bins?: string[];
createdAtMs?: number;
approvedAtMs?: number;
lastConnectedAtMs?: number;
};
export type LegacyNodePairingMigrationResult = {
migrated: number;
orphaned: number;
};
async function archiveLegacyFile(path: string): Promise<void> {
try {
await fs.rename(path, `${path}.migrated`);
} catch {
// Missing file or a racing second gateway process; nothing left to archive.
}
}
/**
* Fold legacy nodes/paired.json rows into device-record node surfaces, then
* archive the legacy files. Idempotent: after the first run the files carry a
* `.migrated` suffix and the function returns null immediately.
*/
export async function migrateLegacyNodePairingStore(params?: {
baseDir?: string;
log?: { info: (message: string) => void; warn: (message: string) => void };
}): Promise<LegacyNodePairingMigrationResult | null> {
const { pendingPath, pairedPath } = resolvePairingPaths(params?.baseDir, "nodes");
const [pairedRaw, pendingRaw] = await Promise.all([
readJsonIfExists<unknown>(pairedPath),
readJsonIfExists<unknown>(pendingPath),
]);
if (pairedRaw == null && pendingRaw == null) {
return null;
}
const legacyRows = coercePairingStateRecord<LegacyNodePairingRow>(pairedRaw);
let migrated = 0;
let orphaned = 0;
if (Object.keys(legacyRows).length > 0) {
await withPairedDeviceRecords(params?.baseDir, (pairedByDeviceId) => {
const now = Date.now();
for (const [rawNodeId, row] of Object.entries(legacyRows)) {
const device = pairedByDeviceId[rawNodeId.trim()];
if (!device || !listApprovedPairedDeviceRoles(device).includes("node")) {
orphaned += 1;
continue;
}
if (device.nodeSurface) {
continue;
}
device.nodeSurface = {
displayName: row.displayName,
version: row.version,
coreVersion: row.coreVersion,
uiVersion: row.uiVersion,
modelIdentifier: row.modelIdentifier,
caps: Array.isArray(row.caps) ? row.caps : undefined,
commands: Array.isArray(row.commands) ? row.commands : undefined,
permissions: row.permissions,
bins: Array.isArray(row.bins) ? row.bins : undefined,
createdAtMs: typeof row.createdAtMs === "number" ? row.createdAtMs : now,
approvedAtMs: typeof row.approvedAtMs === "number" ? row.approvedAtMs : now,
lastConnectedAtMs:
typeof row.lastConnectedAtMs === "number" ? row.lastConnectedAtMs : undefined,
};
migrated += 1;
}
return { value: undefined, persist: migrated > 0 };
});
}
await Promise.all([archiveLegacyFile(pairedPath), archiveLegacyFile(pendingPath)]);
const result = { migrated, orphaned };
params?.log?.info(
`node pairing store migrated: folded ${migrated} node surface(s) into device records, dropped ${orphaned} orphan row(s)`,
);
return result;
}