UzMap docs

Developer console

The console at console.uzmaps.ndc.uz is where you create an account, issue API keys for the map server, restrict them to your own websites, and read how much traffic they are sending. It is a separate application from the map server with its own database. If the console is down, maps keep rendering: the map server reads a file the console writes, never the console's database (see How keys reach the map server).

Pages, as served by src/app/:

PathWhat it is
/signup, /signin, /forgot, /reset, /verifyAccount pages. Reachable without a session.
/Overview: your organisation, plan, this month's requests, and the list of projects.
/projects/{id}One project: its keys, a 30-day request chart, and the "New key" form.
/usageOrganisation-wide usage for the last 30 days, by product and by key.
/api/healthLiveness check for the container. Not a page.

Every other path redirects to /signin when there is no session cookie.

Accounts#

Signing up#

/signup asks for three things:

FieldRule
EmailTrimmed, lower-cased, must be a valid address.
Password12 to 200 characters. Length is the only rule — no required symbols or digits.
OrganisationOptional. Falls back to the part of your email before the @.

Composition rules are deliberately absent: they push people towards Password1! and towards reusing it. Twelve characters rather than eight because this account controls keys carrying production traffic.

Signing up creates everything an account needs in one database transaction:

CreatedDetails
UserPassword stored as an argon2id hash.
OrganisationNamed from the Organisation field or your email's local part; billingEmail set to your address. Its URL slug is the name lower-cased, non-alphanumerics replaced by -, cut to 32 characters, with -2, -3… appended on collision (uniqueSlug).
MembershipYou, with role OWNER.
ProjectOne project called Default project (slug default), so the first key has somewhere to go.
SubscriptionOn the Free plan, status TRIALING, with the current period ending at the start of next month (UTC).
Audit eventaccount.create.

If the free plan is missing from the database, sign-up fails outright rather than creating an account with no limits (provisionAccount throws).

You are signed in immediately, before confirming your email. A verification email ("Confirm your UzMap account", src/lib/mail.ts) carries a link to /verify?email=…&token=… that is valid for 24 hours and can be used once. Until you open it you can look around, but you cannot create an API key — the overview shows "Confirm your email address to issue API keys. Check your inbox for the link." and the key form's button reads "Confirm your email first". Verification gates the thing that costs money and needs an owner, not the act of signing in.

If you sign up with an address that already has an account, no second account is created: the form answers "Check your email to continue." and the address's owner receives a password-reset email. A genuinely new account answers "Account created. Check your email to confirm the address." and is signed in, so the two outcomes are not identical — what the form avoids is saying outright that an address is taken, which on a developer platform would reveal which companies build on it.

Signing in#

/signin takes email and password. Every failure — unknown address, wrong password — returns the same message, "That email and password do not match.". Unknown addresses burn the same time as a real password check so response time cannot be used to tell them apart.

A session lasts 30 days and is extended on use, roughly once a day. Sessions are rows in the database, not signed tokens, so "Sign out" in the top bar ends the session at once rather than when a token expires.

Resetting a password#

/forgot asks for your email and always answers "If that address has an account, a reset link is on its way.". The link to /reset?email=…&token=… is valid for one hour and can be used once; requesting a new one invalidates the previous one.

Setting the new password (completeReset):

  • signs you out everywhere else — every existing session is deleted and a fresh one is created for the browser you reset from;
  • marks the address verified if it was not already, because completing a reset proves control of the mailbox.

The password rules are the same as at sign-up.

Organisations, roles and projects#

Users belong to organisations, organisations own projects, projects own keys. The extra level is what lets an agency give each client their own project, and a company keep its keys when an employee leaves.

The console currently shows your first organisation membership; there is no organisation switcher and no way to invite other members yet, so in practice each account is the OWNER of the one organisation created at sign-up.

Roles#

RoleCan
MEMBERView the overview, projects, keys (in full) and usage.
ADMINEverything above, plus create projects and create, edit, revoke and restore keys.
OWNEREverything above. The schema reserves this role for billing and deletion.

