refactor(push-web): move web push store to shared SQLite state DB (#103294)

* refactor(push-web): move state to shared SQLite

Co-authored-by: hailory <hailory@xydigit.com>

* fix(push-web): harden legacy migration retries

* fix(push-web): format migration lock errors

* fix(push-web): gate runtime during legacy migration

* fix(push-web): keep store internals private

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: hailory <hailory@xydigit.com>
This commit is contained in:
xydt-tanshanshan
2026-07-16 03:54:22 +08:00
committed by GitHub
parent 15a304d6e1
commit d201acd6e8
13 changed files with 2027 additions and 196 deletions
+12 -2
View File
@@ -1103,6 +1103,16 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
the complete legacy `commitments.json`, keeps newer SQLite rows, verifies the
result, and only then removes the unchanged source. Runtime never reads or
writes the retired file.
- Web Push subscriptions and the generated VAPID identity now use typed shared
`web_push_subscriptions` and `web_push_vapid_keys` rows. Runtime registration,
expiry cleanup, and first-use key generation use row-level SQLite
transactions. Explicit Doctor repair validates both retired JSON stores,
claims them before the SQLite write, imports them atomically, rejects
conflicting VAPID identities, verifies the result, and only then removes the
claims. Doctor holds the state-directory maintenance lock for the complete
import so an older Gateway cannot recreate the retired files. Registration,
delivery, deletion, and key resolution fail closed until Doctor resolves
pending legacy sources or interrupted claims.
- Cron job definitions, schedule state, and run history no longer have runtime
JSON writers or readers. Runtime uses `cron_jobs` rows with typed schedule,
payload, delivery, failure-alert, session, status, and runtime-state columns plus
@@ -2181,8 +2191,8 @@ Add a repo check that fails new runtime writes to legacy state paths:
- `nodes/pending.json` / `nodes/paired.json` (retired 2026.7: folded into paired device records at gateway startup)
- `identity/device.json`
- `identity/device-auth.json`
- `push/web-push-subscriptions.json`
- `push/vapid-keys.json`
- `push/web-push-subscriptions.json` (retired; Doctor-only import into `web_push_subscriptions`)
- `push/vapid-keys.json` (retired; Doctor-only import into `web_push_vapid_keys`)
- `push/apns-registrations.json`
- `process-leases.json`
- `gateway-instance-id`
+8 -6
View File
@@ -428,12 +428,14 @@ The Control UI ships a `manifest.webmanifest` and a service worker, so modern br
If the page shows **Protocol mismatch** right after an OpenClaw update, first reopen the dashboard with `openclaw dashboard` and hard-refresh. If it still fails, clear site data for the dashboard origin or test in a private browser window; an old tab or browser service-worker cache can keep running a pre-update Control UI bundle against the newer Gateway.
| Surface | What it does |
| ----------------------------------------------------- | ------------------------------------------------------------------ |
| `ui/public/manifest.webmanifest` | PWA manifest. Browsers offer "Install app" once it is reachable. |
| `ui/public/sw.js` | Service worker that handles `push` events and notification clicks. |
| `push/vapid-keys.json` (under the OpenClaw state dir) | Auto-generated VAPID keypair used to sign Web Push payloads. |
| `push/web-push-subscriptions.json` | Persisted browser subscription endpoints. |
| Surface | What it does |
| -------------------------------------------------- | ---------------------------------------------------------------------------- |
| `ui/public/manifest.webmanifest` | PWA manifest. Browsers offer "Install app" once it is reachable. |
| `ui/public/sw.js` | Service worker that handles `push` events and notification clicks. |
| `state/openclaw.sqlite` → `web_push_vapid_keys` | Auto-generated VAPID keypair used to sign Web Push payloads. |
| `state/openclaw.sqlite` → `web_push_subscriptions` | Persisted browser subscription endpoints, keys, and registration timestamps. |
Upgrades from the retired `push/vapid-keys.json` and `push/web-push-subscriptions.json` stores are imported by `openclaw doctor --fix`. Stop the Gateway before running that repair so an older process cannot recreate retired state during import. Run the repair before using Web Push after an upgrade; registration, delivery, deletion, and key resolution refuse to proceed while either retired source or an interrupted Doctor claim remains. The Gateway runtime reads and writes SQLite only.
Override the VAPID keypair through env vars on the Gateway process when you want to pin keys (multi-host deployments, secrets rotation, or tests):
@@ -93,6 +93,7 @@ const legacyStorePatterns = [
/\btui\/last-session\.json\b/u,
/\bcommitments\/commitments\.json\b/u,
/\bmedia\/outgoing\/records\/[^"'`]*\.json\b/u,
/\bpush\/(?:web-push-subscriptions|vapid-keys)\.json\b/u,
/\btmp\/skill-uploads\b/u,
/\b(?:crestodian|openclaw)\/rescue-pending\/[^"'`]*\.json\b/u,
/\bcron\/(?:runs\/[^"'`]+\.jsonl|jobs\.json|jobs-state\.json)\b/u,
@@ -112,6 +113,7 @@ const allowedRuntimeMigrationPaths = [
"src/infra/state-migrations.tui-last-session.ts",
"src/infra/state-migrations.commitments.ts",
"src/infra/state-migrations.managed-outgoing-images.ts",
"src/infra/state-migrations.web-push.ts",
"src/infra/state-migrations.rescue-pending.ts",
"src/commands/session-state-migration.ts",
"src/commands/doctor-state-migrations.test.ts",
+5
View File
@@ -288,6 +288,11 @@ function createLegacyStateMigrationDetectionResult(params?: {
sourceDir: "/tmp/state/media/outgoing/records",
hasLegacy: false,
},
webPush: {
subscriptionsPath: "/tmp/state/push/web-push-subscriptions.json",
vapidKeysPath: "/tmp/state/push/vapid-keys.json",
hasLegacy: false,
},
rescuePending: {
sourcePaths: ["/tmp/state/crestodian/rescue-pending", "/tmp/state/openclaw/rescue-pending"],
hasLegacy: false,
+270
View File
@@ -0,0 +1,270 @@
// Canonical shared-SQLite store for Web Push subscriptions and VAPID identity.
import type { Insertable, Selectable } from "kysely";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { sha256HexPrefix } from "./crypto-digest.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
export const WEB_PUSH_VAPID_KEY_ID = "default";
export const DEFAULT_WEB_PUSH_VAPID_SUBJECT = "https://openclaw.ai";
const WEB_PUSH_MAX_ENDPOINT_LENGTH = 2048;
const WEB_PUSH_MAX_KEY_LENGTH = 512;
export type WebPushSubscription = {
subscriptionId: string;
endpoint: string;
keys: { p256dh: string; auth: string };
createdAtMs: number;
updatedAtMs: number;
};
export type VapidKeyPair = {
publicKey: string;
privateKey: string;
subject: string;
};
export function createWebPushVapidKeyPair(
publicKey: string,
privateKey: string,
subject: string,
): VapidKeyPair {
return { publicKey, privateKey, subject };
}
export type WebPushDatabase = Pick<
OpenClawStateKyselyDatabase,
"web_push_subscriptions" | "web_push_vapid_keys"
>;
type WebPushSubscriptionRow = Selectable<WebPushDatabase["web_push_subscriptions"]>;
type WebPushSubscriptionInsert = Insertable<WebPushDatabase["web_push_subscriptions"]>;
type WebPushVapidKeyInsert = Insertable<WebPushDatabase["web_push_vapid_keys"]>;
function webPushStateDatabaseOptions(stateDir?: string): OpenClawStateDatabaseOptions {
return stateDir
? { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } }
: { env: process.env };
}
export function hashWebPushEndpoint(endpoint: string): string {
return sha256HexPrefix(endpoint, 32);
}
export function isValidWebPushEndpoint(endpoint: string): boolean {
if (!endpoint || endpoint.length > WEB_PUSH_MAX_ENDPOINT_LENGTH) {
return false;
}
try {
return new URL(endpoint).protocol === "https:";
} catch {
return false;
}
}
export function isValidWebPushKey(key: unknown): key is string {
return typeof key === "string" && key.length > 0 && key.length <= WEB_PUSH_MAX_KEY_LENGTH;
}
export function webPushSubscriptionFromRow(row: WebPushSubscriptionRow): WebPushSubscription {
return {
subscriptionId: row.subscription_id,
endpoint: row.endpoint,
keys: { p256dh: row.p256dh, auth: row.auth },
createdAtMs: row.created_at_ms,
updatedAtMs: row.updated_at_ms,
};
}
export function webPushSubscriptionToRow(params: {
endpointHash: string;
subscription: WebPushSubscription;
}): WebPushSubscriptionInsert {
return {
endpoint_hash: params.endpointHash,
subscription_id: params.subscription.subscriptionId,
endpoint: params.subscription.endpoint,
p256dh: params.subscription.keys.p256dh,
auth: params.subscription.keys.auth,
created_at_ms: params.subscription.createdAtMs,
updated_at_ms: params.subscription.updatedAtMs,
};
}
export function webPushVapidKeyPairToRow(params: {
keyPair: VapidKeyPair;
nowMs: number;
}): WebPushVapidKeyInsert {
return {
key_id: WEB_PUSH_VAPID_KEY_ID,
public_key: params.keyPair.publicKey,
private_key: params.keyPair.privateKey,
subject: params.keyPair.subject,
updated_at_ms: params.nowMs,
};
}
export function webPushSubscriptionsEqual(
left: WebPushSubscription,
right: WebPushSubscription,
): boolean {
return (
left.subscriptionId === right.subscriptionId &&
left.endpoint === right.endpoint &&
left.keys.p256dh === right.keys.p256dh &&
left.keys.auth === right.keys.auth &&
left.createdAtMs === right.createdAtMs &&
left.updatedAtMs === right.updatedAtMs
);
}
export function listWebPushSubscriptions(stateDir?: string): WebPushSubscription[] {
const database = openOpenClawStateDatabase(webPushStateDatabaseOptions(stateDir));
const stateDb = getNodeSqliteKysely<WebPushDatabase>(database.db);
return executeSqliteQuerySync(
database.db,
stateDb
.selectFrom("web_push_subscriptions")
.selectAll()
.orderBy("created_at_ms", "asc")
.orderBy("subscription_id", "asc"),
).rows.map(webPushSubscriptionFromRow);
}
/** Reread the endpoint row inside the write transaction before creating or updating it. */
export function upsertWebPushSubscription(params: {
endpointHash: string;
endpoint: string;
keys: { p256dh: string; auth: string };
candidateSubscriptionId: string;
nowMs: number;
stateDir?: string;
}): WebPushSubscription {
return runOpenClawStateWriteTransaction(({ db }) => {
const stateDb = getNodeSqliteKysely<WebPushDatabase>(db);
const existingRow = executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("web_push_subscriptions")
.selectAll()
.where("endpoint_hash", "=", params.endpointHash),
);
if (existingRow && existingRow.endpoint !== params.endpoint) {
throw new Error("web push endpoint hash collision");
}
const subscription: WebPushSubscription = {
subscriptionId: existingRow?.subscription_id ?? params.candidateSubscriptionId,
endpoint: params.endpoint,
keys: { ...params.keys },
createdAtMs: existingRow?.created_at_ms ?? params.nowMs,
updatedAtMs: params.nowMs,
};
const row = webPushSubscriptionToRow({
endpointHash: params.endpointHash,
subscription,
});
executeSqliteQuerySync(
db,
stateDb
.insertInto("web_push_subscriptions")
.values(row)
.onConflict((conflict) =>
conflict.column("endpoint_hash").doUpdateSet({
subscription_id: row.subscription_id,
endpoint: row.endpoint,
p256dh: row.p256dh,
auth: row.auth,
updated_at_ms: row.updated_at_ms,
}),
),
);
return subscription;
}, webPushStateDatabaseOptions(params.stateDir));
}
export function deleteWebPushSubscriptionByEndpoint(params: {
endpointHash: string;
endpoint: string;
stateDir?: string;
}): boolean {
return runOpenClawStateWriteTransaction(({ db }) => {
const result = executeSqliteQuerySync(
db,
getNodeSqliteKysely<WebPushDatabase>(db)
.deleteFrom("web_push_subscriptions")
.where("endpoint_hash", "=", params.endpointHash)
.where("endpoint", "=", params.endpoint),
);
return Number(result.numAffectedRows ?? 0) > 0;
}, webPushStateDatabaseOptions(params.stateDir));
}
/** Delete an expired send target only if no newer registration replaced it in flight. */
export function deleteWebPushSubscriptionIfCurrent(params: {
endpointHash: string;
subscription: WebPushSubscription;
stateDir?: string;
}): boolean {
const subscription = params.subscription;
return runOpenClawStateWriteTransaction(({ db }) => {
const result = executeSqliteQuerySync(
db,
getNodeSqliteKysely<WebPushDatabase>(db)
.deleteFrom("web_push_subscriptions")
.where("endpoint_hash", "=", params.endpointHash)
.where("subscription_id", "=", subscription.subscriptionId)
.where("endpoint", "=", subscription.endpoint)
.where("p256dh", "=", subscription.keys.p256dh)
.where("auth", "=", subscription.keys.auth)
.where("updated_at_ms", "=", subscription.updatedAtMs),
);
return Number(result.numAffectedRows ?? 0) > 0;
}, webPushStateDatabaseOptions(params.stateDir));
}
export function readPersistedVapidKeyPair(stateDir?: string): VapidKeyPair | null {
const database = openOpenClawStateDatabase(webPushStateDatabaseOptions(stateDir));
const row = executeSqliteQueryTakeFirstSync(
database.db,
getNodeSqliteKysely<WebPushDatabase>(database.db)
.selectFrom("web_push_vapid_keys")
.selectAll()
.where("key_id", "=", WEB_PUSH_VAPID_KEY_ID),
);
return row ? createWebPushVapidKeyPair(row.public_key, row.private_key, row.subject) : null;
}
/** First committed keypair wins so concurrent gateway bootstraps share one signing identity. */
export function insertVapidKeyPairIfAbsent(params: {
candidate: VapidKeyPair;
nowMs: number;
stateDir?: string;
}): VapidKeyPair {
return runOpenClawStateWriteTransaction(({ db }) => {
const stateDb = getNodeSqliteKysely<WebPushDatabase>(db);
const existing = executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("web_push_vapid_keys")
.selectAll()
.where("key_id", "=", WEB_PUSH_VAPID_KEY_ID),
);
if (existing) {
return createWebPushVapidKeyPair(existing.public_key, existing.private_key, existing.subject);
}
executeSqliteQuerySync(
db,
stateDb
.insertInto("web_push_vapid_keys")
.values(webPushVapidKeyPairToRow({ keyPair: params.candidate, nowMs: params.nowMs })),
);
return params.candidate;
}, webPushStateDatabaseOptions(params.stateDir));
}
+255 -64
View File
@@ -1,9 +1,16 @@
// Tests web push subscription storage and delivery helpers.
// Tests SQLite-backed Web Push subscription storage and delivery helpers.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import webPush from "web-push";
import { closeOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
createWebPushVapidKeyPair,
listWebPushSubscriptions,
readPersistedVapidKeyPair,
} from "./push-web-store.js";
import {
broadcastWebPush,
clearWebPushSubscriptionByEndpoint,
@@ -11,19 +18,21 @@ import {
resolveVapidKeys,
} from "./push-web.js";
// Stub resolveStateDir so tests use a temp directory.
let tmpDir: string;
const generatedVapidKeys = vi.hoisted(
() =>
Object.fromEntries([
["publicKey", "test-public-key-base64url"],
["privateKey", "test-private-key-base64url"],
]) as { publicKey: string; privateKey: string },
);
vi.mock("../config/paths.js", () => ({
resolveStateDir: () => tmpDir,
}));
// Stub web-push so we don't make real HTTP requests.
vi.mock("web-push", () => ({
default: {
generateVAPIDKeys: vi.fn(() => ({
publicKey: "test-public-key-base64url",
privateKey: "test-private-key-base64url",
})),
generateVAPIDKeys: vi.fn(() => generatedVapidKeys),
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue({ statusCode: 201 }),
},
@@ -32,47 +41,99 @@ vi.mock("web-push", () => ({
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "push-web-test-"));
vi.clearAllMocks();
vi.mocked(webPush.sendNotification).mockResolvedValue({ statusCode: 201 } as never);
});
afterEach(async () => {
closeOpenClawStateDatabase();
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("resolveVapidKeys", () => {
it("generates and persists VAPID keys on first call", async () => {
it("generates one durable SQLite VAPID identity", async () => {
const keys = await resolveVapidKeys(tmpDir);
expect(keys.publicKey).toBe("test-public-key-base64url");
expect(keys.privateKey).toBe("test-private-key-base64url");
expect(keys.subject).toBe("https://openclaw.ai");
const persistedKeys = JSON.parse(
await fs.readFile(path.join(tmpDir, "push", "vapid-keys.json"), "utf8"),
) as { subject?: string };
expect(persistedKeys.subject).toBe("https://openclaw.ai");
expect(keys).toEqual(
createWebPushVapidKeyPair(
"test-public-key-base64url",
"test-private-key-base64url",
"https://openclaw.ai",
),
);
expect(readPersistedVapidKeyPair(tmpDir)).toEqual(keys);
// Second call returns same keys.
const keys2 = await resolveVapidKeys(tmpDir);
expect(keys2.publicKey).toBe(keys.publicKey);
expect(keys2.privateKey).toBe(keys.privateKey);
closeOpenClawStateDatabase();
await expect(resolveVapidKeys(tmpDir)).resolves.toEqual(keys);
expect(vi.mocked(webPush.generateVAPIDKeys)).toHaveBeenCalledTimes(1);
await expect(fs.stat(path.join(tmpDir, "push", "vapid-keys.json"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("prefers env vars over persisted keys", async () => {
// Persist keys first.
await resolveVapidKeys(tmpDir);
it("requires Doctor before creating an identity beside retired state", async () => {
const pushDir = path.join(tmpDir, "push");
const legacyPath = path.join(pushDir, "vapid-keys.json");
await fs.mkdir(pushDir, { recursive: true });
await fs.writeFile(legacyPath, "{}", "utf8");
// Set env overrides.
process.env.OPENCLAW_VAPID_PUBLIC_KEY = "env-public";
process.env.OPENCLAW_VAPID_PRIVATE_KEY = "env-private";
process.env.OPENCLAW_VAPID_SUBJECT = "mailto:env@test.com";
await expect(resolveVapidKeys(tmpDir)).rejects.toThrow("openclaw doctor --fix");
expect(readPersistedVapidKeyPair(tmpDir)).toBeNull();
expect(vi.mocked(webPush.generateVAPIDKeys)).not.toHaveBeenCalled();
await fs.rename(legacyPath, `${legacyPath}.doctor-importing`);
await expect(resolveVapidKeys(tmpDir)).rejects.toThrow("openclaw doctor --fix");
expect(vi.mocked(webPush.generateVAPIDKeys)).not.toHaveBeenCalled();
await fs.rm(`${legacyPath}.doctor-importing`);
await fs.symlink(path.join(tmpDir, "missing-vapid-keys.json"), legacyPath);
await expect(resolveVapidKeys(tmpDir)).rejects.toThrow("openclaw doctor --fix");
expect(vi.mocked(webPush.generateVAPIDKeys)).not.toHaveBeenCalled();
});
it("converges concurrent first-use generation on the first committed identity", async () => {
vi.mocked(webPush.generateVAPIDKeys)
.mockReturnValueOnce(createWebPushVapidKeyPair("public-a", "private-a", "ignored"))
.mockReturnValueOnce(createWebPushVapidKeyPair("public-b", "private-b", "ignored"));
const [first, second] = await Promise.all([resolveVapidKeys(tmpDir), resolveVapidKeys(tmpDir)]);
expect(first).toEqual(second);
expect(readPersistedVapidKeyPair(tmpDir)).toEqual(first);
expect(vi.mocked(webPush.generateVAPIDKeys)).toHaveBeenCalledTimes(2);
});
it("prefers a complete environment override without persisting it", async () => {
const environmentKeys = createWebPushVapidKeyPair(
"env-public",
"env-private",
"mailto:env@test.com",
);
const envSnapshot = captureEnv([
"OPENCLAW_VAPID_PUBLIC_KEY",
"OPENCLAW_VAPID_PRIVATE_KEY",
"OPENCLAW_VAPID_SUBJECT",
]);
setTestEnvValue("OPENCLAW_VAPID_PUBLIC_KEY", environmentKeys.publicKey);
setTestEnvValue("OPENCLAW_VAPID_PRIVATE_KEY", environmentKeys.privateKey);
setTestEnvValue("OPENCLAW_VAPID_SUBJECT", environmentKeys.subject);
try {
const keys = await resolveVapidKeys(tmpDir);
expect(keys.publicKey).toBe("env-public");
expect(keys.privateKey).toBe("env-private");
expect(keys.subject).toBe("mailto:env@test.com");
expect(vi.mocked(webPush.generateVAPIDKeys)).toHaveBeenCalledTimes(1);
await expect(resolveVapidKeys(tmpDir)).resolves.toEqual(environmentKeys);
expect(readPersistedVapidKeyPair(tmpDir)).toBeNull();
expect(vi.mocked(webPush.generateVAPIDKeys)).not.toHaveBeenCalled();
} finally {
envSnapshot.restore();
}
});
it("applies the current subject to a persisted identity", async () => {
const initial = await resolveVapidKeys(tmpDir);
process.env.OPENCLAW_VAPID_SUBJECT = "mailto:changed@test.com";
try {
await expect(resolveVapidKeys(tmpDir)).resolves.toEqual({
...initial,
subject: "mailto:changed@test.com",
});
expect(readPersistedVapidKeyPair(tmpDir)?.subject).toBe("https://openclaw.ai");
} finally {
delete process.env.OPENCLAW_VAPID_PUBLIC_KEY;
delete process.env.OPENCLAW_VAPID_PRIVATE_KEY;
delete process.env.OPENCLAW_VAPID_SUBJECT;
}
});
@@ -82,43 +143,53 @@ describe("subscription CRUD", () => {
const endpoint = "https://push.example.com/send/abc123";
const keys = { p256dh: "p256dh-key", auth: "auth-key" };
it("registers a new subscription", async () => {
const sub = await registerWebPushSubscription({
endpoint,
keys,
baseDir: tmpDir,
});
expect(sub.subscriptionId).toMatch(/^[0-9a-f-]{36}$/);
expect(sub.endpoint).toBe(endpoint);
expect(sub.keys.p256dh).toBe("p256dh-key");
expect(sub.keys.auth).toBe("auth-key");
expect(sub.createdAtMs).toBeGreaterThan(0);
});
it("updates an existing subscription with the same endpoint", async () => {
const sub1 = await registerWebPushSubscription({
endpoint,
keys,
baseDir: tmpDir,
});
const sub2 = await registerWebPushSubscription({
it("registers, updates, and reopens a durable subscription", async () => {
const first = await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
const updated = await registerWebPushSubscription({
endpoint,
keys: { p256dh: "new-p256dh", auth: "new-auth" },
baseDir: tmpDir,
});
// Same subscription ID, same created time, updated keys.
expect(sub2.subscriptionId).toBe(sub1.subscriptionId);
expect(sub2.createdAtMs).toBe(sub1.createdAtMs);
expect(sub2.keys.p256dh).toBe("new-p256dh");
expect(updated).toMatchObject({
subscriptionId: first.subscriptionId,
createdAtMs: first.createdAtMs,
endpoint,
keys: { p256dh: "new-p256dh", auth: "new-auth" },
});
closeOpenClawStateDatabase();
expect(listWebPushSubscriptions(tmpDir)).toEqual([updated]);
await expect(fs.stat(path.join(tmpDir, "push"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("clears a subscription by endpoint", async () => {
it("preserves unrelated concurrent registrations", async () => {
await Promise.all(
["a", "b", "c"].map((suffix) =>
registerWebPushSubscription({
endpoint: `https://push.example.com/${suffix}`,
keys,
baseDir: tmpDir,
}),
),
);
expect(
listWebPushSubscriptions(tmpDir)
.map((entry) => entry.endpoint)
.toSorted(),
).toEqual([
"https://push.example.com/a",
"https://push.example.com/b",
"https://push.example.com/c",
]);
});
it("clears only the matching endpoint", async () => {
await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
await expect(clearWebPushSubscriptionByEndpoint(endpoint, tmpDir)).resolves.toBe(true);
await expect(clearWebPushSubscriptionByEndpoint(endpoint, tmpDir)).resolves.toBe(false);
});
it("rejects invalid endpoint", async () => {
it("rejects invalid registration data", async () => {
await expect(
registerWebPushSubscription({
endpoint: "http://insecure.example.com",
@@ -126,23 +197,66 @@ describe("subscription CRUD", () => {
baseDir: tmpDir,
}),
).rejects.toThrow("invalid push subscription endpoint");
});
it("rejects empty keys", async () => {
await expect(
registerWebPushSubscription({
endpoint,
keys: { p256dh: "", auth: "auth-key" },
keys: { p256dh: "", auth: "auth" },
baseDir: tmpDir,
}),
).rejects.toThrow("invalid push subscription keys");
});
it("blocks an empty broadcast while retired subscriptions await Doctor", async () => {
const pushDir = path.join(tmpDir, "push");
const legacyPath = path.join(pushDir, "web-push-subscriptions.json");
await fs.mkdir(pushDir, { recursive: true });
await fs.writeFile(
legacyPath,
JSON.stringify({
subscriptionsByEndpointHash: {
legacy: {
subscriptionId: "c0a80101-0000-4000-8000-000000000001",
endpoint: "https://push.example.com/legacy",
keys,
createdAtMs: 1,
updatedAtMs: 1,
},
},
}),
);
expect(listWebPushSubscriptions(tmpDir)).toEqual([]);
await expect(broadcastWebPush({ title: "Blocked" }, tmpDir)).rejects.toThrow(
"openclaw doctor --fix",
);
expect(vi.mocked(webPush.sendNotification)).not.toHaveBeenCalled();
});
it("blocks mutations while a Doctor claim is pending", async () => {
const existing = await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
const pushDir = path.join(tmpDir, "push");
const claimPath = path.join(pushDir, "web-push-subscriptions.json.doctor-importing");
await fs.mkdir(pushDir, { recursive: true });
await fs.writeFile(claimPath, "{}", "utf8");
await expect(clearWebPushSubscriptionByEndpoint(endpoint, tmpDir)).rejects.toThrow(
"openclaw doctor --fix",
);
await expect(
registerWebPushSubscription({
endpoint: "https://push.example.com/new",
keys,
baseDir: tmpDir,
}),
).rejects.toThrow("openclaw doctor --fix");
expect(listWebPushSubscriptions(tmpDir)).toEqual([existing]);
});
});
describe("sending", () => {
const keys = { p256dh: "p256dh-key", auth: "auth-key" };
it("configures VAPID details once before broadcasting to subscribers", async () => {
it("configures VAPID details once before broadcasting", async () => {
await registerWebPushSubscription({
endpoint: "https://push.example.com/a",
keys,
@@ -161,4 +275,81 @@ describe("sending", () => {
expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1);
expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(2);
});
it("does not delete a subscription re-registered during an expired send", async () => {
const endpoint = "https://push.example.com/reregistered";
await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
let rejectSend: ((error: unknown) => void) | undefined;
vi.mocked(webPush.sendNotification).mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectSend = reject;
}),
);
const broadcast = broadcastWebPush({ title: "Race" }, tmpDir);
await vi.waitFor(() => expect(rejectSend).toBeTypeOf("function"));
const replacement = await registerWebPushSubscription({
endpoint,
keys: { p256dh: "replacement-p256dh", auth: "replacement-auth" },
baseDir: tmpDir,
});
rejectSend?.(Object.assign(new Error("gone"), { statusCode: 410 }));
await broadcast;
expect(listWebPushSubscriptions(tmpDir)).toEqual([replacement]);
});
it("does not delete an expired subscription after a legacy claim appears", async () => {
const endpoint = "https://push.example.com/pending-claim";
const subscription = await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
let rejectSend: ((error: unknown) => void) | undefined;
vi.mocked(webPush.sendNotification).mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectSend = reject;
}),
);
const broadcast = broadcastWebPush({ title: "Race" }, tmpDir);
await vi.waitFor(() => expect(rejectSend).toBeTypeOf("function"));
const pushDir = path.join(tmpDir, "push");
await fs.mkdir(pushDir, { recursive: true });
await fs.writeFile(
path.join(pushDir, "web-push-subscriptions.json.doctor-importing"),
"{}",
"utf8",
);
rejectSend?.(Object.assign(new Error("gone"), { statusCode: 410 }));
await expect(broadcast).resolves.toEqual([
expect.objectContaining({ ok: false, statusCode: 410 }),
]);
expect(listWebPushSubscriptions(tmpDir)).toEqual([subscription]);
});
it("keeps completed delivery results when expired-subscription cleanup fails", async () => {
const endpoint = "https://push.example.com/expired";
await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
await resolveVapidKeys(tmpDir);
let rejectSend: ((error: unknown) => void) | undefined;
vi.mocked(webPush.sendNotification).mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectSend = reject;
}),
);
const broadcast = broadcastWebPush({ title: "Expired" }, tmpDir);
await vi.waitFor(() => expect(rejectSend).toBeTypeOf("function"));
closeOpenClawStateDatabase();
const databasePath = path.join(tmpDir, "state", "openclaw.sqlite");
await fs.rename(databasePath, `${databasePath}.backup`);
await fs.mkdir(databasePath);
rejectSend?.(Object.assign(new Error("gone"), { statusCode: 410 }));
await expect(broadcast).resolves.toEqual([
expect.objectContaining({ ok: false, statusCode: 410 }),
]);
});
});
+90 -124
View File
@@ -1,27 +1,28 @@
// Stores and verifies web push subscriptions and delivery payloads.
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type { PushSubscription, VapidKeys } from "web-push";
import { resolveStateDir } from "../config/paths.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { sha256HexPrefix } from "./crypto-digest.js";
import { createAsyncLock, tryReadJson, writeJson } from "./json-files.js";
import {
createWebPushVapidKeyPair,
deleteWebPushSubscriptionByEndpoint,
deleteWebPushSubscriptionIfCurrent,
hashWebPushEndpoint,
insertVapidKeyPairIfAbsent,
isValidWebPushEndpoint,
isValidWebPushKey,
listWebPushSubscriptions,
readPersistedVapidKeyPair,
upsertWebPushSubscription,
DEFAULT_WEB_PUSH_VAPID_SUBJECT,
type VapidKeyPair,
type WebPushSubscription,
} from "./push-web-store.js";
// --- Types ---
type WebPushSubscription = PushSubscription & {
subscriptionId: string;
createdAtMs: number;
updatedAtMs: number;
};
type WebPushRegistrationState = {
subscriptionsByEndpointHash: Record<string, WebPushSubscription>;
};
type VapidKeyPair = VapidKeys & { subject: string };
type WebPushSendResult = {
ok: boolean;
subscriptionId: string;
@@ -31,13 +32,7 @@ type WebPushSendResult = {
// --- Constants ---
const WEB_PUSH_STATE_FILENAME = "push/web-push-subscriptions.json";
const VAPID_KEYS_FILENAME = "push/vapid-keys.json";
const MAX_ENDPOINT_LENGTH = 2048;
const MAX_KEY_LENGTH = 512;
const DEFAULT_VAPID_SUBJECT = "https://openclaw.ai";
const withLock = createAsyncLock();
const LEGACY_WEB_PUSH_PATHS = ["push/web-push-subscriptions.json", "push/vapid-keys.json"] as const;
type WebPushRuntime = typeof import("web-push");
type WebPushRuntimeModule = WebPushRuntime & { default?: WebPushRuntime };
@@ -46,54 +41,40 @@ const loadWebPushRuntime = createLazyRuntimeModule(() =>
import("web-push").then((mod: WebPushRuntimeModule) => mod.default ?? mod),
);
// --- Helpers ---
function resolveWebPushStatePath(baseDir?: string): string {
const root = baseDir ?? resolveStateDir();
return path.join(root, WEB_PUSH_STATE_FILENAME);
}
function resolveVapidKeysPath(baseDir?: string): string {
const root = baseDir ?? resolveStateDir();
return path.join(root, VAPID_KEYS_FILENAME);
}
function hashEndpoint(endpoint: string): string {
return sha256HexPrefix(endpoint, 32);
}
function isValidEndpoint(endpoint: string): boolean {
if (!endpoint || endpoint.length > MAX_ENDPOINT_LENGTH) {
return false;
}
function legacyWebPushPathMayExist(filePath: string): boolean {
try {
const url = new URL(endpoint);
return url.protocol === "https:";
} catch {
return false;
fs.lstatSync(filePath);
return true;
} catch (error) {
// Only a definite absence permits creating a new signing identity.
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
function isValidKey(key: string): boolean {
return typeof key === "string" && key.length > 0 && key.length <= MAX_KEY_LENGTH;
}
// --- State persistence ---
async function loadState(baseDir?: string): Promise<WebPushRegistrationState> {
const filePath = resolveWebPushStatePath(baseDir);
const state = await tryReadJson<WebPushRegistrationState>(filePath);
return state ?? { subscriptionsByEndpointHash: {} };
}
async function persistState(state: WebPushRegistrationState, baseDir?: string): Promise<void> {
const filePath = resolveWebPushStatePath(baseDir);
await writeJson(filePath, state, { trailingNewline: true });
// Production callers run under the Gateway's lifetime state/config lock. Doctor must
// acquire those same locks before claiming legacy files, so this check remains stable
// through the following SQLite operation or asynchronous delivery fan-out.
function assertLegacyWebPushMigrationComplete(baseDir?: string): void {
const stateDir = baseDir ?? resolveStateDir();
const pendingLegacyPath = LEGACY_WEB_PUSH_PATHS.find((relativePath) => {
const sourcePath = path.join(stateDir, relativePath);
return (
legacyWebPushPathMayExist(sourcePath) ||
legacyWebPushPathMayExist(`${sourcePath}.doctor-importing`)
);
});
if (pendingLegacyPath) {
throw new Error(
`legacy Web Push state requires migration; run \`openclaw doctor --fix\` before using Web Push`,
);
}
}
// --- VAPID keys ---
export async function resolveVapidKeys(baseDir?: string): Promise<VapidKeyPair> {
assertLegacyWebPushMigrationComplete(baseDir);
// Env vars take precedence — allows operators to share a stable VAPID
// identity across multiple gateway instances.
const envPublic = resolveVapidPublicKeyFromEnv();
@@ -106,34 +87,29 @@ export async function resolveVapidKeys(baseDir?: string): Promise<VapidKeyPair>
};
}
// Fall back to persisted keys, generating on first use under a lock to
// prevent concurrent bootstraps from writing different keypairs.
return await withLock(async () => {
const filePath = resolveVapidKeysPath(baseDir);
const existing = await tryReadJson<VapidKeyPair>(filePath);
if (existing?.publicKey && existing?.privateKey) {
return {
publicKey: existing.publicKey,
privateKey: existing.privateKey,
// Env var always wins so operators can change subject without deleting vapid-keys.json.
subject: resolveVapidSubjectFromEnv(),
};
}
const existing = readPersistedVapidKeyPair(baseDir);
if (existing) {
return { ...existing, subject: resolveVapidSubjectFromEnv() };
}
const webPush = await loadWebPushRuntime();
const keys = webPush.generateVAPIDKeys();
const pair: VapidKeyPair = {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
subject: resolveVapidSubjectFromEnv(),
};
await writeJson(filePath, pair, { trailingNewline: true });
return pair;
// Generation can race across gateway processes. SQLite selects one durable
// identity, then every contender returns that committed keypair.
const webPush = await loadWebPushRuntime();
const keys = webPush.generateVAPIDKeys();
const pair = insertVapidKeyPairIfAbsent({
candidate: createWebPushVapidKeyPair(
keys.publicKey,
keys.privateKey,
resolveVapidSubjectFromEnv(),
),
nowMs: Date.now(),
stateDir: baseDir,
});
return { ...pair, subject: resolveVapidSubjectFromEnv() };
}
function resolveVapidSubjectFromEnv(): string {
return process.env.OPENCLAW_VAPID_SUBJECT || DEFAULT_VAPID_SUBJECT;
return process.env.OPENCLAW_VAPID_SUBJECT || DEFAULT_WEB_PUSH_VAPID_SUBJECT;
}
function resolveVapidPublicKeyFromEnv(): string | undefined {
@@ -157,51 +133,33 @@ export async function registerWebPushSubscription(
): Promise<WebPushSubscription> {
const { endpoint, keys, baseDir } = params;
if (!isValidEndpoint(endpoint)) {
if (!isValidWebPushEndpoint(endpoint)) {
throw new Error("invalid push subscription endpoint: must be an HTTPS URL under 2048 chars");
}
if (!isValidKey(keys.p256dh) || !isValidKey(keys.auth)) {
if (!isValidWebPushKey(keys.p256dh) || !isValidWebPushKey(keys.auth)) {
throw new Error("invalid push subscription keys: must be non-empty strings under 512 chars");
}
assertLegacyWebPushMigrationComplete(baseDir);
return await withLock(async () => {
const state = await loadState(baseDir);
const hash = hashEndpoint(endpoint);
const now = Date.now();
const existing = state.subscriptionsByEndpointHash[hash];
const subscription: WebPushSubscription = {
subscriptionId: existing?.subscriptionId ?? randomUUID(),
endpoint,
keys: { p256dh: keys.p256dh, auth: keys.auth },
createdAtMs: existing?.createdAtMs ?? now,
updatedAtMs: now,
};
state.subscriptionsByEndpointHash[hash] = subscription;
await persistState(state, baseDir);
return subscription;
return upsertWebPushSubscription({
endpointHash: hashWebPushEndpoint(endpoint),
endpoint,
keys: { p256dh: keys.p256dh, auth: keys.auth },
candidateSubscriptionId: randomUUID(),
nowMs: Date.now(),
stateDir: baseDir,
});
}
async function listWebPushSubscriptions(baseDir?: string): Promise<WebPushSubscription[]> {
const state = await loadState(baseDir);
return Object.values(state.subscriptionsByEndpointHash);
}
export async function clearWebPushSubscriptionByEndpoint(
endpoint: string,
baseDir?: string,
): Promise<boolean> {
return await withLock(async () => {
const state = await loadState(baseDir);
const hash = hashEndpoint(endpoint);
if (state.subscriptionsByEndpointHash[hash]) {
delete state.subscriptionsByEndpointHash[hash];
await persistState(state, baseDir);
return true;
}
return false;
assertLegacyWebPushMigrationComplete(baseDir);
return deleteWebPushSubscriptionByEndpoint({
endpointHash: hashWebPushEndpoint(endpoint),
endpoint,
stateDir: baseDir,
});
}
@@ -260,7 +218,8 @@ export async function broadcastWebPush(
payload: WebPushPayload,
baseDir?: string,
): Promise<WebPushSendResult[]> {
const subscriptions = await listWebPushSubscriptions(baseDir);
assertLegacyWebPushMigrationComplete(baseDir);
const subscriptions = listWebPushSubscriptions(baseDir);
if (subscriptions.length === 0) {
return [];
}
@@ -287,15 +246,22 @@ export async function broadcastWebPush(
);
// Clean up expired subscriptions (HTTP 410 Gone or 404 Not Found) per Web Push spec.
const expiredEndpoints = mapped
const expiredSubscriptions = mapped
.map((result, i) => ({ result, sub: subscriptions[i] }))
.filter(({ result }) => !result.ok && (result.statusCode === 410 || result.statusCode === 404))
.map(({ sub }) => expectDefined(sub, "push web sub").endpoint);
.map(({ sub }) => expectDefined(sub, "push web sub"));
if (expiredEndpoints.length > 0) {
await Promise.allSettled(
expiredEndpoints.map((endpoint) => clearWebPushSubscriptionByEndpoint(endpoint, baseDir)),
);
for (const subscription of expiredSubscriptions) {
try {
assertLegacyWebPushMigrationComplete(baseDir);
deleteWebPushSubscriptionIfCurrent({
endpointHash: hashWebPushEndpoint(subscription.endpoint),
subscription,
stateDir: baseDir,
});
} catch {
// Delivery already completed. Cleanup stays best-effort so callers do not retry valid sends.
}
}
return mapped;
+17
View File
@@ -119,6 +119,7 @@ import {
migrateLegacyUpdateCheckState,
resolveLegacyUpdateCheckPath,
} from "./state-migrations.update-check.js";
import { detectLegacyWebPush, migrateLegacyWebPush } from "./state-migrations.web-push.js";
let autoMigrateChecked = false;
@@ -397,6 +398,10 @@ export async function detectLegacyStateMigrations(params: {
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const webPush = detectLegacyWebPush({
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const rescuePending = detectLegacyRescuePending({
stateDir,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
@@ -555,6 +560,9 @@ export async function detectLegacyStateMigrations(params: {
if (managedOutgoingImages.hasLegacy) {
preview.push("- Managed outgoing images: legacy record JSON → shared SQLite state");
}
if (webPush.hasLegacy) {
preview.push("- Web Push subscriptions and VAPID identity: legacy JSON → shared SQLite state");
}
if (rescuePending.hasLegacy) {
preview.push("- System-agent rescue approvals: discard retired pending JSON capabilities");
}
@@ -646,6 +654,7 @@ export async function detectLegacyStateMigrations(params: {
tuiLastSessions,
commitments,
managedOutgoingImages,
webPush,
rescuePending,
channelPairing,
execApprovals,
@@ -837,6 +846,11 @@ export async function runLegacyStateMigrations(params: {
detected: detected.managedOutgoingImages,
stateDir: detected.stateDir,
});
const webPush = await migrateLegacyWebPush({
detected: detected.webPush,
env,
stateDir: detected.stateDir,
});
const rescuePending = discardLegacyRescuePending({
detected: detected.rescuePending,
stateDir: detected.stateDir,
@@ -874,6 +888,7 @@ export async function runLegacyStateMigrations(params: {
tuiLastSessions,
commitments,
managedOutgoingImages,
webPush,
pluginPlans,
]);
return {
@@ -892,6 +907,7 @@ export async function runLegacyStateMigrations(params: {
...tuiLastSessions.changes,
...commitments.changes,
...managedOutgoingImages.changes,
...webPush.changes,
...rescuePending.changes,
...channelPairing.changes,
...execApprovals.changes,
@@ -918,6 +934,7 @@ export async function runLegacyStateMigrations(params: {
...tuiLastSessions.warnings,
...commitments.warnings,
...managedOutgoingImages.warnings,
...webPush.warnings,
...rescuePending.warnings,
...channelPairing.warnings,
...execApprovals.warnings,
+62
View File
@@ -20,6 +20,12 @@ import {
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import {
createWebPushVapidKeyPair,
hashWebPushEndpoint,
listWebPushSubscriptions,
readPersistedVapidKeyPair,
} from "./push-web-store.js";
import {
autoMigrateLegacyState,
autoMigrateLegacyPluginDoctorState,
@@ -2068,6 +2074,62 @@ describe("state migrations", () => {
await expect(fs.access(path.join(canonicalStateDir, "legacy.txt"))).resolves.toBeUndefined();
});
it("routes explicit Doctor repair through the Web Push SQLite importer", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const cfg = createConfig();
const endpoint = "https://push.example.com/doctor-integration";
const subscription = {
subscriptionId: "c0a80101-0000-4000-8000-000000000001",
endpoint,
keys: { p256dh: "doctor-p256dh", auth: "doctor-auth" },
createdAtMs: 1,
updatedAtMs: 2,
};
const pushDir = path.join(stateDir, "push");
const subscriptionsPath = path.join(pushDir, "web-push-subscriptions.json");
const vapidKeysPath = path.join(pushDir, "vapid-keys.json");
await fs.mkdir(pushDir, { recursive: true });
await fs.writeFile(
subscriptionsPath,
JSON.stringify({
subscriptionsByEndpointHash: {
[hashWebPushEndpoint(endpoint)]: subscription,
},
}),
"utf8",
);
await fs.writeFile(
vapidKeysPath,
JSON.stringify(
createWebPushVapidKeyPair("doctor-public", "doctor-private", "https://openclaw.ai"),
),
"utf8",
);
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
doctorOnlyStateMigrations: true,
});
expect(detected.webPush.hasLegacy).toBe(true);
expect(detected.preview).toContain(
"- Web Push subscriptions and VAPID identity: legacy JSON → shared SQLite state",
);
const result = await runLegacyStateMigrations({ detected, config: cfg, env });
expect(result.warnings).toStrictEqual([]);
expect(listWebPushSubscriptions(stateDir)).toStrictEqual([subscription]);
expect(readPersistedVapidKeyPair(stateDir)).toStrictEqual(
createWebPushVapidKeyPair("doctor-public", "doctor-private", "https://openclaw.ai"),
);
await expectMissingPath(subscriptionsPath);
await expectMissingPath(vapidKeysPath);
});
it("migrates legacy update-check JSON into shared SQLite state", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
+5
View File
@@ -104,6 +104,11 @@ export type LegacyStateDetection = {
sourceDir: string;
hasLegacy: boolean;
};
webPush: {
subscriptionsPath: string;
vapidKeysPath: string;
hasLegacy: boolean;
};
rescuePending: LegacyRescuePendingDetection;
channelPairing: LegacyChannelPairingStateDetection;
execApprovals: {
+543
View File
@@ -0,0 +1,543 @@
// Covers fail-closed Doctor import of the retired Web Push JSON stores.
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { acquireGatewayLock } from "./gateway-lock.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js";
import {
createWebPushVapidKeyPair,
hashWebPushEndpoint,
listWebPushSubscriptions,
readPersistedVapidKeyPair,
webPushSubscriptionToRow,
webPushVapidKeyPairToRow,
DEFAULT_WEB_PUSH_VAPID_SUBJECT,
type VapidKeyPair,
type WebPushDatabase,
type WebPushSubscription,
} from "./push-web-store.js";
import { detectLegacyWebPush, migrateLegacyWebPush } from "./state-migrations.web-push.js";
describe("legacy Web Push Doctor migration", () => {
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
closeOpenClawStateDatabaseForTest();
envSnapshot?.restore();
envSnapshot = undefined;
cleanup();
});
});
function useStateDir(): string {
const stateDir = tempDirs.make("openclaw-web-push-migration-");
envSnapshot ??= captureEnv(["OPENCLAW_STATE_DIR"]);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
return stateDir;
}
function subscription(overrides: Partial<WebPushSubscription> = {}): WebPushSubscription {
return {
subscriptionId: "c0a80101-0000-4000-8000-000000000001",
endpoint: "https://push.example.com/send/legacy",
keys: { p256dh: "legacy-p256dh", auth: "legacy-auth" },
createdAtMs: 1_000,
updatedAtMs: 2_000,
...overrides,
};
}
function vapidKeys(overrides: Partial<VapidKeyPair> = {}): VapidKeyPair {
return {
...createWebPushVapidKeyPair(
"legacy-public-key",
"legacy-private-key",
"https://openclaw.ai",
),
...overrides,
};
}
async function writeLegacyState(params: {
stateDir: string;
subscriptions?: unknown;
vapid?: unknown;
}): Promise<{ subscriptionsPath?: string; vapidKeysPath?: string }> {
const pushDir = path.join(params.stateDir, "push");
await fsp.mkdir(pushDir, { recursive: true });
const result: { subscriptionsPath?: string; vapidKeysPath?: string } = {};
if (params.subscriptions !== undefined) {
result.subscriptionsPath = path.join(pushDir, "web-push-subscriptions.json");
const subscriptionsByEndpointHash = Array.isArray(params.subscriptions)
? Object.fromEntries(
(params.subscriptions as readonly WebPushSubscription[]).map((entry) => [
hashWebPushEndpoint(entry.endpoint),
entry,
]),
)
: params.subscriptions;
await fsp.writeFile(
result.subscriptionsPath,
JSON.stringify({ subscriptionsByEndpointHash }, null, 2),
"utf8",
);
}
if (params.vapid !== undefined) {
result.vapidKeysPath = path.join(pushDir, "vapid-keys.json");
await fsp.writeFile(result.vapidKeysPath, JSON.stringify(params.vapid, null, 2), "utf8");
}
return result;
}
function seedSubscription(endpointHash: string, value: WebPushSubscription): void {
const database = openOpenClawStateDatabase();
executeSqliteQuerySync(
database.db,
getNodeSqliteKysely<WebPushDatabase>(database.db)
.insertInto("web_push_subscriptions")
.values(webPushSubscriptionToRow({ endpointHash, subscription: value })),
);
}
function seedVapid(value: VapidKeyPair): void {
const database = openOpenClawStateDatabase();
executeSqliteQuerySync(
database.db,
getNodeSqliteKysely<WebPushDatabase>(database.db)
.insertInto("web_push_vapid_keys")
.values(webPushVapidKeyPairToRow({ keyPair: value, nowMs: 1 })),
);
}
it("detects original and interrupted-claim files only for explicit Doctor repair", async () => {
const stateDir = useStateDir();
const { subscriptionsPath } = await writeLegacyState({
stateDir,
subscriptions: [],
});
expect(detectLegacyWebPush({ stateDir }).hasLegacy).toBe(false);
expect(detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy).toBe(true);
await fsp.rename(subscriptionsPath!, `${subscriptionsPath}.doctor-importing`);
expect(detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy).toBe(true);
});
it("requires exclusive state ownership before reading or committing legacy state", async () => {
const stateDir = useStateDir();
const { subscriptionsPath } = await writeLegacyState({
stateDir,
subscriptions: [subscription()],
});
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const gatewayLock = await acquireGatewayLock({
allowInTests: true,
env,
pollIntervalMs: 10,
port: 18_789,
timeoutMs: 100,
});
if (!gatewayLock) {
throw new Error("expected test Gateway lock");
}
let blocked: Awaited<ReturnType<typeof migrateLegacyWebPush>>;
try {
blocked = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
env,
stateDir,
});
} finally {
await gatewayLock.release();
}
expect(blocked.warnings[0]).toContain("Gateway or another SQLite maintenance command");
expect(fs.existsSync(subscriptionsPath!)).toBe(true);
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
const retry = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
env,
stateDir,
});
expect(retry.warnings).toEqual([]);
expect(listWebPushSubscriptions(stateDir)).toEqual([subscription()]);
expect(fs.existsSync(subscriptionsPath!)).toBe(false);
});
it("imports subscriptions and VAPID identity in one verified operation", async () => {
const stateDir = useStateDir();
const first = subscription();
const second = subscription({
subscriptionId: "c0a80101-0000-4000-8000-000000000002",
endpoint: "https://push.example.com/send/second",
});
const paths = await writeLegacyState({
stateDir,
subscriptions: [first, second],
vapid: vapidKeys(),
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings).toEqual([]);
expect(listWebPushSubscriptions(stateDir)).toEqual([first, second]);
expect(readPersistedVapidKeyPair(stateDir)).toEqual(vapidKeys());
expect(fs.existsSync(paths.subscriptionsPath!)).toBe(false);
expect(fs.existsSync(paths.vapidKeysPath!)).toBe(false);
});
it.each([
["missing", undefined],
["empty", ""],
])("normalizes a %s legacy VAPID subject", async (_label, subject) => {
const stateDir = useStateDir();
const legacyKeys = vapidKeys({ subject: subject ?? "" });
if (subject === undefined) {
delete (legacyKeys as Partial<VapidKeyPair>).subject;
}
await writeLegacyState({ stateDir, vapid: legacyKeys });
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings).toEqual([]);
expect(readPersistedVapidKeyPair(stateDir)?.subject).toBe(DEFAULT_WEB_PUSH_VAPID_SUBJECT);
});
it("removes an empty valid store only after opening SQLite", async () => {
const stateDir = useStateDir();
const { subscriptionsPath } = await writeLegacyState({ stateDir, subscriptions: [] });
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings).toEqual([]);
expect(fs.existsSync(path.join(stateDir, "state", "openclaw.sqlite"))).toBe(true);
expect(fs.existsSync(subscriptionsPath!)).toBe(false);
});
it("rejects either malformed file without importing its valid pair", async () => {
const stateDir = useStateDir();
const paths = await writeLegacyState({
stateDir,
subscriptions: [subscription()],
vapid: { publicKey: "incomplete" },
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("VAPID keys are invalid");
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
expect(readPersistedVapidKeyPair(stateDir)).toBeNull();
expect(fs.existsSync(paths.subscriptionsPath!)).toBe(true);
expect(fs.existsSync(paths.vapidKeysPath!)).toBe(true);
});
it("rejects a forged endpoint hash and duplicate subscription ids", async () => {
const stateDir = useStateDir();
const pushDir = path.join(stateDir, "push");
await fsp.mkdir(pushDir, { recursive: true });
const sourcePath = path.join(pushDir, "web-push-subscriptions.json");
await fsp.writeFile(
sourcePath,
JSON.stringify({ subscriptionsByEndpointHash: { forged: subscription() } }),
"utf8",
);
let result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("subscription is invalid");
const first = subscription();
const second = subscription({ endpoint: "https://push.example.com/send/second" });
await fsp.writeFile(
sourcePath,
JSON.stringify({
subscriptionsByEndpointHash: {
[hashWebPushEndpoint(first.endpoint)]: first,
[hashWebPushEndpoint(second.endpoint)]: second,
},
}),
"utf8",
);
result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("duplicate subscription id");
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
});
it("keeps newer SQLite fields while preserving the earliest creation time", async () => {
const stateDir = useStateDir();
const legacy = subscription({ createdAtMs: 100, updatedAtMs: 200 });
const canonical = subscription({
keys: { p256dh: "canonical-p256dh", auth: "canonical-auth" },
createdAtMs: 150,
updatedAtMs: 300,
});
seedSubscription(hashWebPushEndpoint(canonical.endpoint), canonical);
await writeLegacyState({ stateDir, subscriptions: [legacy] });
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings).toEqual([]);
expect(listWebPushSubscriptions(stateDir)).toEqual([{ ...canonical, createdAtMs: 100 }]);
});
it("updates an older SQLite row from newer legacy state", async () => {
const stateDir = useStateDir();
const canonical = subscription({ updatedAtMs: 200 });
const legacy = subscription({
keys: { p256dh: "newer-p256dh", auth: "newer-auth" },
createdAtMs: 500,
updatedAtMs: 600,
});
seedSubscription(hashWebPushEndpoint(canonical.endpoint), canonical);
await writeLegacyState({ stateDir, subscriptions: [legacy] });
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings).toEqual([]);
expect(listWebPushSubscriptions(stateDir)).toEqual([legacy]);
});
it("retries a committed newer-row merge after normalizing its creation time", async () => {
const stateDir = useStateDir();
const canonical = subscription({ createdAtMs: 100, updatedAtMs: 200 });
const legacy = subscription({
keys: { p256dh: "newer-p256dh", auth: "newer-auth" },
createdAtMs: 500,
updatedAtMs: 600,
});
seedSubscription(hashWebPushEndpoint(canonical.endpoint), canonical);
const { subscriptionsPath } = await writeLegacyState({
stateDir,
subscriptions: [legacy],
});
const first = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
expect(first.warnings[0]).toContain("legacy cleanup failed");
expect(listWebPushSubscriptions(stateDir)).toEqual([{ ...legacy, createdAtMs: 100 }]);
const retry = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(retry.warnings).toEqual([]);
expect(listWebPushSubscriptions(stateDir)).toEqual([{ ...legacy, createdAtMs: 100 }]);
expect(fs.existsSync(`${subscriptionsPath}.doctor-importing`)).toBe(false);
});
it("rolls back equal-timestamp divergence and VAPID identity conflicts", async () => {
const stateDir = useStateDir();
const canonical = subscription({ keys: { p256dh: "canonical", auth: "canonical" } });
seedSubscription(hashWebPushEndpoint(canonical.endpoint), canonical);
seedVapid(
createWebPushVapidKeyPair("canonical-public", "canonical-private", "https://openclaw.ai"),
);
const paths = await writeLegacyState({
stateDir,
subscriptions: [subscription()],
vapid: vapidKeys(),
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("diverges at the same timestamp");
expect(listWebPushSubscriptions(stateDir)).toEqual([canonical]);
expect(readPersistedVapidKeyPair(stateDir)?.publicKey).toBe("canonical-public");
expect(fs.existsSync(paths.subscriptionsPath!)).toBe(true);
expect(fs.existsSync(paths.vapidKeysPath!)).toBe(true);
expect(fs.existsSync(`${paths.subscriptionsPath}.doctor-importing`)).toBe(false);
expect(fs.existsSync(`${paths.vapidKeysPath}.doctor-importing`)).toBe(false);
});
it("rolls back subscription changes when only VAPID conflicts", async () => {
const stateDir = useStateDir();
seedVapid(
createWebPushVapidKeyPair("canonical-public", "canonical-private", "https://openclaw.ai"),
);
await writeLegacyState({
stateDir,
subscriptions: [subscription()],
vapid: vapidKeys(),
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("VAPID identity conflicts");
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
});
it("rejects a subscription id already owned by another endpoint", async () => {
const stateDir = useStateDir();
const canonical = subscription({ endpoint: "https://push.example.com/canonical" });
const legacy = subscription({ endpoint: "https://push.example.com/legacy" });
seedSubscription(hashWebPushEndpoint(canonical.endpoint), canonical);
const { subscriptionsPath } = await writeLegacyState({
stateDir,
subscriptions: [legacy],
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("subscription id conflicts");
expect(listWebPushSubscriptions(stateDir)).toEqual([canonical]);
expect(fs.existsSync(subscriptionsPath!)).toBe(true);
});
it("fails before database mutation when a source changes after parsing", async () => {
const stateDir = useStateDir();
const { subscriptionsPath } = await writeLegacyState({
stateDir,
subscriptions: [subscription()],
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
beforeVerify: () => fs.appendFileSync(subscriptionsPath!, "\n"),
});
expect(result.warnings[0]).toContain("source changed");
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
expect(fs.existsSync(subscriptionsPath!)).toBe(true);
});
it("restores claimed sources without database mutation when claim verification fails", async () => {
const stateDir = useStateDir();
const { subscriptionsPath } = await writeLegacyState({
stateDir,
subscriptions: [subscription()],
});
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
beforeClaim: () => fs.appendFileSync(subscriptionsPath!, "\n"),
});
expect(result.warnings[0]).toContain("source changed before doctor could claim it");
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
expect(fs.existsSync(subscriptionsPath!)).toBe(true);
expect(fs.existsSync(`${subscriptionsPath}.doctor-importing`)).toBe(false);
});
it("retains fixed claims on cleanup failure and retries idempotently", async () => {
const stateDir = useStateDir();
const paths = await writeLegacyState({
stateDir,
subscriptions: [subscription()],
vapid: vapidKeys(),
});
const first = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
removeSource: () => {
throw new Error("simulated unlink failure");
},
});
expect(first.warnings[0]).toContain("legacy cleanup failed");
expect(fs.existsSync(`${paths.subscriptionsPath}.doctor-importing`)).toBe(true);
expect(fs.existsSync(`${paths.vapidKeysPath}.doctor-importing`)).toBe(true);
expect(listWebPushSubscriptions(stateDir)).toEqual([subscription()]);
const retry = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(retry.warnings).toEqual([]);
expect(fs.existsSync(`${paths.subscriptionsPath}.doctor-importing`)).toBe(false);
expect(fs.existsSync(`${paths.vapidKeysPath}.doctor-importing`)).toBe(false);
expect(listWebPushSubscriptions(stateDir)).toEqual([subscription()]);
});
it("refuses symlinked sources", async () => {
const stateDir = useStateDir();
const outside = path.join(stateDir, "outside.json");
await fsp.writeFile(outside, JSON.stringify({ subscriptionsByEndpointHash: {} }), "utf8");
const sourcePath = path.join(stateDir, "push", "web-push-subscriptions.json");
await fsp.mkdir(path.dirname(sourcePath), { recursive: true });
await fsp.symlink(outside, sourcePath);
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("Failed reading legacy Web Push state");
expect(fs.lstatSync(sourcePath).isSymbolicLink()).toBe(true);
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
});
it("refuses a legacy store reached through a symlinked state-directory ancestor", async () => {
if (process.platform === "win32") {
return;
}
const stateDir = useStateDir();
const outside = tempDirs.make("openclaw-web-push-outside-");
const legacy = subscription();
const sourcePath = path.join(outside, "web-push-subscriptions.json");
await fsp.writeFile(
sourcePath,
JSON.stringify({
subscriptionsByEndpointHash: {
[hashWebPushEndpoint(legacy.endpoint)]: legacy,
},
}),
"utf8",
);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.symlink(outside, path.join(stateDir, "push"));
const result = await migrateLegacyWebPush({
detected: detectLegacyWebPush({ stateDir, doctorOnlyStateMigrations: true }),
stateDir,
});
expect(result.warnings[0]).toContain("Failed reading legacy Web Push state");
expect(fs.existsSync(sourcePath)).toBe(true);
expect(listWebPushSubscriptions(stateDir)).toEqual([]);
});
});
+741
View File
@@ -0,0 +1,741 @@
// Doctor-only import for the retired Web Push JSON stores.
import { createHash } from "node:crypto";
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 { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { formatErrorMessage } from "./errors.js";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} 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";
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;
dev: number;
ino: number;
mtimeMs: number;
raw: string;
sha256: string;
size: number;
};
type ParsedLegacyState = {
subscriptions: Map<string, WebPushSubscription>;
vapidKeys: VapidKeyPair | null;
snapshots: LegacySourceSnapshot[];
};
function resolveLegacyWebPushPaths(stateDir: string): {
subscriptionsPath: string;
vapidKeysPath: string;
} {
const pushDir = path.join(stateDir, "push");
return {
subscriptionsPath: path.join(pushDir, "web-push-subscriptions.json"),
vapidKeysPath: path.join(pushDir, "vapid-keys.json"),
};
}
function legacyPathMayExist(filePath: string): boolean {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
function relativeLegacyPath(stateDir: string, filePath: string): string {
const relativePath = path.relative(path.resolve(stateDir), path.resolve(filePath));
if (
!relativePath ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error(`legacy Web Push path is outside the state directory: ${filePath}`);
}
return relativePath;
}
function sourceOrClaimMayExist(sourcePath: string): boolean {
return (
legacyPathMayExist(sourcePath) || legacyPathMayExist(`${sourcePath}${DOCTOR_CLAIM_SUFFIX}`)
);
}
/** Detect retired Web Push state only when an explicit doctor flow opts in. */
export function detectLegacyWebPush(params: {
stateDir: string;
doctorOnlyStateMigrations?: boolean;
}): LegacyStateDetection["webPush"] {
const paths = resolveLegacyWebPushPaths(params.stateDir);
return {
...paths,
hasLegacy:
params.doctorOnlyStateMigrations === true &&
(sourceOrClaimMayExist(paths.subscriptionsPath) ||
sourceOrClaimMayExist(paths.vapidKeysPath)),
};
}
async function readLegacySourceSnapshot(
stateRoot: Root,
stateDir: string,
sourcePath: string,
maxBytes: number,
): Promise<LegacySourceSnapshot> {
const opened = await stateRoot.read(relativeLegacyPath(stateDir, sourcePath), {
hardlinks: "reject",
maxBytes,
symlinks: "reject",
});
const raw = opened.buffer.toString("utf8");
return {
sourcePath,
dev: opened.stat.dev,
ino: opened.stat.ino,
mtimeMs: opened.stat.mtimeMs,
raw,
sha256: createHash("sha256").update(raw).digest("hex"),
size: opened.stat.size,
};
}
function sourceSnapshotsMatch(left: LegacySourceSnapshot, right: LegacySourceSnapshot): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.mtimeMs === right.mtimeMs &&
left.sha256 === right.sha256 &&
left.size === right.size
);
}
function contentSnapshotsMatch(left: LegacySourceSnapshot, right: LegacySourceSnapshot): boolean {
return left.sha256 === right.sha256 && left.size === right.size;
}
function maxBytesForSource(sourcePath: string, subscriptionsPath: string): number {
return sourcePath === subscriptionsPath
? LEGACY_SUBSCRIPTIONS_MAX_BYTES
: LEGACY_VAPID_KEYS_MAX_BYTES;
}
async function recoverInterruptedClaim(
stateRoot: Root,
stateDir: string,
sourcePath: string,
maxBytes: number,
): Promise<void> {
const claimPath = `${sourcePath}${DOCTOR_CLAIM_SUFFIX}`;
const claimRelativePath = relativeLegacyPath(stateDir, claimPath);
const sourceRelativePath = relativeLegacyPath(stateDir, sourcePath);
if (!(await stateRoot.exists(claimRelativePath))) {
return;
}
const claim = await readLegacySourceSnapshot(stateRoot, stateDir, claimPath, maxBytes);
if (!(await stateRoot.exists(sourceRelativePath))) {
await stateRoot.move(claimRelativePath, sourceRelativePath);
return;
}
const source = await readLegacySourceSnapshot(stateRoot, stateDir, sourcePath, maxBytes);
if (!contentSnapshotsMatch(claim, source)) {
throw new Error("interrupted Web Push doctor claim conflicts with its source");
}
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): 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");
const subject =
parsed.subject === undefined || parsed.subject === ""
? process.env.OPENCLAW_VAPID_SUBJECT || DEFAULT_WEB_PUSH_VAPID_SUBJECT
: parsed.subject;
if (
!isValidWebPushKey(parsed.publicKey) ||
!isValidWebPushKey(parsed.privateKey) ||
typeof subject !== "string" ||
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,
detected: LegacyStateDetection["webPush"],
): Promise<ParsedLegacyState> {
await recoverInterruptedClaim(
stateRoot,
stateDir,
detected.subscriptionsPath,
LEGACY_SUBSCRIPTIONS_MAX_BYTES,
);
await recoverInterruptedClaim(
stateRoot,
stateDir,
detected.vapidKeysPath,
LEGACY_VAPID_KEYS_MAX_BYTES,
);
const snapshots: LegacySourceSnapshot[] = [];
let subscriptions = new Map<string, WebPushSubscription>();
let vapidKeys: VapidKeyPair | null = null;
if (await stateRoot.exists(relativeLegacyPath(stateDir, detected.subscriptionsPath))) {
const snapshot = await readLegacySourceSnapshot(
stateRoot,
stateDir,
detected.subscriptionsPath,
LEGACY_SUBSCRIPTIONS_MAX_BYTES,
);
subscriptions = parseLegacySubscriptions(snapshot.raw);
snapshots.push(snapshot);
}
if (await stateRoot.exists(relativeLegacyPath(stateDir, detected.vapidKeysPath))) {
const snapshot = await readLegacySourceSnapshot(
stateRoot,
stateDir,
detected.vapidKeysPath,
LEGACY_VAPID_KEYS_MAX_BYTES,
);
vapidKeys = parseLegacyVapidKeys(snapshot.raw);
snapshots.push(snapshot);
}
return { subscriptions, vapidKeys, snapshots };
}
async function assertSourcesUnchanged(
stateRoot: Root,
stateDir: string,
snapshots: readonly LegacySourceSnapshot[],
subscriptionsPath: string,
): Promise<void> {
for (const snapshot of snapshots) {
const current = await readLegacySourceSnapshot(
stateRoot,
stateDir,
snapshot.sourcePath,
maxBytesForSource(snapshot.sourcePath, subscriptionsPath),
);
if (!sourceSnapshotsMatch(current, snapshot)) {
throw new Error("legacy Web Push source changed after doctor loaded it");
}
}
}
function mergedSubscription(params: {
existing: WebPushSubscription;
legacy: WebPushSubscription;
}): WebPushSubscription {
const { existing, legacy } = params;
const createdAtMs = Math.min(existing.createdAtMs, legacy.createdAtMs);
if (existing.updatedAtMs === legacy.updatedAtMs) {
const normalizedExisting = { ...existing, createdAtMs };
const normalizedLegacy = { ...legacy, createdAtMs };
if (!webPushSubscriptionsEqual(normalizedExisting, normalizedLegacy)) {
throw new Error("Web Push subscription diverges at the same timestamp");
}
return normalizedExisting;
}
const winner = existing.updatedAtMs > legacy.updatedAtMs ? existing : legacy;
return { ...winner, createdAtMs };
}
function findSubscriptionById(db: DatabaseSync, subscriptionId: string) {
return executeSqliteQueryTakeFirstSync(
db,
getNodeSqliteKysely<WebPushDatabase>(db)
.selectFrom("web_push_subscriptions")
.selectAll()
.where("subscription_id", "=", subscriptionId),
);
}
function writeSubscription(
db: DatabaseSync,
endpointHash: string,
subscription: WebPushSubscription,
): void {
const row = webPushSubscriptionToRow({ endpointHash, subscription });
executeSqliteQuerySync(
db,
getNodeSqliteKysely<WebPushDatabase>(db)
.insertInto("web_push_subscriptions")
.values(row)
.onConflict((conflict) =>
conflict.column("endpoint_hash").doUpdateSet({
subscription_id: row.subscription_id,
endpoint: row.endpoint,
p256dh: row.p256dh,
auth: row.auth,
created_at_ms: row.created_at_ms,
updated_at_ms: row.updated_at_ms,
}),
),
);
}
function migrateIntoDatabase(params: {
stateDir: string;
legacy: ParsedLegacyState;
nowMs: number;
}): { importedSubscriptions: number; importedVapidKeys: boolean } {
let importedSubscriptions = 0;
let importedVapidKeys = false;
runOpenClawStateWriteTransaction(
({ db }) => {
const webPushDb = getNodeSqliteKysely<WebPushDatabase>(db);
const expectedSubscriptions = new Map<string, WebPushSubscription>();
for (const [endpointHash, legacySubscription] of params.legacy.subscriptions) {
const existingRow = executeSqliteQueryTakeFirstSync(
db,
webPushDb
.selectFrom("web_push_subscriptions")
.selectAll()
.where("endpoint_hash", "=", endpointHash),
);
if (existingRow && existingRow.endpoint !== legacySubscription.endpoint) {
throw new Error("Web Push endpoint hash collision during legacy import");
}
const existing = existingRow ? webPushSubscriptionFromRow(existingRow) : null;
const expected = existing
? mergedSubscription({ existing, legacy: legacySubscription })
: legacySubscription;
const conflictingIdRow = findSubscriptionById(db, expected.subscriptionId);
if (conflictingIdRow && conflictingIdRow.endpoint_hash !== endpointHash) {
throw new Error("Web Push subscription id conflicts with another endpoint");
}
if (!existing || !webPushSubscriptionsEqual(existing, expected)) {
writeSubscription(db, endpointHash, expected);
importedSubscriptions += 1;
}
expectedSubscriptions.set(endpointHash, expected);
}
let expectedVapidKeys: VapidKeyPair | null = null;
if (params.legacy.vapidKeys) {
const existingVapidRow = executeSqliteQueryTakeFirstSync(
db,
webPushDb
.selectFrom("web_push_vapid_keys")
.selectAll()
.where("key_id", "=", WEB_PUSH_VAPID_KEY_ID),
);
if (existingVapidRow) {
if (
existingVapidRow.public_key !== params.legacy.vapidKeys.publicKey ||
existingVapidRow.private_key !== params.legacy.vapidKeys.privateKey
) {
throw new Error("legacy Web Push VAPID identity conflicts with SQLite");
}
expectedVapidKeys = createWebPushVapidKeyPair(
existingVapidRow.public_key,
existingVapidRow.private_key,
existingVapidRow.subject,
);
} else {
executeSqliteQuerySync(
db,
webPushDb
.insertInto("web_push_vapid_keys")
.values(
webPushVapidKeyPairToRow({ keyPair: params.legacy.vapidKeys, nowMs: params.nowMs }),
),
);
expectedVapidKeys = params.legacy.vapidKeys;
importedVapidKeys = true;
}
}
for (const [endpointHash, expected] of expectedSubscriptions) {
const row = executeSqliteQueryTakeFirstSync(
db,
webPushDb
.selectFrom("web_push_subscriptions")
.selectAll()
.where("endpoint_hash", "=", endpointHash),
);
if (!row || !webPushSubscriptionsEqual(webPushSubscriptionFromRow(row), expected)) {
throw new Error("SQLite verification failed for a Web Push subscription");
}
}
if (expectedVapidKeys) {
const row = executeSqliteQueryTakeFirstSync(
db,
webPushDb
.selectFrom("web_push_vapid_keys")
.selectAll()
.where("key_id", "=", WEB_PUSH_VAPID_KEY_ID),
);
if (
!row ||
row.public_key !== expectedVapidKeys.publicKey ||
row.private_key !== expectedVapidKeys.privateKey ||
row.subject !== expectedVapidKeys.subject
) {
throw new Error("SQLite verification failed for the Web Push VAPID identity");
}
}
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
);
return { importedSubscriptions, importedVapidKeys };
}
async function restoreClaims(params: {
stateRoot: Root;
stateDir: string;
claimed: readonly LegacySourceSnapshot[];
}): Promise<string[]> {
const errors: string[] = [];
for (const snapshot of params.claimed.toReversed()) {
const claimPath = `${snapshot.sourcePath}${DOCTOR_CLAIM_SUFFIX}`;
const claimRelativePath = relativeLegacyPath(params.stateDir, claimPath);
const sourceRelativePath = relativeLegacyPath(params.stateDir, snapshot.sourcePath);
try {
if (!(await params.stateRoot.exists(claimRelativePath))) {
continue;
}
if (await params.stateRoot.exists(sourceRelativePath)) {
errors.push(`source path already exists: ${snapshot.sourcePath}`);
continue;
}
await params.stateRoot.move(claimRelativePath, sourceRelativePath);
} catch (error) {
errors.push(String(error));
}
}
return errors;
}
async function claimLegacySources(params: {
stateRoot: Root;
stateDir: string;
snapshots: readonly LegacySourceSnapshot[];
subscriptionsPath: string;
beforeClaim?: () => void;
}): Promise<LegacySourceSnapshot[]> {
params.beforeClaim?.();
const claimed: LegacySourceSnapshot[] = [];
try {
for (const snapshot of params.snapshots) {
const claimPath = `${snapshot.sourcePath}${DOCTOR_CLAIM_SUFFIX}`;
await params.stateRoot.move(
relativeLegacyPath(params.stateDir, snapshot.sourcePath),
relativeLegacyPath(params.stateDir, claimPath),
);
claimed.push(snapshot);
const current = await readLegacySourceSnapshot(
params.stateRoot,
params.stateDir,
claimPath,
maxBytesForSource(snapshot.sourcePath, params.subscriptionsPath),
);
if (!sourceSnapshotsMatch(current, snapshot)) {
throw new Error("legacy Web Push source changed before doctor could claim it");
}
}
} catch (error) {
const restoreErrors = await restoreClaims({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
claimed,
});
throw new Error(
`${String(error)}${restoreErrors.length > 0 ? `; restore failures: ${restoreErrors.join("; ")}` : ""}`,
{ cause: error },
);
}
return claimed;
}
async function removeClaimedSources(params: {
stateRoot: Root;
stateDir: string;
claimed: readonly LegacySourceSnapshot[];
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<void> {
for (const snapshot of params.claimed) {
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, snapshot.sourcePath))) {
throw new Error(`legacy Web Push source reappeared during import: ${snapshot.sourcePath}`);
}
}
for (const snapshot of params.claimed) {
const claimPath = `${snapshot.sourcePath}${DOCTOR_CLAIM_SUFFIX}`;
if (params.removeSource) {
await params.removeSource(claimPath);
} else {
await params.stateRoot.remove(relativeLegacyPath(params.stateDir, claimPath));
}
}
}
async function migrateLegacyWebPushWithExclusiveStateOwnership(params: {
stateRoot: Root;
detected: LegacyStateDetection["webPush"];
stateDir: string;
beforeClaim?: () => void;
beforeVerify?: () => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
const changes: string[] = [];
const warnings: string[] = [];
const notices: string[] = [];
if (!params.detected.hasLegacy) {
return { changes, warnings };
}
let legacy: ParsedLegacyState;
try {
legacy = await readLegacyState(params.stateRoot, params.stateDir, params.detected);
} catch (error) {
warnings.push(`Failed reading legacy Web Push state: ${String(error)}`);
return { changes, warnings };
}
let claimed: LegacySourceSnapshot[];
try {
params.beforeVerify?.();
await assertSourcesUnchanged(
params.stateRoot,
params.stateDir,
legacy.snapshots,
params.detected.subscriptionsPath,
);
// Claim both sources before the database transaction. A legacy writer can no longer
// overwrite the retired paths after SQLite becomes canonical.
claimed = await claimLegacySources({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
snapshots: legacy.snapshots,
subscriptionsPath: params.detected.subscriptionsPath,
beforeClaim: params.beforeClaim,
});
} catch (error) {
warnings.push(`Failed migrating legacy Web Push state: ${String(error)}`);
return { changes, warnings };
}
let result: { importedSubscriptions: number; importedVapidKeys: boolean };
try {
result = migrateIntoDatabase({
stateDir: params.stateDir,
legacy,
nowMs: Date.now(),
});
} catch (error) {
const restoreErrors = await restoreClaims({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
claimed,
});
warnings.push(
`Failed migrating legacy Web Push state: ${String(error)}${
restoreErrors.length > 0 ? `; restore failures: ${restoreErrors.join("; ")}` : ""
}`,
);
return { changes, warnings };
}
try {
await removeClaimedSources({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
claimed,
removeSource: params.removeSource,
});
} catch (error) {
warnings.push(`Web Push state is in SQLite, but legacy cleanup failed: ${String(error)}`);
return { changes, warnings };
}
changes.push(
`Migrated ${result.importedSubscriptions} Web Push subscription${result.importedSubscriptions === 1 ? "" : "s"} to SQLite.`,
);
if (result.importedVapidKeys) {
changes.push("Migrated the Web Push VAPID identity to SQLite.");
}
notices.push("Removed retired Web Push JSON state after verified SQLite import.");
return { changes, warnings, notices };
}
/** Import both retired stores while excluding old Gateways that can recreate them. */
export async function migrateLegacyWebPush(params: {
detected: LegacyStateDetection["webPush"];
stateDir: string;
env?: NodeJS.ProcessEnv;
beforeClaim?: () => void;
beforeVerify?: () => void;
removeSource?: (sourcePath: string) => Promise<void> | void;
}): Promise<MigrationMessages> {
if (!params.detected.hasLegacy) {
return { changes: [], warnings: [] };
}
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
let lock: Awaited<ReturnType<typeof acquireGatewayLock>>;
try {
lock = await acquireGatewayLock({
allowInTests: true,
env,
pollIntervalMs: MIGRATION_LOCK_POLL_INTERVAL_MS,
role: "sqlite-maintenance",
timeoutMs: MIGRATION_LOCK_TIMEOUT_MS,
});
} catch (error) {
const detail =
error instanceof GatewayLockError
? "the Gateway or another SQLite maintenance command owns this state directory"
: String(error);
return {
changes: [],
warnings: [
`Failed migrating legacy Web Push state: ${detail}. Stop the Gateway and run \`openclaw doctor --fix\` again.`,
],
};
}
if (!lock) {
return {
changes: [],
warnings: ["Failed migrating legacy Web Push state: exclusive state ownership unavailable."],
};
}
let result: MigrationMessages = { changes: [], warnings: [] };
let releaseError: unknown;
try {
try {
const stateRoot = await root(params.stateDir, {
hardlinks: "reject",
maxBytes: LEGACY_SUBSCRIPTIONS_MAX_BYTES,
symlinks: "reject",
});
result = await migrateLegacyWebPushWithExclusiveStateOwnership({
...params,
stateRoot,
});
} catch (error) {
result.warnings.push(`Failed reading legacy Web Push state: ${String(error)}`);
}
} finally {
try {
await lock.release();
} catch (error) {
releaseError = error;
}
}
if (releaseError) {
result.warnings.push(
`Web Push migration lock release failed: ${formatErrorMessage(releaseError)}`,
);
}
return result;
}
@@ -214,6 +214,23 @@ describe("check-database-first-legacy-stores", () => {
expect(violations).toEqual([{ kind: "legacy store filesystem write", line: 4 }]);
});
it("flags runtime writes to retired Web Push JSON stores", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
import { promises as fs } from "node:fs";
import path from "node:path";
await fs.writeFile(path.join(stateDir, "push", "web-push-subscriptions.json"), "{}\n");
await fs.writeFile(path.join(stateDir, "push", "vapid-keys.json"), "{}\n");
`,
"src/infra/push-web-file-store.ts",
);
expect(violations).toEqual([
{ kind: "legacy store filesystem write", line: 4 },
{ kind: "legacy store filesystem write", line: 6 },
]);
});
it("flags runtime writes to retired skill-upload staging", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`