diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 6f3cc7f..e999458 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -11,7 +11,6 @@
import type * as auth from "../auth.js";
import type * as http from "../http.js";
import type * as lib_auth from "../lib/auth.js";
-import type * as lib_country from "../lib/country.js";
import type * as lib_hierarchy from "../lib/hierarchy.js";
import type * as lib_sync from "../lib/sync.js";
import type * as migrations from "../migrations.js";
@@ -29,7 +28,6 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
http: typeof http;
"lib/auth": typeof lib_auth;
- "lib/country": typeof lib_country;
"lib/hierarchy": typeof lib_hierarchy;
"lib/sync": typeof lib_sync;
migrations: typeof migrations;
diff --git a/convex/lib/country.ts b/convex/lib/country.ts
deleted file mode 100644
index 4aa0b1b..0000000
--- a/convex/lib/country.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Cloudflare reports "XX" when it cannot place a client and "T1" for Tor exit
- * nodes. Neither is a country, so neither is stored.
- */
-const NOT_A_COUNTRY = new Set(["XX", "T1"]);
-
-/** A clean upper-case ISO 3166-1 alpha-2 code, or `null` when the value is not one. */
-export function normalizeCountry(value: string | null | undefined): string | null {
- const code = value?.trim().toUpperCase() ?? "";
- return /^[A-Z]{2}$/.test(code) && !NOT_A_COUNTRY.has(code) ? code : null;
-}
diff --git a/convex/schema.ts b/convex/schema.ts
index 01cf8eb..a129f27 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -42,28 +42,6 @@ const syncedFields = {
export default defineSchema({
...authTables,
- // Convex Auth's own `users` table, restated so it can carry one extra field.
- // Everything but `country` is copied from `authTables.users`; keep it in step
- // with that definition when upgrading @convex-dev/auth.
- users: defineTable({
- name: v.optional(v.string()),
- image: v.optional(v.string()),
- email: v.optional(v.string()),
- emailVerificationTime: v.optional(v.number()),
- phone: v.optional(v.string()),
- phoneVerificationTime: v.optional(v.number()),
- isAnonymous: v.optional(v.boolean()),
- /**
- * ISO 3166-1 alpha-2 country the account was first seen from (e.g. "ES"),
- * as reported by Cloudflare. Set once, never overwritten, and only the
- * country — the IP address is never stored. Absent for accounts that have
- * not opened the app since this was introduced.
- */
- country: v.optional(v.string()),
- })
- .index("email", ["email"])
- .index("phone", ["phone"]),
-
folders: defineTable({
...syncedFields,
/** Ciphertext when `cryptoVersion` > 0, plaintext when 0. */
diff --git a/convex/users.test.ts b/convex/users.test.ts
deleted file mode 100644
index c1d3b89..0000000
--- a/convex/users.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-///
-// @vitest-environment edge-runtime
-import { convexTest } from "convex-test";
-import { beforeEach, describe, expect, it } from "vitest";
-
-import { api } from "./_generated/api";
-import type { Id } from "./_generated/dataModel";
-import { normalizeCountry } from "./lib/country";
-import schema from "./schema";
-
-const modules = import.meta.glob("./**/*.ts");
-
-let t: ReturnType;
-let alice: Id<"users">;
-let bob: Id<"users">;
-
-beforeEach(async () => {
- t = convexTest(schema, modules);
- alice = await t.run((ctx) => ctx.db.insert("users", {}));
- bob = await t.run((ctx) => ctx.db.insert("users", {}));
-});
-
-const as = (userId: Id<"users">) => t.withIdentity({ subject: userId });
-const countryOf = (userId: Id<"users">) => t.run(async (ctx) => (await ctx.db.get(userId))?.country ?? null);
-
-describe("normalizeCountry", () => {
- it("accepts two letters in any case and rejects everything else", () => {
- expect(normalizeCountry("es")).toBe("ES");
- expect(normalizeCountry(" GB ")).toBe("GB");
- for (const bad of ["", "E", "ESP", "E1", "1E", null, undefined]) expect(normalizeCountry(bad)).toBeNull();
- });
-
- it("does not treat Cloudflare's unknown and Tor markers as countries", () => {
- expect(normalizeCountry("XX")).toBeNull();
- expect(normalizeCountry("T1")).toBeNull();
- });
-});
-
-describe("recordCountry", () => {
- it("requires a signed-in caller", async () => {
- await expect(t.mutation(api.users.recordCountry, { country: "ES" })).rejects.toThrow("Not authenticated");
- });
-
- it("stores the country once and never replaces it", async () => {
- expect(await as(alice).mutation(api.users.recordCountry, { country: "es" })).toEqual({ stored: true });
- expect(await as(alice).mutation(api.users.recordCountry, { country: "FR" })).toEqual({ stored: false });
- expect(await countryOf(alice)).toBe("ES");
- });
-
- it("ignores values that are not a country", async () => {
- expect(await as(alice).mutation(api.users.recordCountry, { country: "XX" })).toEqual({ stored: false });
- expect(await as(alice).mutation(api.users.recordCountry, { country: "not-a-country" })).toEqual({ stored: false });
- expect(await countryOf(alice)).toBeNull();
- });
-
- it("only ever writes the caller's own account", async () => {
- await as(alice).mutation(api.users.recordCountry, { country: "ES" });
- expect(await countryOf(bob)).toBeNull();
- });
-});
-
-describe("hasCountry", () => {
- it("is null signed out, false until a country is recorded, then true", async () => {
- expect(await t.query(api.users.hasCountry, {})).toBeNull();
- expect(await as(alice).query(api.users.hasCountry, {})).toBe(false);
- await as(alice).mutation(api.users.recordCountry, { country: "US" });
- expect(await as(alice).query(api.users.hasCountry, {})).toBe(true);
- expect(await as(bob).query(api.users.hasCountry, {})).toBe(false);
- });
-});
diff --git a/convex/users.ts b/convex/users.ts
index bff9795..34d4387 100644
--- a/convex/users.ts
+++ b/convex/users.ts
@@ -1,9 +1,5 @@
-import { v } from "convex/values";
-
import type { Id } from "./_generated/dataModel";
-import { mutation, query } from "./_generated/server";
-import { requireUserId } from "./lib/auth";
-import { normalizeCountry } from "./lib/country";
+import { query } from "./_generated/server";
/**
* The signed-in account, or `null` when there is no session.
@@ -35,39 +31,3 @@ export const viewer = query({
};
},
});
-
-/**
- * Whether the caller's account already has a country on record, or `null` when
- * signed out. The client asks this before reporting one, so an account that is
- * already known costs no further requests.
- */
-export const hasCountry = query({
- args: {},
- handler: async (ctx) => {
- const identity = await ctx.auth.getUserIdentity();
- if (!identity) return null;
-
- const user = await ctx.db.get(identity.subject.split("|")[0] as Id<"users">);
- return user ? user.country !== undefined : null;
- },
-});
-
-/**
- * Records the country an account was first seen from. First write wins: a
- * country already on file is never replaced, so travelling or a VPN cannot
- * rewrite it, and an unusable value ("XX", "T1", garbage) stores nothing.
- */
-export const recordCountry = mutation({
- args: { country: v.string() },
- handler: async (ctx, args) => {
- const userId = await requireUserId(ctx);
- const country = normalizeCountry(args.country);
- if (!country) return { stored: false };
-
- const user = await ctx.db.get(userId);
- if (!user || user.country !== undefined) return { stored: false };
-
- await ctx.db.patch(userId, { country });
- return { stored: true };
- },
-});
diff --git a/src/__tests__/account-country-route.test.ts b/src/__tests__/account-country-route.test.ts
deleted file mode 100644
index 1a69211..0000000
--- a/src/__tests__/account-country-route.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const mutation = vi.fn();
-let cfCountry: string | undefined;
-let convexConfigured = true;
-
-vi.mock("@opennextjs/cloudflare", () => ({
- getCloudflareContext: () => ({ cf: cfCountry === undefined ? undefined : { country: cfCountry } }),
-}));
-
-vi.mock("@/lib/convexServer", () => ({
- readBearerToken: (request: Request) => request.headers.get("authorization")?.replace(/^Bearer /, "") || null,
- getConvexClientForToken: () => (convexConfigured ? { mutation } : null),
-}));
-
-import { POST } from "@/app/api/account/country/route";
-
-function post(headers: Record = {}) {
- return POST(new Request("https://klipcode.test/api/account/country", { method: "POST", headers }));
-}
-
-beforeEach(() => {
- mutation.mockReset();
- mutation.mockResolvedValue({ stored: true });
- cfCountry = undefined;
- convexConfigured = true;
-});
-
-describe("POST /api/account/country", () => {
- it("rejects a request without a token", async () => {
- expect((await post({ "cf-ipcountry": "ES" })).status).toBe(401);
- expect(mutation).not.toHaveBeenCalled();
- });
-
- it("reports the country from Cloudflare's header, normalised", async () => {
- const res = await post({ authorization: "Bearer t", "cf-ipcountry": "es" });
- expect(await res.json()).toEqual({ stored: true });
- expect(mutation).toHaveBeenCalledWith(expect.anything(), { country: "ES" });
- });
-
- it("falls back to request.cf.country when the header is missing", async () => {
- cfCountry = "GB";
- await post({ authorization: "Bearer t" });
- expect(mutation).toHaveBeenCalledWith(expect.anything(), { country: "GB" });
- });
-
- it.each(["XX", "T1", "", "ESP"])("stores nothing for %j", async (value) => {
- const res = await post({ authorization: "Bearer t", "cf-ipcountry": value });
- expect(await res.json()).toEqual({ stored: false });
- expect(mutation).not.toHaveBeenCalled();
- });
-
- it("stores nothing when Cloudflare gives no country at all", async () => {
- expect(await (await post({ authorization: "Bearer t" })).json()).toEqual({ stored: false });
- expect(mutation).not.toHaveBeenCalled();
- });
-
- it("does nothing without a Convex deployment", async () => {
- convexConfigured = false;
- expect(await (await post({ authorization: "Bearer t", "cf-ipcountry": "ES" })).json()).toEqual({ stored: false });
- });
-
- it("answers 401 when Convex rejects the token", async () => {
- mutation.mockRejectedValue(new Error("Not authenticated"));
- expect((await post({ authorization: "Bearer t", "cf-ipcountry": "ES" })).status).toBe(401);
- });
-});
diff --git a/src/app/api/account/country/route.ts b/src/app/api/account/country/route.ts
deleted file mode 100644
index e4f5bdf..0000000
--- a/src/app/api/account/country/route.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { getCloudflareContext } from "@opennextjs/cloudflare";
-import { api } from "@convex/_generated/api";
-import { normalizeCountry } from "@convex/lib/country";
-import { getConvexClientForToken, readBearerToken } from "@/lib/convexServer";
-
-/**
- * Tells Convex which country the signed-in user is connecting from.
- *
- * This has to be a Cloudflare route: the sign-in callback is served by Convex
- * itself, which never sees Cloudflare's geolocation, whereas every request that
- * reaches this Worker carries it. Only the two-letter country is read — the IP
- * address is neither looked at nor stored.
- *
- * Like the other routes here it acts purely as the caller (their own token, no
- * admin key), and `users.recordCountry` keeps the first value it is given.
- */
-
-function json(body: unknown, status = 200) {
- return Response.json(body, { status, headers: { "cache-control": "no-store" } });
-}
-
-function requestCountry(request: Request): string | null {
- const fromHeader = normalizeCountry(request.headers.get("cf-ipcountry"));
- if (fromHeader) return fromHeader;
-
- try {
- const cf = getCloudflareContext().cf as { country?: string } | undefined;
- return normalizeCountry(cf?.country);
- } catch {
- // Outside the Workers runtime (plain `next dev`, tests): no geolocation.
- return null;
- }
-}
-
-export async function POST(request: Request) {
- const token = readBearerToken(request);
- if (!token) {
- return json({ error: "unauthorized" }, 401);
- }
-
- const convex = getConvexClientForToken(token);
- if (!convex) {
- return json({ stored: false });
- }
-
- const country = requestCountry(request);
- if (!country) {
- return json({ stored: false });
- }
-
- try {
- return json(await convex.mutation(api.users.recordCountry, { country }));
- } catch {
- return json({ error: "unauthorized" }, 401);
- }
-}
diff --git a/src/components/ConvexClientProvider.tsx b/src/components/ConvexClientProvider.tsx
index 405e684..84f7801 100644
--- a/src/components/ConvexClientProvider.tsx
+++ b/src/components/ConvexClientProvider.tsx
@@ -3,7 +3,7 @@
import { api } from "@convex/_generated/api";
import { ConvexAuthProvider, useAuthActions, useAuthToken } from "@convex-dev/auth/react";
import { useConvexAuth, useQuery } from "convex/react";
-import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import { CloudSessionContext, type CloudSession } from "@/hooks/useCloudSession";
import { setAuthToken } from "@/lib/authToken";
@@ -28,25 +28,6 @@ function CloudSessionBridge({ children }: { children: React.ReactNode }) {
setAuthToken(token ?? null);
}, [token]);
- // Accounts that predate country tracking, and new ones, report it once: the
- // Worker reads it from Cloudflare and Convex keeps the first value. Failures
- // are ignored — this is bookkeeping and the next visit simply asks again.
- const hasCountry = useQuery(api.users.hasCountry, isAuthenticated ? {} : "skip");
- const countryAskedFor = useRef(null);
- useEffect(() => {
- const userId = viewer?.id;
- if (!token || !userId || hasCountry !== false || countryAskedFor.current === userId) {
- return;
- }
- countryAskedFor.current = userId;
- void fetch("/api/account/country", {
- method: "POST",
- headers: { authorization: `Bearer ${token}` },
- }).catch(() => {
- countryAskedFor.current = null;
- });
- }, [hasCountry, token, viewer?.id]);
-
const handleSignIn = useCallback(async () => {
await signIn("github", { redirectTo: window.location.href });
}, [signIn]);