What each role may do: createProject requires ADMIN; createKey, updateKey, revokeKey and restoreKey require ADMIN; viewing a project requires MEMBER. Ranking is MEMBER < ADMIN < OWNER. A MEMBER does not see the "New project" or "New key" forms at all.

Opening a project or key that belongs to an organisation you are not a member of gives 404, not 403. The membership check is part of the database query, so a foreign id does not resolve at all — and a 403 would confirm to an outsider that the id exists.

Projects#

Create a project from the overview with "New project". The only field is a name of 1 to 80 characters; the slug is derived the same way as an organisation's and is unique within the organisation. Projects are cheap on purpose: separate staging from production, or one client from another, without thinking about it.

The project page (/projects/{id}) shows requests and refusals for the last 30 days, live-key count, an hourly request chart, the key table, and the "New key" form.

API keys#

A key is 32 hexadecimal characters generated from a CSPRNG. The console shows keys in full, always. That is deliberate: a key used in a browser ships inside your page and is public by definition, so hiding it in the console would protect nothing. What protects a browser key is its origin list.

Creating a key#

On a project page, open "New key". You need a verified email and at least ADMIN.

FieldRule
Name1 to 80 characters. Shown in the key table and in the map server's key name as Organisation / Project / Key name.
Allowed originsZero or more origins, one per line or comma-separated. Each must match ^https?://(*.)?host[:port]$ — scheme and host only, optionally a *. wildcard prefix and a port. Trailing slashes are removed; duplicates are dropped. A path (https://example.uz/map) is rejected, because an allowlist entry with a path can never match a request and would fail silently.

Leaving origins empty creates an unrestricted key: anyone who copies it can use it from anywhere. The console says so when it creates one and tags it unrestricted in the key table. Only do this for a key that stays on a server you control.

After the key is saved the console republishes the key file the map server reads. If that publish fails you still get the key, with the warning "The key is saved but the map server has not picked it up yet." — the key exists in the database and the next publish carries it across. A failed publish is never turned into an error, because someone shown an error after a successful create tries again and ends up with two keys.

How origin restriction is enforced#

Enforcement happens on the map server, per request:

RuleDetail
Where the origin comes fromThe Origin header, falling back to Referer. Neither can be set by page JavaScript.
Exact entryhttps://acme.uz matches only that scheme and host (comparison is case-insensitive, trailing / ignored).
Wildcard entryhttps://*.acme.uz matches https://app.acme.uz, https://deep.app.acme.uz and the bare https://acme.uz. It never matches https://acme.uz.evil.com, and never matches http:// — a wildcard does not cross schemes.
No origin at allA request that sends neither header (a non-browser client) is refused for a restricted key. Otherwise stripping headers would bypass the allowlist.

Refusals are JSON with a message field:

SituationStatus
No key sent401
Unknown key401
Key revoked in the console401 — a revoked key is left out of the key file (see below), so the map server treats it as unknown
Key carrying disabled: true in the key file403. Neither the console nor uzmap keys revoke writes this; only a hand-edited file does
Origin not allowed, or no origin for a restricted key403
Rate limit exceeded429
Monthly quota exhausted429, with Retry-After and RateLimit-* headers

Editing, revoking and restoring#

In the key table, ADMIN and above can:

  • Edit — change the name and origins (updateKey). The rules are the same as at creation.
  • Revoke — after a browser confirmation dialog, the key is marked disabled and removed from the map server's key file (revokeKey). The row stays, greyed out and tagged revoked, so usage already recorded against it stays attributable. The warning shown if the publish fails is explicit: the map server may keep accepting the key for a few seconds until the next publish.
  • Restore — re-enables a revoked key with the same value (restoreKey).

Nothing in the console deletes a key outright.

Limits on a key#

Every key carries a per-minute rate limit and a monthly quota to the map server. In the console these always come from your organisation's plan: the rateLimit and monthlyQuota columns on a key are nullable and the "New key" and "Edit" forms do not set them. A null inherits from the plan at publish time, so a plan's limits changing lifts every key on it.

