Location
geoGet a coarse location hint from the declared platform's headers for sensible defaults, and geocode addresses through network, never for access decisions.
geoFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
geo gives you a coarse place from the request, for defaults such as a currency, and turns a typed address into a point. The place is a hint and never decides access.
A place guessed from a network address is wrong for people on a VPN or roaming, so using it to allow or deny someone locks out real people and lets attackers in. MainSDK refuses it in policy, access and auth.require.
At a glance
geo:
driver: platform-headers
edge: cloudfront
key: secret:MAPBOX_TOKEN
cache-ttl: 30d
timeout: 2simport { geo } from "mainsdk/geo";
const hint = geo.of(request); // a hint for defaults, never for access
const place = await geo.geocode(address); // a Place, or null when the provider is downapp/checkout/geo.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, localGeoDriver } from "@mainsdk/testing";
import { geo } from "mainsdk/geo";
describe("geo", () => {
it("geo AC4 geocodes from the table", async () => {
const app = createTestApp({ yaml: "schema: 1\ngeo:\n driver: local\n" });
app.socialPorts.geo = localGeoDriver({
"1 main st, springfield": { lat: 39.78, lon: -89.65, precision: "address", country: "US" },
});
assert.equal((await geo.geocode("1 Main St, Springfield"))?.country, "US");
assert.equal(await geo.geocode("Nowhere Lane"), null);
});
});Calls
geo.of(req)geo.geocode(address, { country? })createGeo(settings)parseGeo(raw)geoHosts(config)geoEdge(config)
Guarantees
Whatever the driver.
- Location is a hint, never used for access
- The hint never holds an IP address and reads only the declared platform's headers
- An address is personal data, sent only to a host that serves the tenant's region and cached per tenant under a digest
- A geocoding provider that is down gives null and the app serves on
Drivers
4 drivers. Select one to see its line in main.yaml.
geo:
driver: platform-headersChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-GEO-0002 | Checkmainsdk check, before deploy | geo.of is used in access code: in a file under a folder named access, policy, policies or auth, or its call, or a variable it was put in, sits in the arguments of auth.require, access.check, access.decide or policy.decide |
MAIN-GEO-0005 | Checkmainsdk check, before deploy | A geo key is wrong (runtime: 400): an unknown driver or edge, a provider driver with no key: secret:NAME, or geo.of used with no declared edge |
MAIN-GEO-0001 | RuntimeError responses and logs | See the spec |
MAIN-GEO-0003 | RuntimeError responses and logs | See the spec |
MAIN-GEO-0004 | RuntimeError responses and logs | See the spec |
MAIN-GEO-0006 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| geo@1 contract | Phase 2 | 0.7 | geo#contract |
| Request location hint from the declared platform | Phase 2 | 0.7 | geo#hint |
| Guard that keeps a hint out of policy | Phase 2 | 0.7 | geo#guard |
| Geocoding through network | Phase 2 | 0.7 | geo#geocode |
| Platform headers | Phase 2 | 0.7 | geo#drivers |
Quick start
schema: 1
deploy:
target: vercel
geo:
driver: platform-headersimport { geo } from "mainsdk/geo";
export async function GET(request: Request) {
const hint = geo.of(request); // { kind, source, country?, region?, city?, timezone? }
return Response.json({ currency: hint.country === "IN" ? "INR" : "USD" });
}geo.of() reads the headers of the one platform your app declares, here Vercel. It makes no call, never throws for a bad header, and returns an empty hint when the headers are missing.
Configuration
geo:
driver: platform-headers # platform-headers | mapbox | google-maps | local
edge: cloudfront # vercel | cloudflare | fly | cloudfront; defaults to deploy.target
key: secret:MAPBOX_TOKEN # mapbox and google-maps only
cache-ttl: 30d # 0 turns the cache off
timeout: 2s # one geocode call; the answer is null after this| Key | Type | Default | What it does |
|---|---|---|---|
driver | string | platform-headers | The hint always comes from the headers. mapbox and google-maps add geocoding. local is the testing kit's table |
edge | string | deploy.target when it is vercel, cloudflare or fly | The platform whose headers geo.of reads. Set it when you run on Docker or Node behind CloudFront or another edge. With none declared the hint is empty |
key | secret reference | none | Required for mapbox and google-maps, as secret:NAME. Never put the key itself in main.yaml |
cache-ttl | duration | 30d | How long a found place is cached per tenant. A missing address is cached for one hour at most. 0 turns the cache off, and Mapbox is then asked for a temporary result |
timeout | duration | 2s | The longest one geocode call may take. After it the answer is null |
The headers each platform sets:
| Edge | Country | Region | City | Time zone |
|---|---|---|---|---|
vercel | x-vercel-ip-country | x-vercel-ip-country-region | x-vercel-ip-city | x-vercel-ip-timezone |
cloudflare | cf-ipcountry | cf-region-code | cf-ipcity | cf-timezone |
cloudfront | cloudfront-viewer-country | cloudfront-viewer-country-region | cloudfront-viewer-city | cloudfront-viewer-time-zone |
fly | x-mainsdk-country | x-mainsdk-region | x-mainsdk-city | x-mainsdk-timezone |
Cloudflare sends region, city and time zone only with its visitor location headers turned on. Fly sends no country header itself, so set x-mainsdk-country (and the others) from your own GeoIP step. Headers of every other platform are ignored, so a visitor cannot give your app a place by sending them.
Building it by hand
The SDK builds geo for you from main.yaml. Call these only when you wire it by hand, for example in a test or a custom runtime.
| Name | What it is |
|---|---|
parseGeo(raw) | Reads the geo section (kebab-case keys) into a config. An unknown driver or edge, or a bad duration, fails with MAIN-GEO-0005. |
createGeo(settings) | Builds the interface over a geo@1 driver. Settings: config, edge, driver, store (the cache, tenant-scoped), tenant (the id and region of the running tenant) and log. Optional: driverConfig (the key, already read), fetch (the network layer's) and now. |
geoHosts(config) | The hosts the chosen driver calls, so the network allow list can include them. |
geoEdge(config) | The platform the config declares, or undefined. |
What you get
A GeoHint for defaults, and a Place for an address. The hint never holds an IP address, and each field is checked: the country is two letters (not XX or T1), the region a short code, the city cleaned and cut at 64 characters, the time zone an IANA name. A value that fails is left out.
type GeoHint = {
readonly kind: "geo-hint";
readonly source: "vercel" | "cloudflare" | "fly" | "cloudfront" | "none";
readonly country?: string; // ISO 3166-1 alpha-2, upper case
readonly region?: string;
readonly city?: string;
readonly timezone?: string; // IANA
};
type Place = {
lat: number; lon: number;
precision: "address" | "street" | "city" | "region" | "country";
country?: string; region?: string; city?: string; label?: string;
};An address is personal data. geo.geocode() sends it through network only to a provider host that serves the tenant's region, caches the place per tenant under a digest (never the address), and never writes the address to a log, an audit record, an error or a metric.
Examples
Geocode a delivery address
"use server";
import { geo } from "mainsdk/geo";
export async function placeFor(address: string) {
const place = await geo.geocode(address, { country: "IN" });
return place ?? { precision: "unknown" }; // null: not found, or the provider is down
}geo.geocode() returns null when nothing matches and when the provider is down, so checkout keeps working with the typed address. It fails with MAIN-GEO-0003 for an empty address, one over 512 characters or a driver with no geocoder, and with MAIN-GEO-0004 when the driver has no host for the tenant's region.
Suggest a market in a Hono API
import { Hono } from "hono";
import { geo } from "mainsdk/geo";
const app = new Hono();
app.get("/suggested-market", (c) => {
const { country } = geo.of(c.req.raw);
return c.json({ suggested: country ?? null });
});
export default app;The person still chooses. Market overlays resolve from the user's and organisation's context, not from the hint.
Errors and checks
| Code | Where | Meaning and fix |
|---|---|---|
MAIN-GEO-0001 | runtime | A GeoHint was passed into a policy, access or auth.require context (400). Decide on the person, the device and the resource |
MAIN-GEO-0002 | check | geo.of is used in access code. Country rules for protected files belong in protect policy |
MAIN-GEO-0003 | runtime | An empty or oversized address, or a driver with no geocoder (400). Set geo.driver to mapbox or google-maps |
MAIN-GEO-0004 | runtime | The driver has no host that serves the tenant's region, so the address is not sent (400). Use a driver that serves the region |
MAIN-GEO-0005 | check, runtime | An unknown driver or edge, a provider driver with no key: secret:NAME, geo.of with no declared platform, or geo.geocode with no geocoder |
MAIN-GEO-0006 | runtime | A provider could not answer. geo.geocode catches it, logs one line without the address and returns null |
See check codes and errors.
Swapping drivers
geo:
driver: mapbox # was platform-headers
key: secret:MAPBOX_TOKENYour geo.of() and geo.geocode() calls stay the same. Each driver serves the regions it declares: Mapbox serves us, Google Maps Platform us and eu. For a tenant in another region the call fails with MAIN-GEO-0004 instead of sending the address. See Mapbox and Google Maps Platform.
Testing
createTestApp installs the local driver, which geocodes from a table with no network. Set geo.driver: local in your test settings, and give a test its own table:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, localGeoDriver } from "@mainsdk/testing";
import { geo } from "mainsdk/geo";
describe("geo", () => {
it("geo AC4 geocodes from the table", async () => {
const app = createTestApp({ yaml: "schema: 1\ngeo:\n driver: local\n" });
app.socialPorts.geo = localGeoDriver({
"1 main st, springfield": { lat: 39.78, lon: -89.65, precision: "address", country: "US" },
});
assert.equal((await geo.geocode("1 Main St, Springfield"))?.country, "US");
assert.equal(await geo.geocode("Nowhere Lane"), null);
});
});fakeGeoProvider("mapbox") and fakeGeoProvider("google-maps") are fake provider servers for the real drivers: they answer in each provider's response shape, record every request and can be set to down, refused, garbage or hang to test the null path. Every driver, yours included, runs the geo@1 conformance suite with runDriverConformance(driver).
Security and privacy
- Location is a hint, never used for access. Passing a
GeoHintinto a policy,accessorauth.requirefails withMAIN-GEO-0001, andmainsdk checkflagsgeo.ofin access code. The guard cannot follow a country you copy into a plain string, so do not. - The hint never holds an IP address, and
geo.of()reads only the declared platform's headers. - Addresses you geocode are personal data: sent only to a host that serves the tenant's region, cached under a digest, never logged. Label the fields that store them in
db. - Precise location needs consent.
geohandles a coarse place and typed addresses, not a device's coordinates.
FAQ
Can I block a country with geo? No. Use protect policy for country rules on protected files, and network and policy for rules you enforce.
Does geo choose the user's language? No. The locale comes from the user's choice first; see Languages and formats.
How do I count where my app is used? With analytics: analytics.query({ by: ["country"] }).
Why is my hint empty? No platform is declared (deploy.target or geo.edge), or the platform did not send the header. mainsdk check reports the first.
Next
Was this page helpful?