refactor(push-web): split legacy migration parsers

This commit is contained in:
Peter Steinberger
2026-07-17 05:02:43 -04:00
parent ce4a16b0d3
commit 31c6ab2a0c
2 changed files with 114 additions and 109 deletions
@@ -0,0 +1,109 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
createWebPushVapidKeyPair,
hashWebPushEndpoint,
isValidWebPushEndpoint,
isValidWebPushKey,
DEFAULT_WEB_PUSH_VAPID_SUBJECT,
type VapidKeyPair,
type WebPushSubscription,
} from "./push-web-store.js";
const SUBSCRIPTION_STORE_KEYS = new Set(["subscriptionsByEndpointHash"]);
const SUBSCRIPTION_KEYS = new Set([
"subscriptionId",
"endpoint",
"keys",
"createdAtMs",
"updatedAtMs",
]);
const PUSH_KEYS = new Set(["p256dh", "auth"]);
const VAPID_KEYS = new Set(["publicKey", "privateKey", "subject"]);
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function assertOnlyKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
label: string,
) {
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
if (unexpected) {
throw new Error(`${label} has unexpected field ${unexpected}`);
}
}
export function parseLegacySubscriptions(raw: string): Map<string, WebPushSubscription> {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed) || !isRecord(parsed.subscriptionsByEndpointHash)) {
throw new Error("legacy Web Push subscriptions must be an object");
}
assertOnlyKeys(parsed, SUBSCRIPTION_STORE_KEYS, "legacy Web Push subscriptions store");
const subscriptions = new Map<string, WebPushSubscription>();
const subscriptionIds = new Set<string>();
for (const [endpointHash, rawSubscription] of Object.entries(
parsed.subscriptionsByEndpointHash,
)) {
if (!isRecord(rawSubscription) || !isRecord(rawSubscription.keys)) {
throw new Error("legacy Web Push subscription is not an object");
}
assertOnlyKeys(rawSubscription, SUBSCRIPTION_KEYS, "legacy Web Push subscription");
assertOnlyKeys(rawSubscription.keys, PUSH_KEYS, "legacy Web Push subscription keys");
const { subscriptionId, endpoint, createdAtMs, updatedAtMs } = rawSubscription;
const p256dh = rawSubscription.keys.p256dh;
const auth = rawSubscription.keys.auth;
if (
typeof subscriptionId !== "string" ||
!UUID_RE.test(subscriptionId) ||
typeof endpoint !== "string" ||
!isValidWebPushEndpoint(endpoint) ||
hashWebPushEndpoint(endpoint) !== endpointHash ||
!isValidWebPushKey(p256dh) ||
!isValidWebPushKey(auth) ||
typeof createdAtMs !== "number" ||
!Number.isSafeInteger(createdAtMs) ||
createdAtMs < 0 ||
typeof updatedAtMs !== "number" ||
!Number.isSafeInteger(updatedAtMs) ||
updatedAtMs < createdAtMs
) {
throw new Error("legacy Web Push subscription is invalid");
}
if (subscriptionIds.has(subscriptionId)) {
throw new Error("legacy Web Push subscriptions contain a duplicate subscription id");
}
subscriptionIds.add(subscriptionId);
subscriptions.set(endpointHash, {
subscriptionId,
endpoint,
keys: { p256dh, auth },
createdAtMs,
updatedAtMs,
});
}
return subscriptions;
}
export function parseLegacyVapidKeys(raw: string, env: NodeJS.ProcessEnv): VapidKeyPair {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
throw new Error("legacy Web Push VAPID keys must be an object");
}
assertOnlyKeys(parsed, VAPID_KEYS, "legacy Web Push VAPID keys");
if (parsed.subject !== undefined && typeof parsed.subject !== "string") {
throw new Error("legacy Web Push VAPID keys are invalid");
}
const subject =
normalizeOptionalString(parsed.subject) ??
normalizeOptionalString(env.OPENCLAW_VAPID_SUBJECT) ??
DEFAULT_WEB_PUSH_VAPID_SUBJECT;
if (
!isValidWebPushKey(parsed.publicKey) ||
!isValidWebPushKey(parsed.privateKey) ||
subject.length > 512
) {
throw new Error("legacy Web Push VAPID keys are invalid");
}
return createWebPushVapidKeyPair(parsed.publicKey, parsed.privateKey, subject);
}
+5 -109
View File
@@ -4,8 +4,6 @@ import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { root, type Root } from "@openclaw/fs-safe";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { formatErrorMessage } from "./errors.js";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
@@ -16,37 +14,26 @@ import {
} from "./kysely-sync.js";
import {
createWebPushVapidKeyPair,
hashWebPushEndpoint,
isValidWebPushEndpoint,
isValidWebPushKey,
webPushSubscriptionFromRow,
webPushSubscriptionToRow,
webPushSubscriptionsEqual,
webPushVapidKeyPairToRow,
DEFAULT_WEB_PUSH_VAPID_SUBJECT,
WEB_PUSH_VAPID_KEY_ID,
type VapidKeyPair,
type WebPushDatabase,
type WebPushSubscription,
} from "./push-web-store.js";
import type { LegacyStateDetection, MigrationMessages } from "./state-migrations.types.js";
import {
parseLegacySubscriptions,
parseLegacyVapidKeys,
} from "./state-migrations.web-push-parse.js";
const LEGACY_SUBSCRIPTIONS_MAX_BYTES = 4 * 1024 * 1024;
const LEGACY_VAPID_KEYS_MAX_BYTES = 64 * 1024;
const MIGRATION_LOCK_TIMEOUT_MS = 250;
const MIGRATION_LOCK_POLL_INTERVAL_MS = 25;
const DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
const SUBSCRIPTION_STORE_KEYS = new Set(["subscriptionsByEndpointHash"]);
const SUBSCRIPTION_KEYS = new Set([
"subscriptionId",
"endpoint",
"keys",
"createdAtMs",
"updatedAtMs",
]);
const PUSH_KEYS = new Set(["p256dh", "auth"]);
const VAPID_KEYS = new Set(["publicKey", "privateKey", "subject"]);
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type LegacySourceSnapshot = {
sourcePath: string;
@@ -185,92 +172,6 @@ async function recoverInterruptedClaim(
await stateRoot.remove(claimRelativePath);
}
function assertOnlyKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
label: string,
) {
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
if (unexpected) {
throw new Error(`${label} has unexpected field ${unexpected}`);
}
}
function parseLegacySubscriptions(raw: string): Map<string, WebPushSubscription> {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed) || !isRecord(parsed.subscriptionsByEndpointHash)) {
throw new Error("legacy Web Push subscriptions must be an object");
}
assertOnlyKeys(parsed, SUBSCRIPTION_STORE_KEYS, "legacy Web Push subscriptions store");
const subscriptions = new Map<string, WebPushSubscription>();
const subscriptionIds = new Set<string>();
for (const [endpointHash, rawSubscription] of Object.entries(
parsed.subscriptionsByEndpointHash,
)) {
if (!isRecord(rawSubscription) || !isRecord(rawSubscription.keys)) {
throw new Error("legacy Web Push subscription is not an object");
}
assertOnlyKeys(rawSubscription, SUBSCRIPTION_KEYS, "legacy Web Push subscription");
assertOnlyKeys(rawSubscription.keys, PUSH_KEYS, "legacy Web Push subscription keys");
const { subscriptionId, endpoint, createdAtMs, updatedAtMs } = rawSubscription;
const p256dh = rawSubscription.keys.p256dh;
const auth = rawSubscription.keys.auth;
if (
typeof subscriptionId !== "string" ||
!UUID_RE.test(subscriptionId) ||
typeof endpoint !== "string" ||
!isValidWebPushEndpoint(endpoint) ||
hashWebPushEndpoint(endpoint) !== endpointHash ||
!isValidWebPushKey(p256dh) ||
!isValidWebPushKey(auth) ||
typeof createdAtMs !== "number" ||
!Number.isSafeInteger(createdAtMs) ||
createdAtMs < 0 ||
typeof updatedAtMs !== "number" ||
!Number.isSafeInteger(updatedAtMs) ||
updatedAtMs < createdAtMs
) {
throw new Error("legacy Web Push subscription is invalid");
}
if (subscriptionIds.has(subscriptionId)) {
throw new Error("legacy Web Push subscriptions contain a duplicate subscription id");
}
subscriptionIds.add(subscriptionId);
subscriptions.set(endpointHash, {
subscriptionId,
endpoint,
keys: { p256dh, auth },
createdAtMs,
updatedAtMs,
});
}
return subscriptions;
}
function parseLegacyVapidKeys(raw: string, env: NodeJS.ProcessEnv): VapidKeyPair {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
throw new Error("legacy Web Push VAPID keys must be an object");
}
assertOnlyKeys(parsed, VAPID_KEYS, "legacy Web Push VAPID keys");
if (parsed.subject !== undefined && typeof parsed.subject !== "string") {
throw new Error("legacy Web Push VAPID keys are invalid");
}
const subject =
normalizeOptionalString(parsed.subject) ??
normalizeOptionalString(env.OPENCLAW_VAPID_SUBJECT) ??
DEFAULT_WEB_PUSH_VAPID_SUBJECT;
if (
!isValidWebPushKey(parsed.publicKey) ||
!isValidWebPushKey(parsed.privateKey) ||
subject.length > 512
) {
throw new Error("legacy Web Push VAPID keys are invalid");
}
return createWebPushVapidKeyPair(parsed.publicKey, parsed.privateKey, subject);
}
async function readLegacyState(
stateRoot: Root,
stateDir: string,
@@ -600,12 +501,7 @@ async function migrateLegacyWebPushWithExclusiveStateOwnership(params: {
let legacy: ParsedLegacyState;
try {
legacy = await readLegacyState(
params.stateRoot,
params.stateDir,
params.detected,
params.env,
);
legacy = await readLegacyState(params.stateRoot, params.stateDir, params.detected, params.env);
} catch (error) {
warnings.push(`Failed reading legacy Web Push state: ${String(error)}`);
return { changes, warnings };