mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
matrix-js: add startup verification policy
This commit is contained in:
@@ -211,6 +211,13 @@ openclaw matrix-js verify backup restore --verbose
|
||||
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
|
||||
Use `--json` for full machine-readable output when scripting.
|
||||
|
||||
When `encryption: true`, Matrix-js defaults `startupVerification` to `"if-unverified"`.
|
||||
On startup, if this device is still unverified, Matrix-js will request self-verification in another Matrix client,
|
||||
skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts.
|
||||
Failed request attempts retry sooner than successful request creation by default.
|
||||
Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours`
|
||||
if you want a shorter or longer retry window.
|
||||
|
||||
## Automatic verification notices
|
||||
|
||||
Matrix-js now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
|
||||
@@ -338,6 +345,8 @@ See [Groups](/channels/groups) for mention-gating and allowlist behavior.
|
||||
- `groupAllowFrom`: allowlist of user IDs for room traffic.
|
||||
- `replyToMode`: `off`, `first`, or `all`.
|
||||
- `threadReplies`: `off`, `inbound`, or `always`.
|
||||
- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`).
|
||||
- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests.
|
||||
- `textChunkLimit`: outbound message chunk size.
|
||||
- `chunkMode`: `length` or `newline`.
|
||||
- `responsePrefix`: optional message prefix for outbound replies.
|
||||
|
||||
@@ -19,6 +19,8 @@ type LegacyAccountField =
|
||||
| "textChunkLimit"
|
||||
| "chunkMode"
|
||||
| "responsePrefix"
|
||||
| "startupVerification"
|
||||
| "startupVerificationCooldownHours"
|
||||
| "mediaMaxMb"
|
||||
| "autoJoin"
|
||||
| "autoJoinAllowlist"
|
||||
@@ -45,6 +47,8 @@ const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
|
||||
"textChunkLimit",
|
||||
"chunkMode",
|
||||
"responsePrefix",
|
||||
"startupVerification",
|
||||
"startupVerificationCooldownHours",
|
||||
"mediaMaxMb",
|
||||
"autoJoin",
|
||||
"autoJoinAllowlist",
|
||||
|
||||
@@ -60,6 +60,8 @@ export const MatrixConfigSchema = z.object({
|
||||
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
|
||||
.optional(),
|
||||
reactionNotifications: z.enum(["off", "own"]).optional(),
|
||||
startupVerification: z.enum(["off", "if-unverified"]).optional(),
|
||||
startupVerificationCooldownHours: z.number().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createDirectRoomTracker } from "./direct.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
||||
import { ensureMatrixStartupVerification } from "./startup-verification.js";
|
||||
|
||||
export type MonitorMatrixOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
@@ -363,16 +364,45 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
|
||||
}
|
||||
|
||||
// If E2EE is enabled, report device verification status and guidance.
|
||||
// If E2EE is enabled, report device verification status and request self-verification
|
||||
// when configured and the device is still unverified.
|
||||
if (auth.encryption && client.crypto) {
|
||||
try {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
if (status.verified) {
|
||||
const startupVerification = await ensureMatrixStartupVerification({
|
||||
client,
|
||||
auth,
|
||||
accountConfig,
|
||||
accountId: account.accountId,
|
||||
env: process.env,
|
||||
});
|
||||
if (startupVerification.kind === "verified") {
|
||||
logger.info("matrix: device is verified and ready for encrypted rooms");
|
||||
} else {
|
||||
} else if (
|
||||
startupVerification.kind === "disabled" ||
|
||||
startupVerification.kind === "cooldown" ||
|
||||
startupVerification.kind === "pending" ||
|
||||
startupVerification.kind === "request-failed"
|
||||
) {
|
||||
logger.info(
|
||||
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
|
||||
);
|
||||
if (startupVerification.kind === "pending") {
|
||||
logger.info(
|
||||
"matrix: startup verification request is already pending; finish it in another Matrix client",
|
||||
);
|
||||
} else if (startupVerification.kind === "cooldown") {
|
||||
logVerboseMessage(
|
||||
`matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`,
|
||||
);
|
||||
} else if (startupVerification.kind === "request-failed") {
|
||||
logger.debug?.("Matrix startup verification request failed (non-fatal)", {
|
||||
error: startupVerification.error ?? "unknown",
|
||||
});
|
||||
}
|
||||
} else if (startupVerification.kind === "requested") {
|
||||
logger.info(
|
||||
"matrix: device not verified — requested verification in another Matrix client",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixStartupVerification } from "./startup-verification.js";
|
||||
|
||||
function createTempStateDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-js-startup-verify-"));
|
||||
}
|
||||
|
||||
function createStateFilePath(rootDir: string): string {
|
||||
return path.join(rootDir, "startup-verification.json");
|
||||
}
|
||||
|
||||
type VerificationSummaryLike = {
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
isSelfVerification: boolean;
|
||||
completed: boolean;
|
||||
pending: boolean;
|
||||
};
|
||||
|
||||
function createHarness(params?: {
|
||||
verified?: boolean;
|
||||
requestVerification?: () => Promise<{ id: string; transactionId?: string }>;
|
||||
listVerifications?: () => Promise<VerificationSummaryLike[]>;
|
||||
}) {
|
||||
const requestVerification =
|
||||
params?.requestVerification ??
|
||||
(async () => ({
|
||||
id: "verification-1",
|
||||
transactionId: "txn-1",
|
||||
}));
|
||||
const listVerifications = params?.listVerifications ?? (async () => []);
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => ({
|
||||
encryptionEnabled: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
verified: params?.verified === true,
|
||||
localVerified: params?.verified === true,
|
||||
crossSigningVerified: params?.verified === true,
|
||||
signedByOwner: params?.verified === true,
|
||||
recoveryKeyStored: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: null,
|
||||
backupVersion: null,
|
||||
backup: {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: null,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
}));
|
||||
return {
|
||||
client: {
|
||||
getOwnDeviceVerificationStatus,
|
||||
crypto: {
|
||||
listVerifications: vi.fn(listVerifications),
|
||||
requestVerification: vi.fn(requestVerification),
|
||||
},
|
||||
},
|
||||
getOwnDeviceVerificationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ensureMatrixStartupVerification", () => {
|
||||
it("skips automatic requests when the device is already verified", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({ verified: true });
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("verified");
|
||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips automatic requests when a self verification is already pending", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({
|
||||
listVerifications: async () => [
|
||||
{
|
||||
id: "verification-1",
|
||||
transactionId: "txn-1",
|
||||
isSelfVerification: true,
|
||||
completed: false,
|
||||
pending: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("pending");
|
||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects the startup verification cooldown", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness();
|
||||
await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
nowMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(second.kind).toBe("cooldown");
|
||||
expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("supports disabling startup verification requests", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness();
|
||||
const stateFilePath = createStateFilePath(tempHome);
|
||||
fs.writeFileSync(stateFilePath, JSON.stringify({ attemptedAt: "2026-03-08T12:00:00.000Z" }));
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {
|
||||
startupVerification: "off",
|
||||
},
|
||||
stateFilePath,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("disabled");
|
||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(stateFilePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("persists a successful startup verification request", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness();
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("requested");
|
||||
expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
|
||||
|
||||
expect(fs.existsSync(createStateFilePath(tempHome))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps startup verification failures non-fatal", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({
|
||||
requestVerification: async () => {
|
||||
throw new Error("no other verified session");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("request-failed");
|
||||
expect(result.error).toContain("no other verified session");
|
||||
|
||||
const cooledDown = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
nowMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(cooledDown.kind).toBe("cooldown");
|
||||
});
|
||||
|
||||
it("retries failed startup verification requests sooner than successful ones", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const stateFilePath = createStateFilePath(tempHome);
|
||||
const failingHarness = createHarness({
|
||||
requestVerification: async () => {
|
||||
throw new Error("no other verified session");
|
||||
},
|
||||
});
|
||||
|
||||
await ensureMatrixStartupVerification({
|
||||
client: failingHarness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath,
|
||||
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
const retryingHarness = createHarness();
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: retryingHarness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath,
|
||||
nowMs: Date.parse("2026-03-08T13:30:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("requested");
|
||||
expect(retryingHarness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the persisted startup state after verification succeeds", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const stateFilePath = createStateFilePath(tempHome);
|
||||
const unverified = createHarness();
|
||||
|
||||
await ensureMatrixStartupVerification({
|
||||
client: unverified.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath,
|
||||
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(stateFilePath)).toBe(true);
|
||||
|
||||
const verified = createHarness({ verified: true });
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: verified.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("verified");
|
||||
expect(fs.existsSync(stateFilePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
239
extensions/matrix-js/src/matrix/monitor/startup-verification.ts
Normal file
239
extensions/matrix-js/src/matrix/monitor/startup-verification.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { MatrixConfig } from "../../types.js";
|
||||
import { resolveMatrixStoragePaths } from "../client/storage.js";
|
||||
import type { MatrixAuth } from "../client/types.js";
|
||||
import type { MatrixClient, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
|
||||
|
||||
const STARTUP_VERIFICATION_STATE_FILENAME = "startup-verification.json";
|
||||
const DEFAULT_STARTUP_VERIFICATION_MODE = "if-unverified" as const;
|
||||
const DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS = 24;
|
||||
const DEFAULT_STARTUP_VERIFICATION_FAILURE_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
|
||||
type MatrixStartupVerificationState = {
|
||||
userId?: string | null;
|
||||
deviceId?: string | null;
|
||||
attemptedAt?: string;
|
||||
outcome?: "requested" | "failed";
|
||||
requestId?: string;
|
||||
transactionId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type MatrixStartupVerificationOutcome =
|
||||
| {
|
||||
kind: "disabled" | "verified" | "cooldown" | "pending" | "requested" | "request-failed";
|
||||
verification: MatrixOwnDeviceVerificationStatus;
|
||||
requestId?: string;
|
||||
transactionId?: string;
|
||||
error?: string;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
| {
|
||||
kind: "unsupported";
|
||||
verification?: undefined;
|
||||
};
|
||||
|
||||
function normalizeCooldownHours(value: number | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS;
|
||||
}
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
function resolveStartupVerificationStatePath(params: {
|
||||
auth: MatrixAuth;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
accountId: params.accountId,
|
||||
env: params.env,
|
||||
});
|
||||
return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME);
|
||||
}
|
||||
|
||||
async function readStartupVerificationState(
|
||||
filePath: string,
|
||||
): Promise<MatrixStartupVerificationState | null> {
|
||||
const { value } = await readJsonFileWithFallback<MatrixStartupVerificationState | null>(
|
||||
filePath,
|
||||
null,
|
||||
);
|
||||
return value && typeof value === "object" ? value : null;
|
||||
}
|
||||
|
||||
async function clearStartupVerificationState(filePath: string): Promise<void> {
|
||||
await fs.rm(filePath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function resolveStateCooldownMs(
|
||||
state: MatrixStartupVerificationState | null,
|
||||
cooldownMs: number,
|
||||
): number {
|
||||
if (state?.outcome === "failed") {
|
||||
return Math.min(cooldownMs, DEFAULT_STARTUP_VERIFICATION_FAILURE_COOLDOWN_MS);
|
||||
}
|
||||
return cooldownMs;
|
||||
}
|
||||
|
||||
function resolveRetryAfterMs(params: {
|
||||
attemptedAt?: string;
|
||||
cooldownMs: number;
|
||||
nowMs: number;
|
||||
}): number | undefined {
|
||||
const attemptedAtMs = Date.parse(params.attemptedAt ?? "");
|
||||
if (!Number.isFinite(attemptedAtMs)) {
|
||||
return undefined;
|
||||
}
|
||||
const remaining = attemptedAtMs + params.cooldownMs - params.nowMs;
|
||||
return remaining > 0 ? remaining : undefined;
|
||||
}
|
||||
|
||||
function shouldHonorCooldown(params: {
|
||||
state: MatrixStartupVerificationState | null;
|
||||
verification: MatrixOwnDeviceVerificationStatus;
|
||||
stateCooldownMs: number;
|
||||
nowMs: number;
|
||||
}): boolean {
|
||||
if (!params.state || params.stateCooldownMs <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.state.userId &&
|
||||
params.verification.userId &&
|
||||
params.state.userId !== params.verification.userId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.state.deviceId &&
|
||||
params.verification.deviceId &&
|
||||
params.state.deviceId !== params.verification.deviceId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
resolveRetryAfterMs({
|
||||
attemptedAt: params.state.attemptedAt,
|
||||
cooldownMs: params.stateCooldownMs,
|
||||
nowMs: params.nowMs,
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function hasPendingSelfVerification(
|
||||
verifications: Array<{
|
||||
isSelfVerification: boolean;
|
||||
completed: boolean;
|
||||
pending: boolean;
|
||||
}>,
|
||||
): boolean {
|
||||
return verifications.some(
|
||||
(entry) =>
|
||||
entry.isSelfVerification === true && entry.completed !== true && entry.pending !== false,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureMatrixStartupVerification(params: {
|
||||
client: Pick<MatrixClient, "crypto" | "getOwnDeviceVerificationStatus">;
|
||||
auth: MatrixAuth;
|
||||
accountConfig: Pick<MatrixConfig, "startupVerification" | "startupVerificationCooldownHours">;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nowMs?: number;
|
||||
stateFilePath?: string;
|
||||
}): Promise<MatrixStartupVerificationOutcome> {
|
||||
if (params.auth.encryption !== true || !params.client.crypto) {
|
||||
return { kind: "unsupported" };
|
||||
}
|
||||
|
||||
const verification = await params.client.getOwnDeviceVerificationStatus();
|
||||
const statePath =
|
||||
params.stateFilePath ??
|
||||
resolveStartupVerificationStatePath({
|
||||
auth: params.auth,
|
||||
accountId: params.accountId,
|
||||
env: params.env,
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
await clearStartupVerificationState(statePath);
|
||||
return {
|
||||
kind: "verified",
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
const mode = params.accountConfig.startupVerification ?? DEFAULT_STARTUP_VERIFICATION_MODE;
|
||||
if (mode === "off") {
|
||||
await clearStartupVerificationState(statePath);
|
||||
return {
|
||||
kind: "disabled",
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
const verifications = await params.client.crypto.listVerifications().catch(() => []);
|
||||
if (hasPendingSelfVerification(verifications)) {
|
||||
return {
|
||||
kind: "pending",
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
const cooldownHours = normalizeCooldownHours(
|
||||
params.accountConfig.startupVerificationCooldownHours,
|
||||
);
|
||||
const cooldownMs = cooldownHours * 60 * 60 * 1000;
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const state = await readStartupVerificationState(statePath);
|
||||
const stateCooldownMs = resolveStateCooldownMs(state, cooldownMs);
|
||||
if (shouldHonorCooldown({ state, verification, stateCooldownMs, nowMs })) {
|
||||
return {
|
||||
kind: "cooldown",
|
||||
verification,
|
||||
retryAfterMs: resolveRetryAfterMs({
|
||||
attemptedAt: state?.attemptedAt,
|
||||
cooldownMs: stateCooldownMs,
|
||||
nowMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const request = await params.client.crypto.requestVerification({ ownUser: true });
|
||||
await writeJsonFileAtomically(statePath, {
|
||||
userId: verification.userId,
|
||||
deviceId: verification.deviceId,
|
||||
attemptedAt: new Date(nowMs).toISOString(),
|
||||
outcome: "requested",
|
||||
requestId: request.id,
|
||||
transactionId: request.transactionId,
|
||||
} satisfies MatrixStartupVerificationState);
|
||||
return {
|
||||
kind: "requested",
|
||||
verification,
|
||||
requestId: request.id,
|
||||
transactionId: request.transactionId ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
await writeJsonFileAtomically(statePath, {
|
||||
userId: verification.userId,
|
||||
deviceId: verification.deviceId,
|
||||
attemptedAt: new Date(nowMs).toISOString(),
|
||||
outcome: "failed",
|
||||
error,
|
||||
} satisfies MatrixStartupVerificationState).catch(() => {});
|
||||
return {
|
||||
kind: "request-failed",
|
||||
verification,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@ export type MatrixConfig = {
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
|
||||
/** Inbound reaction notifications for bot-authored Matrix messages. */
|
||||
reactionNotifications?: "off" | "own";
|
||||
/** Whether Matrix-js should auto-request self verification on startup when unverified. */
|
||||
startupVerification?: "off" | "if-unverified";
|
||||
/** Cooldown window for automatic startup verification requests. Default: 24 hours. */
|
||||
startupVerificationCooldownHours?: number;
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Auto-join invites (always|allowlist|off). Default: always. */
|
||||
|
||||
@@ -96,7 +96,7 @@ export {
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { writeJsonFileAtomically } from "./json-store.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||
export { createLoggerBackedRuntime } from "./runtime.js";
|
||||
|
||||
Reference in New Issue
Block a user