CI / Build & Quality Checks (push) Successful in 1m28s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
LOTUS_TODO.md → LOTUS_REFERENCE.md (design laws, decided deferrals, server-blocked features, operational reference only). Every open task was filed: cinny #195–#210, cinny-desktop #15–#18, matrix #8–#10. LOTUS_TESTING.md keeps the automated-coverage map, the Playwright notes and the deploy tip; every manual checklist is now a `qa` issue under the 'Manual QA backlog' (cinny #170–#194, #198) and 'Desktop QA backlog' (cinny-desktop #11–#14, #18) milestones. Repointed the README, LOTUS_FEATURES, CI and source comments that referenced LOTUS_TODO.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
152 lines
4.8 KiB
TypeScript
152 lines
4.8 KiB
TypeScript
import type { MatrixClient } from 'matrix-js-sdk';
|
|
import pkg from '../../../package.json';
|
|
|
|
// Lotus E2EE investigation kit — capture-only console diagnostics.
|
|
//
|
|
// Installs pass-through wrappers around `console.warn` / `console.error` that
|
|
// ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures
|
|
// (E2EE KE-1..4 capture; see cinny #201). It NEVER swallows a log call — the
|
|
// original console method is always invoked — and it performs NO network I/O.
|
|
// The report metadata is limited to SDK version / device id / user id / sync
|
|
// state; the captured log lines themselves are intentional evidence and may
|
|
// contain event ids or matrix ids exactly as the SDK logged them.
|
|
|
|
export type CryptoDiagLevel = 'warn' | 'error';
|
|
|
|
export type CryptoDiagEntry = {
|
|
/** ISO-8601 UTC timestamp of when the line was captured. */
|
|
ts: string;
|
|
level: CryptoDiagLevel;
|
|
/** Which KE bucket the signature belongs to, e.g. `KE-1`. */
|
|
ke: string;
|
|
/** Human-readable label of the matched signature. */
|
|
signature: string;
|
|
/** The serialized console line (best-effort). */
|
|
message: string;
|
|
};
|
|
|
|
type Signature = {
|
|
ke: string;
|
|
label: string;
|
|
re: RegExp;
|
|
};
|
|
|
|
// Ordered most-specific-first so the recorded label is the tightest match.
|
|
const SIGNATURES: Signature[] = [
|
|
{ ke: 'KE-1', label: 'already exists', re: /already exists/i },
|
|
{ ke: 'KE-2', label: 'missing key at index', re: /missing key at index/i },
|
|
{
|
|
ke: 'KE-2',
|
|
label: 'io.element.call.encryption_keys',
|
|
re: /io\.element\.call\.encryption_keys/,
|
|
},
|
|
{ ke: 'KE-2', label: 'MissingKey', re: /MissingKey/ },
|
|
{ ke: 'KE-3', label: 'DecryptionError', re: /DecryptionError/ },
|
|
{ ke: 'KE-4', label: 'update_delayed_event', re: /update_delayed_event/ },
|
|
{ ke: 'KE-4', label: 'delayed event', re: /delayed event/i },
|
|
];
|
|
|
|
const MAX_ENTRIES = 200;
|
|
|
|
const entries: CryptoDiagEntry[] = [];
|
|
|
|
let installed = false;
|
|
let originalWarn: ((...data: unknown[]) => void) | undefined;
|
|
let originalError: ((...data: unknown[]) => void) | undefined;
|
|
|
|
const stringifyArg = (arg: unknown): string => {
|
|
if (typeof arg === 'string') return arg;
|
|
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
|
try {
|
|
return JSON.stringify(arg);
|
|
} catch {
|
|
return String(arg);
|
|
}
|
|
};
|
|
|
|
const capture = (level: CryptoDiagLevel, args: unknown[]): void => {
|
|
const message = args.map(stringifyArg).join(' ');
|
|
const sig = SIGNATURES.find((s) => s.re.test(message));
|
|
if (!sig) return;
|
|
|
|
entries.push({
|
|
ts: new Date().toISOString(),
|
|
level,
|
|
ke: sig.ke,
|
|
signature: sig.label,
|
|
message,
|
|
});
|
|
// Ring-buffer: keep only the most recent MAX_ENTRIES.
|
|
while (entries.length > MAX_ENTRIES) {
|
|
entries.shift();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Install the capture-only console wrappers. Idempotent — calling it more than
|
|
* once is a no-op. Safe to call as early as possible during app boot.
|
|
*/
|
|
export const installCryptoDiagLog = (): void => {
|
|
if (installed) return;
|
|
installed = true;
|
|
|
|
originalWarn = console.warn.bind(console);
|
|
originalError = console.error.bind(console);
|
|
|
|
console.warn = (...args: unknown[]): void => {
|
|
capture('warn', args);
|
|
originalWarn?.(...args);
|
|
};
|
|
console.error = (...args: unknown[]): void => {
|
|
capture('error', args);
|
|
originalError?.(...args);
|
|
};
|
|
};
|
|
|
|
/** A snapshot copy of the current capture buffer (most-recent-last). */
|
|
export const getCryptoDiagEntries = (): CryptoDiagEntry[] => entries.slice();
|
|
|
|
const readSdkVersion = (mx?: MatrixClient): string => {
|
|
// Prefer the value the running client reports; fall back to the declared pin.
|
|
const declared = (pkg.dependencies as Record<string, string> | undefined)?.['matrix-js-sdk'];
|
|
const clientVersion = (mx as unknown as { getSdkVersion?: () => string } | undefined)
|
|
?.getSdkVersion;
|
|
if (typeof clientVersion === 'function') {
|
|
try {
|
|
return clientVersion.call(mx) || declared || 'unknown';
|
|
} catch {
|
|
// fall through to the declared pin
|
|
}
|
|
}
|
|
return declared ?? 'unknown';
|
|
};
|
|
|
|
/**
|
|
* Build a self-contained JSON diagnostic report string. Contains only the SDK
|
|
* version, device id, user id, sync state, crypto readiness, and the captured
|
|
* KE signature buffer — no message content, tokens, or other PII.
|
|
*/
|
|
export const buildCryptoDiagReport = (mx?: MatrixClient): string => {
|
|
const buffer = getCryptoDiagEntries();
|
|
const countsByKe: Record<string, number> = {};
|
|
buffer.forEach((entry) => {
|
|
countsByKe[entry.ke] = (countsByKe[entry.ke] ?? 0) + 1;
|
|
});
|
|
|
|
const report = {
|
|
kind: 'lotus-crypto-diag',
|
|
generatedAt: new Date().toISOString(),
|
|
sdkVersion: readSdkVersion(mx),
|
|
deviceId: mx?.getDeviceId() ?? null,
|
|
userId: mx?.getUserId() ?? null,
|
|
syncState: mx?.getSyncState() ?? null,
|
|
cryptoReady: Boolean(mx?.getCrypto()),
|
|
entryCount: buffer.length,
|
|
maxEntries: MAX_ENTRIES,
|
|
countsByKe,
|
|
entries: buffer,
|
|
};
|
|
|
|
return JSON.stringify(report, null, 2);
|
|
};
|