pairing: isolate account-scoped allowlist and pending requests

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 23:48:43 -05:00
parent 35976da7a0
commit cf8d01bc5a
2 changed files with 47 additions and 4 deletions

View File

@@ -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"] });

View File

@@ -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 ?? "")