fix: cap pairing requests and suppress outbound pairing replies

This commit is contained in:
Peter Steinberger
2026-01-09 22:58:11 +00:00
parent 98d0318d4e
commit 88cbe2d275
13 changed files with 106 additions and 27 deletions

View File

@@ -106,4 +106,31 @@ describe("pairing store", () => {
}
});
});
it("caps pending requests at the default limit", async () => {
await withTempStateDir(async () => {
const ids = ["+15550000001", "+15550000002", "+15550000003"];
for (const id of ids) {
const created = await upsertProviderPairingRequest({
provider: "whatsapp",
id,
});
expect(created.created).toBe(true);
}
const blocked = await upsertProviderPairingRequest({
provider: "whatsapp",
id: "+15550000004",
});
expect(blocked.created).toBe(false);
const list = await listProviderPairingRequests("whatsapp");
const listIds = list.map((entry) => entry.id);
expect(listIds).toHaveLength(3);
expect(listIds).toContain("+15550000001");
expect(listIds).toContain("+15550000002");
expect(listIds).toContain("+15550000003");
expect(listIds).not.toContain("+15550000004");
});
});
});

View File

@@ -10,6 +10,7 @@ import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
const PAIRING_PENDING_MAX = 3;
const PAIRING_STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
@@ -160,6 +161,22 @@ function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
return { requests: kept, removed };
}
function resolveLastSeenAt(entry: PairingRequest): number {
return (
parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0
);
}
function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
if (maxPending <= 0 || reqs.length <= maxPending) {
return { requests: reqs, removed: false };
}
const sorted = reqs
.slice()
.sort((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
return { requests: sorted.slice(-maxPending), removed: true };
}
function randomCode(): string {
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
let out = "";
@@ -259,8 +276,13 @@ export async function listProviderPairingRequests(
});
const reqs = Array.isArray(value.requests) ? value.requests : [];
const nowMs = Date.now();
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
if (removed) {
const { requests: prunedExpired, removed: expiredRemoved } =
pruneExpiredRequests(reqs, nowMs);
const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests(
prunedExpired,
PAIRING_PENDING_MAX,
);
if (expiredRemoved || cappedRemoved) {
await writeJsonFile(filePath, {
version: 1,
requests: pruned,
@@ -309,8 +331,9 @@ export async function upsertProviderPairingRequest(params: {
: undefined;
let reqs = Array.isArray(value.requests) ? value.requests : [];
const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
reqs = pruned;
const { requests: prunedExpired, removed: expiredRemoved } =
pruneExpiredRequests(reqs, nowMs);
reqs = prunedExpired;
const existingIdx = reqs.findIndex((r) => r.id === id);
const existingCodes = new Set(
reqs.map((req) =>
@@ -335,13 +358,31 @@ export async function upsertProviderPairingRequest(params: {
meta: meta ?? existing?.meta,
};
reqs[existingIdx] = next;
const { requests: capped } = pruneExcessRequests(
reqs,
PAIRING_PENDING_MAX,
);
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
requests: capped,
} satisfies PairingStore);
return { code, created: false };
}
const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(
reqs,
PAIRING_PENDING_MAX,
);
reqs = capped;
if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
if (expiredRemoved || cappedRemoved) {
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
} satisfies PairingStore);
}
return { code: "", created: false };
}
const code = generateUniqueCode(existingCodes);
const next: PairingRequest = {
id,