feat: opt-in IP geolocation for get_location
Adds the one thing the extension could not answer before: roughly where the user actually is, rather than which zone their clock is set to. - `lib/geo.ts` resolves a place from ipapi.co. Chosen because it is HTTPS and needs no API key; ip-api.com, the other obvious free option, is HTTP-only on its free tier, which is not a place to send an IP address. - Off by default, and skipped entirely when the user has set Location: the better answer is already there, so spending their privacy on a worse one would be indefensible. `ipPlace` is only wired into ToolContext when the preference is on, so an off setting cannot reach the network at all. - Cached for 6 hours. A city does not change between questions, and every lookup hands the provider another copy of the address. Set Up Lam Agent clears it on re-test, alongside the vision cache. - Failures are returned, not swallowed: a rate limit or an offline machine says so in the tool output while the region answer stays available. The provider signals some failures with error: true and HTTP 200, so the body is checked as well as the status. - The tool output always names the provider and calls the result city-level rather than exact. It also flags an IP timezone that disagrees with the device clock, which usually means a VPN. - The README claimed no geolocation service ever saw the user's address. That is now conditional, so the section was rewritten rather than left to quietly become untrue. Deliberately not done: GPS. CoreLocation has no CLI on macOS and GeoClue's usual provider resolves nearby Wi-Fi through a Mozilla service anyway. Verified: 341 tests (25 new), tsc, eslint, prettier, and vici build. I did not make a live API call, because doing so would send this machine's IP to the provider as the exact flow being opted into.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
|
||||
import { GEO_TTL_MS, buildPlace, forgetGeoPlace, ipPlace } from "../lib/geo";
|
||||
|
||||
/**
|
||||
* The only code in the extension that sends anything about the user to a third
|
||||
* party, so the parts worth pinning down are that it caches (rather than leaking
|
||||
* the address on every question), that it never throws, and that a failure is
|
||||
* reported instead of being served as a coarser answer.
|
||||
*/
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function answer(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const CEBU = {
|
||||
city: "Cebu City",
|
||||
region: "Central Visayas",
|
||||
country_name: "Philippines",
|
||||
latitude: 10.3167,
|
||||
longitude: 123.8907,
|
||||
timezone: "Asia/Manila",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLocalStorage();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("buildPlace", () => {
|
||||
it("joins the parts that exist", () => {
|
||||
expect(buildPlace({ city: "Cebu City", region: "Central Visayas", country: "Philippines" })).toBe(
|
||||
"Cebu City, Central Visayas, Philippines",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops repeats, which providers return for city-states", () => {
|
||||
// "Singapore, Singapore, Singapore" reads like a bug rather than a place.
|
||||
expect(buildPlace({ city: "Singapore", region: "Singapore", country: "Singapore" })).toBe("Singapore");
|
||||
});
|
||||
|
||||
it("ignores repeats that differ only in case", () => {
|
||||
expect(buildPlace({ city: "Manila", region: "manila" })).toBe("Manila");
|
||||
});
|
||||
|
||||
it("drops empty and whitespace parts", () => {
|
||||
expect(buildPlace({ city: " ", region: "", country: "Philippines" })).toBe("Philippines");
|
||||
});
|
||||
|
||||
it("returns nothing when there is nothing", () => {
|
||||
expect(buildPlace({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ipPlace", () => {
|
||||
it("resolves a place from a provider answer", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
|
||||
expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines");
|
||||
expect(result.place.latitude).toBe(10.3167);
|
||||
expect(result.place.timezone).toBe("Asia/Manila");
|
||||
expect(result.place.source).toBe("ipapi.co");
|
||||
});
|
||||
|
||||
it("asks for JSON with no cache-busting that a cache would defeat", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ipapi.co/json/");
|
||||
expect(init.headers.Accept).toBe("application/json");
|
||||
});
|
||||
|
||||
it("serves a repeat from the cache instead of re-sending the address", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
await ipPlace({ now: 1_000 });
|
||||
const second = await ipPlace({ now: 2_000 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("re-fetches once the cached place is stale", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const later = await ipPlace({ now: 1_000 + GEO_TTL_MS + 1 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(later.status === "ok" && later.place.city).toBe("Davao City");
|
||||
});
|
||||
|
||||
it("treats exactly the TTL as still fresh", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
await ipPlace({ now: 1_000 + GEO_TTL_MS });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores the cache when asked to refresh", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const refreshed = await ipPlace({ now: 1_001, refresh: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(refreshed.status === "ok" && refreshed.place.city).toBe("Davao City");
|
||||
});
|
||||
|
||||
it("reports a rate limit by status rather than pretending to know the place", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("slow down", { status: 429 }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "ipapi.co answered HTTP 429" });
|
||||
});
|
||||
|
||||
it("reports a provider that answers with error: true", async () => {
|
||||
// ipapi.co signals failure in the body while still answering 200.
|
||||
fetchMock.mockResolvedValueOnce(answer({ error: true, reason: "RateLimited", message: "Too many" }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "Too many" });
|
||||
});
|
||||
|
||||
it("reports a body that is not JSON", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("<html>nope</html>", { status: 200 }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("reports an answer with no usable place", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer({ city: null, region: null, country_name: null }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "ipapi.co returned no usable place" });
|
||||
});
|
||||
|
||||
it("survives the network being down without throwing", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("does not cache a failure", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
const second = await ipPlace({ now: 1_001 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(second.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores a corrupt cache entry rather than failing", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
await LocalStorage.setItem("lam.geo", "{not json");
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
await expect(ipPlace({ now: 1_000 })).resolves.toMatchObject({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgetGeoPlace", () => {
|
||||
it("drops the cached place so a move is not pinned for hours", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
await forgetGeoPlace();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const after = await ipPlace({ now: 1_001 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(after.status === "ok" && after.place.city).toBe("Davao City");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user