Using a key#

The map server accepts the key either as an X-API-Key header or as a ?key= query parameter. The header keeps the key out of URLs and logs; the query form exists because tile requests are plain URLs.

bash
# Read this key's own usage for the last 7 days
curl -H "X-API-Key: $KEY" "https://uzmaps.ndc.uz/api/usage?days=7"

The @uzmaps/engine package takes the key as the apiKey option and appends key= to every request it makes to the map server, and only to those. The project page shows a script-tag snippet and links to the browser SDK documentation for the details.

Usage#

The console holds no usage data of its own. For every live key it asks the map server GET /api/usage, authenticating with that key, and adds the results up. A key can read only its own usage, so the console needs no master credential that could read everyone's traffic. Reading usage is not itself billable and does not count against your quota.

What each page shows#

PageWindowFigures
Overview /Current calendar month, UTC (the server's default when no days is given)Requests this month, percentage of the plan's quota, included quota, live keys, refused.
Project /projects/{id}Rolling 30 daysRequests, refused, live keys, hourly chart, and requests per key in the key table.
Usage /usageRolling 30 daysRequests, refused, "Remaining this month", hourly chart, breakdown by product, breakdown by key.

"Remaining this month" on /usage is the plan quota minus the rolling 30-day total, not minus the calendar-month total the quota actually resets on. Treat it as an approximation near the start of a month; the overview's "Requests this month" is the calendar-month figure.

Refused counts requests that presented a known key and were turned away — wrong origin, no origin for a restricted key, rate limit, quota — and is labelled "Not billable". A key revoked in the console is unknown to the map server, so requests using it are not counted at all. On a server running without --require-key nothing is refused, so nothing is counted as refused.

If the map server does not answer for one of your keys, that key shows as unavailable and the page says the totals are a lower bound rather than failing.

Products#

Usage is broken down by product, which is the unit pricing is expressed in:

ProductCovers
tilesVector tiles, glyphs, sprites, styles, terrain
searchSearch, autocomplete, suggest
geocodingReverse geocoding, lookup
placesPlace details, nearby, categories, geometry, photos
routingRoutes
matrixDistance matrices
isochroneIsochrones
staticStatic map images
otherAnything gated but unclassified

Plans#

Plans are rows in the plans table, seeded by migration (prisma/migrations/20260905150000_seed_plans/migration.sql; the same values in prisma/seed.ts). Prices are stored in tiyin; 100 tiyin = 1 so'm.

PlanMonthly quotaRate limitPrice per monthOverage per 1 000 requestsListed publicly
Free (free)25 000300 / min0none — refused at the limityes
Startup (startup)500 000600 / min300 000 so'm80 so'myes
Business (business)5 000 0003 000 / min1 500 000 so'm50 so'myes
Enterprise (enterprise)unlimited10 000 / minnegotiatednoneno — assigned, not chosen

Every new account starts on Free. There is no page for changing plan, and payments and invoicing are not built (README status, apps/console/README.md). The overage prices above are recorded on the plan but nothing charges them yet: today the map server refuses requests at the quota for every plan that has one.

Rate limit is a per-key token bucket that refills at the plan's rate and allows a burst of up to one minute's worth. It smooths traffic; it is not an allowance.

Monthly quota is billable requests per calendar month, UTC. It is consulted only for a request that already passed the key, origin and rate checks, and only for billable products. It resets on the 1st.

Subscription status#

StatusEffect on your keys
TRIALING, ACTIVEServed.
PAST_DUEStill served — a failed payment does not break a production site over a retryable error.
CANCELEDEvery key in the organisation is left out of the map server's key file and stops working.

For operators#

Data model#

prisma/schema.prisma, PostgreSQL, Prisma 7 with the client generated into src/generated/prisma.

ModelTableNotes
UseruserspasswordHash is nullable for future OAuth accounts.
Account, Session, VerificationTokenaccounts, sessions, verification_tokensAuth.js table shapes, kept so adding OAuth later is a migration rather than a rewrite. Sessions and tokens store only the SHA-256 of the value in the cookie or link.
Organizationorganizationsslug unique; billingEmail, legalName, taxId for invoices.
Membershipmemberships(userId, orgId) unique; role is the Role enum OWNER, ADMIN, MEMBER.
Projectprojects(orgId, slug) unique. Deleting a project cascades to its keys.
ApiKeyapi_keyskey stored in the clear and unique; origins String[]; rateLimit Int? and monthlyQuota BigInt? inherit from the plan when null; disabled is the revoke flag.
PlanplansmonthlyQuota BigInt (0 = unlimited), rateLimit Int (default 600), priceTiyin BigInt, overagePerThousandTiyin BigInt? (null = refuse at the limit), isPublic.
SubscriptionsubscriptionsOne per organisation. SubscriptionStatus enum TRIALING, ACTIVE, PAST_DUE, CANCELED.
Invoiceinvoices(orgId, period) unique; period is the first instant of the billed month in UTC; amounts in tiyin; includedRequests and overageRequests snapshotted at close. InvoiceStatus enum DRAFT, OPEN, PAID, VOID, UNCOLLECTIBLE. Nothing writes invoices yet.
AuditEventaudit_eventsaction, target, meta, ip, userAgent; userId is set null if the user is deleted.

Money is BigInt tiyin throughout; the formatter converts to so'm with integer arithmetic.

Audit actions written today: account.create; project.create, project.delete; key.create, key.update, key.revoke, key.restore. Audit rows carry a masked key (maskKey in src/lib/apikey.ts, first six and last four characters), never the whole credential.

How keys reach the map server#

console ──writes──▶ /etc/uzmap/keys.json ──polls──▶ map server

   └── postgres (accounts, plans, invoices)

The ApiKey table is the source of truth. src/lib/keys.ts projects it into keys.json, the file the map server already reads for keys issued by its own CLI.

buildProjection() selects every key with disabled = false, joins its project, organisation, subscription and plan, and emits one entry per key. Entries match the Go apikey.Key struct field for field:

FieldValue
keyThe key.
name"<org name> / <project name> / <key name>".
originsThe key's origins; omitted when empty.
rate_limitThe key's rateLimit, or the plan's when null; omitted when 0.
monthly_quotaThe key's monthlyQuota, or the plan's when null; omitted when 0 (unlimited). Converted from BigInt to a JSON number.
created_atISO 8601.

Rules applied during projection:

  • Revoked keys are omitted, not written with disabled: true, so a stale reader cannot re-enable one and the file stays proportional to live keys.
  • Keys of an organisation with no subscription or a CANCELED one are omitted. PAST_DUE is still published.
  • The projection is complete every time, never a diff. A diff that went wrong could leave a revoked key live, which is the worst failure this system can have.

publishKeys() writes the JSON to a temporary file in the same directory (rename is atomic only within one filesystem, and /tmp is usually a different one in a container), sets mode 0640, and renames it over UZMAPS_KEYS_FILE (default /etc/uzmap/keys.json). The map server therefore sees either the old set or the new one, never a partial file. It returns the number of keys published.

publishKeysSafely() wraps that and never throws: the database is correct either way and the next publish repairs the file. It runs after createKey, updateKey, revokeKey, restoreKey and deleteProject.

A real projection, as shipped in services/server/internal/apikey/testdata/console-projection.json (generated by scripts/crosscheck.ts with the key values and timestamps replaced):

json
[
  {
    "key": "1111111111111111aaaaaaaaaaaaaaaa",
    "name": "Acme / Website / browser key",
    "created_at": "2026-09-05T12:00:00.000Z",
    "origins": ["https://a.uz", "https://*.b.uz"],
    "rate_limit": 300,
    "monthly_quota": 25000
  },
  {
    "key": "2222222222222222bbbbbbbbbbbbbbbb",
    "name": "Acme / Website / server key",
    "created_at": "2026-09-05T12:00:01.000Z",
    "rate_limit": 120,
    "monthly_quota": 9999
  }
]

The Go test TestConsoleProjectionLoads loads that file and checks the origins, limits and overrides decode. Neither build checks the other, so this fixture is what catches a renamed field before it breaks every customer's keys.

How the map server picks it up#

The server is started with --keys=/etc/uzmap/keys.json and, in production, --require-key=${UZMAPS_REQUIRE_KEY:-false} (deploy/docker-compose.yml; flags in services/server/cmd/uzmap/main.go). Without --require-key it loads and meters keys but serves every request. It polls the file's mtime and size every --key-reload interval, default 5 seconds, and swaps in the new set. Polling rather than inotify because the file arrives over a bind mount, where inotify does not see writes made on the host.

On reload, rate-limit buckets survive for keys still present — a customer does not get a fresh burst every time someone else issues a key — and are dropped for keys that disappeared. A malformed or half-written file is logged and ignored, keeping the last good set.

Both containers mount the same directory: the console read-write at /etc/uzmap, the server read-only at /etc/uzmap/keys.json. Both run as uid 100 / gid 101: the console's image creates its user with those ids explicitly (apps/console/Dockerfile) to match the uzmap user the server image creates with Alpine's default ids (services/server/Dockerfile pins none; deploy/docker-compose.yml and docs/api-keys.md document it as 100:101), because the file is 0640 and, in the words of the source, group membership is not enough — they must be one user.

The map server never reads the console database#

Postgres sits on the stack's internal network only, and the server container is given no DATABASE_URL. The server's only input from the console is keys.json; the only data flowing the other way is /api/usage, which the console reads one key at a time with that key's own credential. A console outage, a migration gone wrong or a full disk on the database host leaves tile and API requests unaffected.

Configuration#

Variables the console code reads:

VariableRead inMeaning
DATABASE_URLsrc/lib/db.ts, prisma.config.tsPostgreSQL connection string. Missing at import time is a hard error.
UZMAPS_KEYS_FILEsrc/lib/keys.tsWhere to write the projection. Default /etc/uzmap/keys.json; set to something writable in development.
NEXT_PUBLIC_MAP_URLsrc/lib/usage.ts, src/app/projects/[id]/page.tsxThe map server usage is read from. Default https://uzmaps.ndc.uz.
AUTH_URLsrc/lib/mail.tsBase URL for verification and reset links. Default https://console.uzmaps.ndc.uz.
MAIL_API_URL, MAIL_API_TOKENsrc/lib/mail.tsHTTP mail endpoint (POST, Authorization: Bearer, JSON body from, to, subject, text). Unset: mail is logged, not sent, and the link is shown on screen. In production this is also warned about on every send.
MAIL_FROMsrc/lib/mail.tsSender. Default UzMap <no-reply@uzmaps.ndc.uz>.

The session cookie is uzmaps.session, httpOnly, SameSite=Lax, Secure in production.

Health and deployment#

GET /api/health runs SELECT 1 and answers {"ok":true}, or 503 with {"ok":false,"error":…} when the database is unreachable. A console that cannot reach Postgres can serve nothing useful, so it is reported unhealthy on purpose. The container's HEALTHCHECK hits it every 30 seconds (apps/console/Dockerfile).

Migrations run in the container entrypoint before the server starts, under set -e, so a failed migration stops the container instead of serving against a half-migrated schema. The plans are seeded by migration for the same reason: an account cannot be provisioned without the free plan.

Running and checking it locally#

From apps/console:

bash
npm install
cp .env.example .env.local        # fill in DATABASE_URL; point UZMAPS_KEYS_FILE somewhere writable
npx prisma migrate dev
npm run dev                       # http://localhost:3100
npm test                          # password and token handling, no database needed
npm run smoke                     # provisioning, projection and tenant isolation, against real Postgres

scripts/smoke.ts creates and deletes its own accounts. It writes to whatever DATABASE_URL points at — never run it against production.

Map data © OpenStreetMap contributors, licensed under the ODbL. © 2026 National Development Community.