mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor(push-web): split legacy migration parsers
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user