Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ function applySessionToCache(session: Session | null): void {
queryClient.setQueryData<Session | null>(SESSION_QUERY_KEY, session);
}

// Relay route that stamps the user's signup country. The Worker reads
// `request.cf.country` server-side (tamper-proof) and writes it to the
// profile — see houston-relay `POST /capture-country`.
const RELAY_CAPTURE_COUNTRY_URL = "https://tunnel.gethouston.ai/capture-country";

/**
* Best-effort signup-country capture, fired once right after sign-in.
* Deliberately fire-and-forget: it is never awaited in the auth path and a
* failure only logs — country capture must never block or fail sign-in.
* (Background analytics ping, not a user-initiated action, so a warning log
* is the right surface rather than a toast.)
*/
function captureSignupCountry(accessToken: string | undefined): void {
if (!accessToken) return;
void fetch(RELAY_CAPTURE_COUNTRY_URL, {
method: "POST",
headers: { authorization: `Bearer ${accessToken}` },
})
.then((res) => {
if (!res.ok) logger.warn(`[auth] country capture returned ${res.status}`);
})
.catch((e) => logger.warn(`[auth] country capture failed: ${e}`));
}

// Where Supabase sends the browser after Google consent. Resolved per
// client at sign-in time by `resolveRedirectUri`:
//
Expand Down Expand Up @@ -265,6 +289,7 @@ export function installDeepLinkListener(): () => void {
return;
}
applySessionToCache(data.session ?? null);
captureSignupCountry(data.session?.access_token);
analytics.track("user_signed_in", { provider: pendingProvider ?? "unknown" });
pendingProvider = null;
logger.info(`[auth] session established (pkce) for ${data.user?.email}`);
Expand Down Expand Up @@ -297,6 +322,7 @@ export function installDeepLinkListener(): () => void {
// cache key directly here makes the UI transition deterministic
// regardless of whether the listener fires.
applySessionToCache(data.session);
captureSignupCountry(data.session?.access_token);
analytics.track("user_signed_in", { provider: pendingProvider ?? "unknown" });
pendingProvider = null;
logger.info(
Expand Down
100 changes: 100 additions & 0 deletions houston-relay/src/capture-country.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Signup-country capture.
//
// We can't report users by country: Google OAuth returns no location and
// the `handle_new_user` Postgres trigger that creates the profile row
// never sees the request IP. This route closes that gap.
//
// The client calls `POST /capture-country` once, right after sign-in,
// with the user's Supabase access token. The Worker reads the country
// Cloudflare already resolved for the request (`request.cf.country`) —
// server-side, so the user can't forge it — and stamps it onto their
// profile with the service role (idempotent: only when still null).
//
// No IP is ever read or stored; `request.cf.country` is pre-resolved by
// Cloudflare. Country only, which keeps this clear of GDPR PII handling.

import type { Env } from "./types";

/**
* Cloudflare sets `request.cf.country` to an ISO 3166-1 alpha-2 code, or a
* sentinel when it can't place the request: `"XX"` (unknown) / `"T1"`
* (Tor). Map those — and anything empty or malformed — to null; otherwise
* return the upper-cased two-letter code.
*/
export function normalizeCountry(raw: string | undefined | null): string | null {
if (!raw) return null;
const c = raw.toUpperCase();
if (c === "XX" || c === "T1") return null;
if (!/^[A-Z]{2}$/.test(c)) return null;
return c;
}

/** Resolve the caller's user id from their Supabase access token by asking
* GoTrue. Returns null on any auth failure (caller maps that to 401). */
async function resolveUserId(env: Env, token: string): Promise<string | null> {
const res = await fetch(`${env.SUPABASE_URL}/auth/v1/user`, {
headers: {
authorization: `Bearer ${token}`,
apikey: env.SUPABASE_SERVICE_ROLE_KEY as string,
},
});
if (!res.ok) return null;
const body = (await res.json()) as { id?: string };
return body.id ?? null;
}

/**
* `POST /capture-country` — stamp the signed-in user's profile with the
* country Cloudflare resolved for this request. Auth: the user's Supabase
* access token in `Authorization: Bearer`. Idempotent and tamper-proof.
*/
export async function handleCaptureCountry(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
}
// Needs both the project URL and the service-role key. If the relay
// isn't configured for capture (e.g. local/miniflare), say so plainly
// rather than pretending success — the client treats this as best-effort.
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
return Response.json({ ok: false, error: "not_configured" }, { status: 503 });
}

const token = (request.headers.get("authorization") ?? "")
.replace(/^bearer\s+/i, "")
.trim();
if (!token) return Response.json({ ok: false, error: "missing_token" }, { status: 401 });

const userId = await resolveUserId(env, token);
if (!userId) return Response.json({ ok: false, error: "invalid_token" }, { status: 401 });

// `request.cf` is loosely typed here; normalizeCountry validates the
// value at runtime, so a cast to string is safe.
const country = normalizeCountry((request.cf?.country ?? null) as string | null);
if (!country) {
// Authenticated, but Cloudflare couldn't place the request. Nothing to
// store — leave the row null rather than writing a bogus value.
return Response.json({ ok: true, stored: false });
}

// Idempotent: the `signup_country=is.null` filter means only the first
// successful call stamps the row; re-logins never overwrite it. The
// service-role key bypasses RLS, so the stored value is the one the
// Worker derived server-side — a client can't PATCH its own country.
const patch = await fetch(
`${env.SUPABASE_URL}/rest/v1/profiles?user_id=eq.${encodeURIComponent(userId)}&signup_country=is.null`,
{
method: "PATCH",
headers: {
apikey: env.SUPABASE_SERVICE_ROLE_KEY,
authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
"content-type": "application/json",
prefer: "return=minimal",
},
body: JSON.stringify({ signup_country: country, country_source: "cf_worker" }),
},
);
if (!patch.ok) {
return Response.json({ ok: false, error: `profiles_patch_${patch.status}` }, { status: 502 });
}
return Response.json({ ok: true, stored: true });
}
3 changes: 3 additions & 0 deletions houston-relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// Route table:
// GET /health liveness probe
// POST /allocate mint {tunnelId, tunnelToken}
// POST /capture-country stamp signup country (cf.country)
// GET /e/:tunnelId/register (Upgrade) desktop engine registers
// GET /e/:tunnelId/v1/ws (Upgrade) mobile engine WebSocket
// * /e/:tunnelId/v1/* proxied engine HTTP
Expand All @@ -17,6 +18,7 @@
// * everything else PWA static assets

