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/:
| Path | What it is |
|---|---|
/signup, /signin, /forgot, /reset, /verify | Account 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. |
/usage | Organisation-wide usage for the last 30 days, by product and by key. |
/api/health | Liveness 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:
| Field | Rule |
|---|---|
| Trimmed, lower-cased, must be a valid address. | |
| Password | 12 to 200 characters. Length is the only rule — no required symbols or digits. |
| Organisation | Optional. 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:
| Created | Details |
|---|---|
| User | Password stored as an argon2id hash. |
| Organisation | Named 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). |
| Membership | You, with role OWNER. |
| Project | One project called Default project (slug default), so the first key has somewhere to go. |
| Subscription | On the Free plan, status TRIALING, with the current period ending at the start of next month (UTC). |
| Audit event | account.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#
| Role | Can |
|---|---|
MEMBER | View the overview, projects, keys (in full) and usage. |
ADMIN | Everything above, plus create projects and create, edit, revoke and restore keys. |
OWNER | Everything 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.
| Field | Rule |
|---|---|
| Name | 1 to 80 characters. Shown in the key table and in the map server's key name as Organisation / Project / Key name. |
| Allowed origins | Zero 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:
| Rule | Detail |
|---|---|
| Where the origin comes from | The Origin header, falling back to Referer. Neither can be set by page JavaScript. |
| Exact entry | https://acme.uz matches only that scheme and host (comparison is case-insensitive, trailing / ignored). |
| Wildcard entry | https://*.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 all | A 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:
| Situation | Status |
|---|---|
| No key sent | 401 |
| Unknown key | 401 |
| Key revoked in the console | 401 — 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 file | 403. Neither the console nor uzmap keys revoke writes this; only a hand-edited file does |
| Origin not allowed, or no origin for a restricted key | 403 |
| Rate limit exceeded | 429 |
| Monthly quota exhausted | 429, 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 taggedrevoked, 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.
# 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#
| Page | Window | Figures |
|---|---|---|
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 days | Requests, refused, live keys, hourly chart, and requests per key in the key table. |
Usage /usage | Rolling 30 days | Requests, 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:
| Product | Covers |
|---|---|
tiles | Vector tiles, glyphs, sprites, styles, terrain |
search | Search, autocomplete, suggest |
geocoding | Reverse geocoding, lookup |
places | Place details, nearby, categories, geometry, photos |
routing | Routes |
matrix | Distance matrices |
isochrone | Isochrones |
static | Static map images |
other | Anything 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.
| Plan | Monthly quota | Rate limit | Price per month | Overage per 1 000 requests | Listed publicly |
|---|---|---|---|---|---|
Free (free) | 25 000 | 300 / min | 0 | none — refused at the limit | yes |
Startup (startup) | 500 000 | 600 / min | 300 000 so'm | 80 so'm | yes |
Business (business) | 5 000 000 | 3 000 / min | 1 500 000 so'm | 50 so'm | yes |
Enterprise (enterprise) | unlimited | 10 000 / min | negotiated | none | no — 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#
| Status | Effect on your keys |
|---|---|
TRIALING, ACTIVE | Served. |
PAST_DUE | Still served — a failed payment does not break a production site over a retryable error. |
CANCELED | Every 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.
| Model | Table | Notes |
|---|---|---|
User | users | passwordHash is nullable for future OAuth accounts. |
Account, Session, VerificationToken | accounts, sessions, verification_tokens | Auth.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. |
Organization | organizations | slug unique; billingEmail, legalName, taxId for invoices. |
Membership | memberships | (userId, orgId) unique; role is the Role enum OWNER, ADMIN, MEMBER. |
Project | projects | (orgId, slug) unique. Deleting a project cascades to its keys. |
ApiKey | api_keys | key stored in the clear and unique; origins String[]; rateLimit Int? and monthlyQuota BigInt? inherit from the plan when null; disabled is the revoke flag. |
Plan | plans | monthlyQuota BigInt (0 = unlimited), rateLimit Int (default 600), priceTiyin BigInt, overagePerThousandTiyin BigInt? (null = refuse at the limit), isPublic. |
Subscription | subscriptions | One per organisation. SubscriptionStatus enum TRIALING, ACTIVE, PAST_DUE, CANCELED. |
Invoice | invoices | (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. |
AuditEvent | audit_events | action, 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:
| Field | Value |
|---|---|
key | The key. |
name | "<org name> / <project name> / <key name>". |
origins | The key's origins; omitted when empty. |
rate_limit | The key's rateLimit, or the plan's when null; omitted when 0. |
monthly_quota | The key's monthlyQuota, or the plan's when null; omitted when 0 (unlimited). Converted from BigInt to a JSON number. |
created_at | ISO 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
CANCELEDone are omitted.PAST_DUEis 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):
[
{
"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:
| Variable | Read in | Meaning |
|---|---|---|
DATABASE_URL | src/lib/db.ts, prisma.config.ts | PostgreSQL connection string. Missing at import time is a hard error. |
UZMAPS_KEYS_FILE | src/lib/keys.ts | Where to write the projection. Default /etc/uzmap/keys.json; set to something writable in development. |
NEXT_PUBLIC_MAP_URL | src/lib/usage.ts, src/app/projects/[id]/page.tsx | The map server usage is read from. Default https://uzmaps.ndc.uz. |
AUTH_URL | src/lib/mail.ts | Base URL for verification and reset links. Default https://console.uzmaps.ndc.uz. |
MAIL_API_URL, MAIL_API_TOKEN | src/lib/mail.ts | HTTP 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_FROM | src/lib/mail.ts | Sender. 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:
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 Postgresscripts/smoke.ts creates and deletes its own accounts. It writes to whatever DATABASE_URL points at — never run it against production.