Skip to content

Commit 1e1ca8c

Browse files
committed
fix(search-nlp): key intent cache on privacy posture + provider chain
The NLP intent cache key was derived only from query + map center, so a cloud-derived intent cached by a consent/cloud request could be served to a later noCloud / privacyMode:strict request for the same query+center — a privacy-posture leak. Fold the effective no-cloud flag (noCloud || strict) and the configured providerChain into the key, partitioning the cache so a no-cloud request can never read a cloud-eligible entry and a providerChain change yields a fresh namespace. Removes the manual nlp:intent:* flush caveat.
1 parent 1a3809a commit 1e1ca8c

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "vitest";
2+
import { intentCacheKey } from "../index.js";
3+
4+
const Q = "coffee near me";
5+
const C: [number, number] = [13.3777, 52.5163];
6+
7+
describe("intentCacheKey privacy partitioning", () => {
8+
it("is stable for identical inputs", () => {
9+
expect(intentCacheKey(Q, C, 2, "local,claude,keyword", false)).toBe(
10+
intentCacheKey(Q, C, 2, "local,claude,keyword", false),
11+
);
12+
});
13+
14+
it("differs by the no-cloud flag (a strict/noCloud request can't hit a cloud entry)", () => {
15+
const cloud = intentCacheKey(Q, C, 2, "local,claude,keyword", false);
16+
const noCloud = intentCacheKey(Q, C, 2, "local,claude,keyword", true);
17+
expect(cloud).not.toBe(noCloud);
18+
});
19+
20+
it("differs by configured provider chain", () => {
21+
const a = intentCacheKey(Q, C, 2, "local,claude,keyword", false);
22+
const b = intentCacheKey(Q, C, 2, "local,keyword", false);
23+
expect(a).not.toBe(b);
24+
});
25+
26+
it("still varies by query and center", () => {
27+
expect(intentCacheKey("tea", C, 2, "local,keyword", true)).not.toBe(
28+
intentCacheKey(Q, C, 2, "local,keyword", true),
29+
);
30+
expect(intentCacheKey(Q, [0, 0], 2, "local,keyword", true)).not.toBe(
31+
intentCacheKey(Q, C, 2, "local,keyword", true),
32+
);
33+
});
34+
35+
it("emits the nlp:intent: namespace", () => {
36+
expect(intentCacheKey(Q, C, 2, "local,keyword", true)).toMatch(/^nlp:intent:[0-9a-f]{32}$/);
37+
});
38+
});

integrations/search-nlp/index.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,18 @@ export function applyNoCloud(providers: NlpProvider[]): NlpProvider[] {
140140
return [...local, keywordProvider];
141141
}
142142

143-
function intentCacheKey(query: string, center: [number, number], decimals: number): string {
143+
export function intentCacheKey(
144+
query: string,
145+
center: [number, number],
146+
decimals: number,
147+
chainId: string,
148+
noCloud: boolean,
149+
): string {
144150
const normalized = query.trim().toLowerCase().replace(/\s+/g, " ");
145151
const lng = center[0].toFixed(decimals);
146152
const lat = center[1].toFixed(decimals);
147-
return `nlp:intent:${createHash("sha256").update(`${normalized}|${lng},${lat}`).digest("hex").slice(0, 32)}`;
153+
const material = `${normalized}|${lng},${lat}|${chainId}|${noCloud ? "nc" : "cl"}`;
154+
return `nlp:intent:${createHash("sha256").update(material).digest("hex").slice(0, 32)}`;
148155
}
149156

150157
/**
@@ -265,8 +272,13 @@ export function setup(ctx: IntegrationContext): void {
265272
const chain = createChain(active);
266273
const parseCtx: ParseContext = { mapCenter, mapBbox, lang };
267274

275+
const noCloudEffective = noCloud || strictPrivacy;
276+
const chainId = (
277+
(ctx.config.providerChain as string[] | undefined) ?? ["local", "keyword"]
278+
).join(",");
279+
268280
let cached = true;
269-
const key = intentCacheKey(query, mapCenter, roundDecimals);
281+
const key = intentCacheKey(query, mapCenter, roundDecimals, chainId, noCloudEffective);
270282

271283
try {
272284
const result = await ctx.cache.withCache(key, intentTtl, async () => {

0 commit comments

Comments
 (0)