import { handleAllocate, verifyTunnelToken } from "./allocate";
import { handleCaptureCountry } from "./capture-country";
import type { Env } from "./types";
import { TunnelRoom } from "./tunnel-do";

Expand Down Expand Up @@ -56,6 +58,7 @@ async function route(
): Promise<Response> {
if (url.pathname === "/health") return Response.json({ ok: true });
if (url.pathname === "/allocate") return handleAllocate(request, env);
if (url.pathname === "/capture-country") return handleCaptureCountry(request, env);

if (segments[0] === "pair" && segments[1]) {
if (request.method === "GET") {
Expand Down
10 changes: 10 additions & 0 deletions houston-relay/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ export interface Env {
ASSETS: { fetch: (req: Request) => Promise<Response> };
RELAY_PUBLIC_HOST: string;

/** Supabase project URL (e.g. https://<ref>.supabase.co). Public —
* set in `[vars]`. Used by `POST /capture-country` to verify the
* caller's access token and PATCH their profile row. */
SUPABASE_URL?: string;

// Secrets (wrangler secret put):
TUNNEL_SHARED_SECRET?: string;
/** Supabase `service_role` key. Lets `POST /capture-country` write the
* Worker-derived country (bypassing RLS) so the value can't be spoofed
* by a client. Set via `wrangler secret put SUPABASE_SERVICE_ROLE_KEY`
* (prod + `--env staging`). Never bundled into the app. */
SUPABASE_SERVICE_ROLE_KEY?: string;
}

// ---------------------------------------------------------------------------
Expand Down
30 changes: 30 additions & 0 deletions houston-relay/test/capture-country.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { normalizeCountry } from "../src/capture-country";

describe("normalizeCountry", () => {
it("upper-cases a valid two-letter code", () => {
expect(normalizeCountry("us")).toBe("US");
expect(normalizeCountry("Mx")).toBe("MX");
expect(normalizeCountry("GB")).toBe("GB");
});

it("maps Cloudflare unknown/Tor sentinels to null", () => {
expect(normalizeCountry("XX")).toBeNull();
expect(normalizeCountry("xx")).toBeNull();
expect(normalizeCountry("T1")).toBeNull();
expect(normalizeCountry("t1")).toBeNull();
});

it("maps empty / missing to null", () => {
expect(normalizeCountry("")).toBeNull();
expect(normalizeCountry(undefined)).toBeNull();
expect(normalizeCountry(null)).toBeNull();
});

it("rejects malformed codes rather than storing junk", () => {
expect(normalizeCountry("USA")).toBeNull();
expect(normalizeCountry("U")).toBeNull();
expect(normalizeCountry("12")).toBeNull();
expect(normalizeCountry("u1")).toBeNull();
});
});
10 changes: 8 additions & 2 deletions houston-relay/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ new_sqlite_classes = ["TunnelRoom"]
# Public host. Used by /allocate to build the `publicHost` field so
# desktops know where to build WSS register URLs + QR codes.
RELAY_PUBLIC_HOST = "tunnel.gethouston.ai"
# Supabase project URL. Public (also baked into the app bundle + present
# in the OAuth redirect URIs). Used by /capture-country to verify the
# caller's access token and PATCH their profile row.
SUPABASE_URL = "https://zfpnlvxazrataiannvtq.supabase.co"

# ---------------------------------------------------------------------------
# Secrets (set via `wrangler secret put`):
# TUNNEL_SHARED_SECRET — HMAC key for tunnel allocation tokens
# TUNNEL_SHARED_SECRET — HMAC key for tunnel allocation tokens
# SUPABASE_SERVICE_ROLE_KEY — privileged key for /capture-country writes
# (prod + `--env staging`)
# ---------------------------------------------------------------------------

# Static assets binding — serves the mobile PWA from the SAME origin
Expand All @@ -58,7 +64,7 @@ workers_dev = false
routes = [
{ pattern = "tunnel-staging.gethouston.ai", custom_domain = true }
]
vars = { RELAY_PUBLIC_HOST = "tunnel-staging.gethouston.ai" }
vars = { RELAY_PUBLIC_HOST = "tunnel-staging.gethouston.ai", SUPABASE_URL = "https://zfpnlvxazrataiannvtq.supabase.co" }

[env.staging.assets]
directory = "../mobile/dist"
Expand Down
26 changes: 26 additions & 0 deletions supabase/migrations/20260621000000_profiles_country.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Country capture on profiles.
--
-- We can't report users by country today: Google OAuth returns no
-- location, and `handle_new_user` (the trigger that creates the row)
-- runs inside Postgres and never sees the request IP. So country is
-- stamped after sign-in by the houston-relay Cloudflare Worker, which
-- reads `request.cf.country` server-side (tamper-proof) and PATCHes
-- the row with the service role. See houston-relay/src/index.ts
-- (POST /capture-country).
--
-- Additive + nullable: backward-compatible, no backfill (no historical
-- IPs exist — auth.audit_log_entries is purged). Country is captured
-- from launch forward only.
--
-- No RLS policy is added for these columns on purpose: clients must NOT
-- be able to write `signup_country` themselves, or the value becomes
-- spoofable. The Worker writes with the service role, which bypasses RLS.

alter table public.profiles
add column if not exists signup_country text,
add column if not exists country_source text;

comment on column public.profiles.signup_country is
'ISO 3166-1 alpha-2 country derived from request.cf.country at first sign-in. Null = unknown/unresolved.';
comment on column public.profiles.country_source is
'Provenance of signup_country: cf_worker | manual.';