From cf8d01bc5a3d5fcaf539c81c264ca2a591f49610 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 25 Feb 2026 23:48:43 -0500 Subject: [PATCH] pairing: isolate account-scoped allowlist and pending requests --- src/pairing/pairing-store.test.ts | 30 ++++++++++++++++++++++++++++-- src/pairing/pairing-store.ts | 21 +++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index e44dd391eaf..3d42546f6c1 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -257,7 +257,7 @@ describe("pairing store", () => { }); }); - it("reads sync allowFrom with scoped + legacy dedupe and wildcard filtering", async () => { + it("reads sync allowFrom with account-scoped isolation and wildcard filtering", async () => { await withTempStateDir(async (stateDir) => { await writeAllowFromFixture({ stateDir, @@ -273,11 +273,37 @@ describe("pairing store", () => { const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); const channelScoped = readChannelAllowFromStoreSync("telegram"); - expect(scoped).toEqual(["1002", "1001"]); + expect(scoped).toEqual(["1002", "1001", "1002"]); expect(channelScoped).toEqual(["1001", "1001"]); }); }); + it("does not reuse pairing requests across accounts for the same sender id", async () => { + await withTempStateDir(async () => { + const first = await upsertChannelPairingRequest({ + channel: "telegram", + accountId: "alpha", + id: "12345", + }); + const second = await upsertChannelPairingRequest({ + channel: "telegram", + accountId: "beta", + id: "12345", + }); + + expect(first.created).toBe(true); + expect(second.created).toBe(true); + expect(second.code).not.toBe(first.code); + + const alpha = await listChannelPairingRequests("telegram", process.env, "alpha"); + const beta = await listChannelPairingRequests("telegram", process.env, "beta"); + expect(alpha).toHaveLength(1); + expect(beta).toHaveLength(1); + expect(alpha[0]?.code).toBe(first.code); + expect(beta[0]?.code).toBe(second.code); + }); + }); + it("reads legacy channel-scoped allowFrom for default account", async () => { await withTempStateDir(async (stateDir) => { await writeAllowFromFixture({ stateDir, channel: "telegram", allowFrom: ["1001"] }); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index eb0b52b308b..0f46d53b479 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -218,6 +218,12 @@ function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: str ); } +function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean { + // Keep backward compatibility for legacy channel-scoped allowFrom only on default account. + // Non-default accounts should remain isolated to avoid cross-account implicit approvals. + return !normalizedAccountId || normalizedAccountId === "default"; +} + function normalizeId(value: string | number): string { return String(value).trim(); } @@ -344,8 +350,11 @@ export async function readChannelAllowFromStore( const scopedPath = resolveAllowFromPath(channel, env, accountId); const scopedEntries = await readAllowFromStateForPath(channel, scopedPath); + if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { + return scopedEntries; + } // Backward compatibility: legacy channel-level allowFrom store was unscoped. - // Keep honoring it alongside account-scoped files to prevent re-pair prompts after upgrades. + // Keep honoring it for default account to prevent re-pair prompts after upgrades. const legacyPath = resolveAllowFromPath(channel, env); const legacyEntries = await readAllowFromStateForPath(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); @@ -364,6 +373,9 @@ export function readChannelAllowFromStoreSync( const scopedPath = resolveAllowFromPath(channel, env, accountId); const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath); + if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { + return scopedEntries; + } const legacyPath = resolveAllowFromPath(channel, env); const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); @@ -503,7 +515,12 @@ export async function upsertChannelPairingRequest(params: { nowMs, ); reqs = prunedExpired; - const existingIdx = reqs.findIndex((r) => r.id === id); + const existingIdx = reqs.findIndex((r) => { + if (r.id !== id) { + return false; + } + return requestMatchesAccountId(r, normalizePairingAccountId(normalizedAccountId)); + }); const existingCodes = new Set( reqs.map((req) => String(req.code ?? "")