Case study

Разбор проекта

GeoLock.Site

Location-gated dead drops: content pinned to a place, discoverable and readable only by someone standing there. No links exist — presence is the entire access mechanism, decided by a PostGIS predicate.

Геоточки обмена: контент, привязанный к месту, — обнаруживаемый и читаемый только тем, кто там стоит. Ссылок не существует: присутствие — весь механизм доступа, решаемый предикатом PostGIS.

PostgreSQL 16PostGISFastifyReactViteLeafletDockernginx

01 · Context

01 · Контекст

The web can gate by almost anything — except place

Content on the web can be gated by identity, by payment, by time, by invitation. Almost never by place. GeoLock makes physical presence the key: a drop is pinned to a point on the map with an unlock radius, and its content reveals itself only to people who are physically inside that zone, browsing what's nearby. There is no link to send — presence itself is the access mechanism.

That constraint is the point, not an omission. The use cases are practical, not playful: a county emergency service posting a wildfire warning that only residents and travelers currently in the affected area will ever see; a festival or fair broadcasting schedule changes to the people actually on the grounds; a traveler leaving a message findable only by someone standing at a specific meeting point, useful precisely when a phone is lost or a network is unreliable overseas. In each case, remote visibility would be a liability — the information is only relevant, or only safe, where it's tied to place.

What makes it interesting as a systems exercise is where the gate lives: not in application code comparing distances, but in the database, as a spatial predicate. Discovery is a SQL query that either returns nearby drops or doesn't.

The build ran as fifteen implementation tickets executed strictly in sequence — a deliberate constraint to practice working from a fixed backlog rather than improvising scope mid-flight.

Веб умеет ограничивать доступ почти по чему угодно — кроме места

Доступ к контенту в вебе ограничивают по личности, по оплате, по времени, по приглашению. Почти никогда — по месту. GeoLock делает физическое присутствие ключом: точка обмена прикреплена к точке на карте с радиусом открытия, и её контент показывается только тем, кто физически находится внутри этой зоны и просматривает, что рядом. Отправлять нечего — сама ссылка отсутствует, доступом служит присутствие.

Это ограничение — суть, а не недоработка. Сценарии практические, не игровые: служба экстренного оповещения округа публикует предупреждение о лесном пожаре, которое увидят только жители и проезжающие, реально находящиеся в зоне; фестиваль или ярмарка рассылает изменения расписания тем, кто уже на месте; путешественник оставляет сообщение, находимое только тем, кто стоит в конкретной точке встречи, — полезно именно тогда, когда телефон потерян или сеть за границей ненадёжна. В каждом случае удалённая видимость была бы уязвимостью: информация актуальна или безопасна только там, где она привязана к месту.

Как системное упражнение проект интересен тем, где живёт замок: не в коде приложения, сравнивающем расстояния, а в базе данных — как пространственный предикат. Обнаружение — это SQL-запрос, который либо возвращает точку обмена поблизости, либо нет.

Разработка шла как пятнадцать тикетов, выполненных строго последовательно, — намеренное ограничение, чтобы отработать работу по зафиксированному бэклогу вместо импровизации со скоупом на ходу.

02 · Requirements & constraints

02 · Требования и ограничения

What it must do — and under what conditions

FRCreate a drop: pick a point on the map, set an unlock radius, choose an expiration, give it a title (its displayed identifier), optionally set a drop key as a password, attach content. Discover drops: no links exist anywhere — a user is shown only the drops currently near their reported position, browsed by title. Opening a titled drop from that nearby list returns content only if the position check passes and, if a key is set, the correct key is supplied. Search for places by name when placing a drop; a create flow that works on a phone.
NFRThe unlock decision is server-side — the client never receives content before the check passes. Drops outside a user's current area must be unreachable and non-enumerable, not merely hidden by the UI. The mapping stack must be free of API keys and billing. The whole flow must work outdoors on a phone, where GPS accuracy is a real variable, not a lab constant.
ConstraintsOne shared VPS hosting other Softwarean projects; PostGIS runs in Docker; nginx in front of the API.

Что система обязана делать — и в каких условиях

FRСоздать точку обмена: выбрать точку на карте, задать радиус открытия, срок действия, название (отображаемый идентификатор) и опционально ключ-пароль, прикрепить контент. Обнаружение точек обмена: ссылок нигде не существует — пользователю показываются только точки, находящиеся рядом с его текущей позицией, по названию. Открытие названной точки обмена из этого списка возвращает контент, только если пройдена проверка позиции и, если задан ключ, введён верный ключ. Поиск мест по названию при размещении точки обмена; сценарий создания, работающий с телефона.
NFRРешение об открытии принимается на сервере — клиент не получает контент до прохождения проверки. точки обмена вне текущей области пользователя должны быть недостижимы и неперечислимы, а не просто скрыты в UI. Картографический стек без API-ключей и биллинга. Весь сценарий должен работать на улице с телефона, где точность GPS — реальная переменная, а не лабораторная константа.
ОграниченияОдин общий VPS с другими проектами Softwarean; PostGIS — в Docker; nginx перед API.

03 · Architecture

03 · Архитектура

The gate lives in the database

A React + Leaflet SPA served by nginx; a Fastify API behind the same nginx; PostGIS in a Docker container as the single store — and the single decision-maker. The browser talks to two external services directly: CartoDB Voyager for map tiles and Nominatim for place search. Neither ever sees drop content; they only serve the map. The API receives the reported position and delegates the inside-or-outside question to PostGIS.

Замок живёт в базе данных

React + Leaflet SPA раздаётся через nginx; Fastify-API за тем же nginx; PostGIS в Docker-контейнере — единственное хранилище и единственный «принимающий решение». Браузер напрямую обращается к двум внешним сервисам: CartoDB Voyager за тайлами карты и Nominatim за поиском мест. Ни один из них не видит контент точек обмена — они обслуживают только карту. API получает заявленную позицию и делегирует вопрос «внутри или снаружи» самому PostGIS.

CartoDB Voyager map tiles Nominatim geocoding Browser React + Leaflet nginx static + proxy Fastify API Node.js PostGIS Docker · ST_Within gate HTTPS /api unlock query
Container view. Dashed lines are browser-direct calls to keyless external services; drop content only ever travels the solid path.
Контейнерная диаграмма. Пунктир — прямые обращения браузера к внешним сервисам без ключей; контент точек обмена ходит только по сплошному пути.

04 · Key decisions

04 · Ключевые решения

Four decisions that shaped the system

Digests of the ADRs that mattered. Each one names what the decision cost — a trade-off you don't acknowledge is a trade-off you didn't see.

Четыре решения, определившие систему

Выжимки из значимых ADR. В каждой названа цена решения — трейд-офф, который не назван, это трейд-офф, который не был замечен.

The gate is a spatial predicate, not app code

Замок — пространственный предикат, а не код приложения

Options
Haversine distance computed in Node · geohash prefix comparison · PostGIS with geography types.
Chosen
PostGIS. The unlock check is ST_Within evaluated inside the database.
Why
Geometry on a sphere is exactly the kind of code that looks right and is subtly wrong — boundary behavior, meters-vs-degrees, projection. PostGIS makes the gate declarative: one predicate that is testable from psql with known coordinates, independent of any application code.
What it cost
A heavyweight dependency — a Dockerized spatial database — for what is, day to day, one query. It also welds the app to Postgres; there is no casual migration away.
Варианты
Формула гаверсинуса в Node · сравнение префиксов geohash · PostGIS с типами geography.
Выбрано
PostGIS. Проверка открытия — ST_Within, вычисляемый внутри базы данных.
Почему
Геометрия на сфере — ровно тот код, который выглядит правильным и тонко ошибается: поведение на границе, метры против градусов, проекции. PostGIS делает замок декларативным: один предикат, проверяемый из psql на известных координатах, независимо от кода приложения.
Цена
Тяжёлая зависимость — пространственная БД в Docker — ради, по сути, одного запроса. И жёсткая привязка к Postgres: лёгкой миграции отсюда нет.

Trusting the browser's Geolocation API

Доверие Geolocation API браузера

Options
Trust client-reported coordinates · cross-check against IP geolocation · hardware attestation (not available to the web platform).
Chosen
Trust the client, and say so. Coordinates are whatever the browser reports.
Why
The web platform offers nothing stronger — anyone with DevTools can override geolocation. GeoLock is a playful artifact, not an access-control system, so the honest move is to accept spoofability and position the product accordingly rather than bolt on checks that only pretend to help.
What it cost
The unlock is a promise, not a guarantee. The product must never be marketed for anything where a determined liar matters — a constraint on positioning, permanently.
Варианты
Доверять координатам клиента · сверять с геолокацией по IP · аппаратная аттестация (веб-платформе недоступна).
Выбрано
Доверять клиенту — и говорить об этом прямо. Координаты — то, что сообщил браузер.
Почему
Ничего сильнее веб-платформа не предлагает: геолокацию переопределит любой, у кого открыт DevTools. GeoLock — игровой артефакт, а не система контроля доступа, поэтому честный ход — принять подделываемость и позиционировать продукт соответственно, а не навешивать проверки, которые лишь изображают защиту.
Цена
Открытие — обещание, а не гарантия. Продукт нельзя позиционировать ни для чего, где важен решительный обманщик, — постоянное ограничение на позиционирование.

Proximity discovery, not links

Обнаружение по близости, а не по ссылкам

Options
Shareable unlock links · QR codes fixed to a physical location · a nearby-only feed with no link ever generated.
Chosen
No links exist. Users are shown only the drops currently within range of their reported position; drops are opened by title from that list.
Why
A link is a remote-access mechanism, and remote access is exactly what these use cases can't allow — a wildfire warning meant for one county, or a festival announcement meant for people on the grounds, loses its entire value proposition the moment it's forwardable to someone a thousand miles away. Making location the only path to discovery, not just the only path to unlock, keeps the guarantee end to end.
What it cost
No way to tell someone about a drop before they arrive — you can't say "check this out" over text, since there's nothing to send. Discovery is also strictly pull-based: a user has to be in the app, in the area, to ever see the content exists.
Варианты
Ссылки для открытия · QR-коды, закреплённые на месте · лента только для ближайших точек обмена без генерации ссылок вообще.
Выбрано
Ссылок нет. Пользователю показываются только точки обмена в радиусе от его текущей позиции; открытие — по названию из этого списка.
Почему
Ссылка — это механизм удалённого доступа, а удалённый доступ — ровно то, что эти сценарии не могут допустить: предупреждение о лесном пожаре для одного округа или анонс фестиваля для людей на месте теряет весь смысл, как только его можно переслать за тысячу километров. Сделав местоположение единственным путём не только к открытию, но и к самому обнаружению, гарантия держится сквозно.
Цена
Нет способа рассказать о точке обмена до прибытия — «глянь вот это» в переписке не отправить, отправлять нечего. Обнаружение строго pull-based: пользователь должен быть в приложении, в зоне, чтобы вообще узнать о существовании контента.

An optional key as a second factor

Опциональный ключ как второй фактор

Options
Location as the only gate · mandatory password on every drop · location plus an optional per-drop key.
Chosen
Optional. A public alert needs no key; a personal drop can add one.
Why
The use cases split into two shapes. A county wildfire warning wants maximum reach inside the zone — any key is friction working against the purpose. A personal message for one specific person, left in a public-ish area, needs a second factor so that anyone else who happens to wander into range doesn't read it. One field, optional, serves both without forcing a choice on the low-stakes case.
What it cost
There's no account system, so a forgotten key has no recovery path — the creator locks themselves out too. And the key travels to the server to be checked, so this is a shared secret, not a zero-knowledge scheme.
Варианты
Только местоположение · обязательный пароль на каждой точке обмена · местоположение плюс опциональный ключ на точку обмена.
Выбрано
Опционально. Публичному оповещению ключ не нужен; личной точке обмена его можно добавить.
Почему
Сценарии распадаются на два типа. Оповещению округа о пожаре нужен максимальный охват внутри зоны — любой ключ работает против цели. Личному сообщению для конкретного человека, оставленному в публичном месте, нужен второй фактор, чтобы случайно забредший в зону не прочитал его. Одно опциональное поле закрывает оба случая, не навязывая выбор в случае с низкими ставками.
Цена
Системы аккаунтов нет, поэтому у забытого ключа нет пути восстановления — создатель блокирует и себя. И ключ передаётся на сервер для проверки, то есть это общий секрет, а не zero-knowledge схема.

A keyless mapping stack

Картографический стек без ключей

Options
Google Maps · Mapbox · Leaflet + CartoDB Voyager tiles + Nominatim geocoding.
Chosen
The OpenStreetMap ecosystem: Leaflet for the map, Voyager for tiles, Nominatim for search.
Why
Google and Mapbox both mean API keys, billing accounts, and quota anxiety on a zero-budget project. The OSM stack has no keys, no billing, and — a bonus discovered in use — Voyager's muted palette makes drop pins more legible than default OSM tiles.
What it cost
Nominatim's usage policy caps request rates and forbids autocomplete-style hammering, so search is a deliberate action, not search-as-you-type. Tile usage policies would also need revisiting before any real traffic.
Варианты
Google Maps · Mapbox · Leaflet + тайлы CartoDB Voyager + геокодинг Nominatim.
Выбрано
Экосистема OpenStreetMap: Leaflet для карты, Voyager для тайлов, Nominatim для поиска.
Почему
Google и Mapbox — это API-ключи, платёжные аккаунты и тревога о квотах в проекте с нулевым бюджетом. OSM-стек — без ключей и биллинга, а приглушённая палитра Voyager (бонус, обнаруженный в работе) делает пины точек обмена читаемее, чем стандартные тайлы OSM.
Цена
Политика Nominatim ограничивает частоту запросов и запрещает «долбёжку» в стиле автодополнения, так что поиск — осознанное действие, а не search-as-you-type. Политики использования тайлов тоже пришлось бы пересматривать при реальном трафике.

05 · Data model

05 · Модель данных

Discovery and unlock are the same predicate, asked twice

A drop is a row: a title (its display identifier — currently unenforced for uniqueness, more on that below), an optional hashed key, an expiration timestamp, content, a point on the earth stored as geography(Point, 4326), and an unlock radius in meters. There is no slug and no link column — nothing in the schema is designed to be addressed remotely. The same spatial predicate answers two different-sounding questions: "what's near me?" (discovery, no title known yet) and "may I read this one?" (unlock, title already chosen from the list):

Обнаружение и открытие — один и тот же предикат, заданный дважды

точка обмена — это строка: название (отображаемый идентификатор — пока без проверки уникальности, подробнее ниже), опциональный хэш ключа, срок действия, контент, точка на Земле в типе geography(Point, 4326) и радиус открытия в метрах. В схеме нет ни слага, ни поля со ссылкой — ничто не рассчитано на удалённую адресацию. Один и тот же пространственный предикат отвечает на два разных на слух вопроса: «что рядом со мной?» (обнаружение, название ещё неизвестно) и «можно ли мне это прочитать?» (открытие, название уже выбрано из списка):

-- discovery: what's near me right now?
SELECT title
FROM   drops
WHERE  (expires_at IS NULL OR expires_at > now())
AND    ST_Within(
         ST_SetSRID(ST_MakePoint($1, $2), 4326)::geometry,  -- $1 = lng, $2 = lat. In that order.
         unlock_zone::geometry
       );

-- unlock: open the one I picked
SELECT content
FROM   drops
WHERE  title = $3
AND    (key_hash IS NULL OR key_hash = crypt($4, key_hash))
AND    (expires_at IS NULL OR expires_at > now())
AND    ST_Within(
         ST_SetSRID(ST_MakePoint($1, $2), 4326)::geometry,
         unlock_zone::geometry
       );

Two properties of this design carry the system. First, geography means distances and containment are computed in meters on a spheroid — no hand-rolled projection math, no degrees-to-meters conversion to get wrong. Second, because the gate is a database predicate, it is testable in isolation: feed psql a coordinate known to be inside a zone and one known to be outside, and the security model of the whole product is verified in two queries — the same two queries run for real discovery.

The comment on the point constructor is not decoration. PostGIS point constructors take (x, y) — longitude first — while every human-facing convention, from Leaflet to spoken coordinates, is latitude first. That single inversion produced the project's defining bug, covered below.

The unenforced uniqueness on title is a deliberate deferral, not an oversight: multiple unrelated drops can currently share a display name, since nothing yet needs a global namespace. The planned next step — a freemium tier where paid accounts can reserve a unique title — turns that open question into the product's first monetization lever rather than a bug to fix defensively now.

Систему держат два свойства этой схемы. Первое: geography означает, что расстояния и вхождение считаются в метрах на сфероиде — никакой самодельной математики проекций, никакой конвертации градусов в метры, в которой можно ошибиться. Второе: раз замок — предикат в базе, он тестируется изолированно: скормите psql координату, заведомо лежащую внутри зоны, и заведомо внешнюю — и модель безопасности всего продукта проверена двумя запросами, теми же самыми, что выполняются при реальном обнаружении.

Комментарий у конструктора точки — не украшение. Конструкторы точек PostGIS принимают (x, y) — сначала долготу, — тогда как все человеческие конвенции, от Leaflet до координат вслух, начинаются с широты. Эта единственная перестановка породила главный баг проекта — о нём ниже.

Отсутствие проверки уникальности у title — сознательная отсрочка, а не недосмотр: сейчас разные точки обмена могут делить отображаемое имя, поскольку глобальное пространство имён пока не нужно никому. Запланированный следующий шаг — freemium-тариф, где платные аккаунты смогут резервировать уникальное название, — превращает этот открытый вопрос в первый рычаг монетизации продукта, а не в баг, который нужно защитно чинить прямо сейчас.

06 · Hard problems

06 · Трудные места

The bug that never errors

ST_MakePoint(lng, lat) — and I called it with (lat, lng). Nothing fails. The arguments are both valid floats, the point is a perfectly legal location — just the wrong one, coordinates transposed across the globe. Every unlock attempt returned "not there yet" while standing on the exact spot. No exception, no log line, no hint: the system was working flawlessly on a question nobody meant to ask. Diagnosis came from casting the stored point back to text in psql and reading the raw coordinates — at which point the transposition was obvious in one glance. The class of bug is worth naming: an interface where argument order is the only contract, both arguments share a type, and the failure mode is silent semantic corruption. Type systems don't catch it; only a test with a known real-world coordinate does.

The phantom failure

A success path that reported failure: the frontend's fetch wrapper called .json() on every response, and endpoints returning 204 No Content — which have, by definition, no body — made the wrapper throw Unexpected end of JSON input. The operation had succeeded on the server; the UI showed an error anyway. The fix is trivial (branch on status before parsing), but the lesson generalizes: a response wrapper is an interface contract with every endpoint, including the bodyless ones you haven't written yet.

Баг, который никогда не падает

ST_MakePoint(lng, lat) — а я вызвал с (lat, lng). Ничего не ломается. Оба аргумента — валидные числа, точка — совершенно легальная локация, просто не та: координаты переставлены через полмира. Каждая попытка открытия отвечала «вы ещё не на месте», когда я стоял ровно в точке. Ни исключения, ни строки в логах, ни намёка: система безупречно работала над вопросом, который никто не собирался задавать. Диагноз пришёл из psql — привести сохранённую точку к тексту и прочитать сырые координаты; перестановка стала очевидна с одного взгляда. Класс бага стоит назвать по имени: интерфейс, где порядок аргументов — единственный контракт, оба аргумента одного типа, а режим отказа — тихая семантическая порча. Системы типов это не ловят; ловит только тест с известной реальной координатой.

Фантомный отказ

Успешный сценарий, сообщавший об ошибке: fetch-обёртка фронтенда вызывала .json() на каждом ответе, и эндпоинты, возвращающие 204 No Content — у которого по определению нет тела, — заставляли обёртку бросать Unexpected end of JSON input. Операция на сервере удалась; UI всё равно показывал ошибку. Правка тривиальна (ветвление по статусу до парсинга), но урок обобщается: обёртка над ответами — это контракт с каждым эндпоинтом, включая безтелые, которые вы ещё не написали.

07 · Verification

07 · Верификация

The gate was tested from both sides

  • The predicate verified directly in psql: a coordinate known to lie inside a test drop's zone returns the content; a coordinate meters outside returns zero rows. Same query, both sides of the fence.
  • The full path verified in the field: a drop created on desktop, then unlocked from a phone standing at the pinned location — reported GPS position, real network, real accuracy noise.
  • The server-side-gate NFR checked at the API boundary: a locked drop's read endpoint returns no content payload for an outside position — the content isn't hidden by the UI, it never arrives.
  • The optional key checked both ways: a keyed drop returned content from inside the zone with the correct key, and was refused with the wrong key even though the position check passed.
  • Expiration checked by advancing a test drop's expires_at into the past: it stopped appearing in the discovery query and its unlock query returned zero rows, with no separate code path needed for either.

Замок проверен с обеих сторон

  • Предикат проверен напрямую в psql: координата, заведомо лежащая внутри зоны тестовой точки обмена, возвращает контент; координата в считанных метрах снаружи — ноль строк. Один запрос, обе стороны забора.
  • Полный путь проверен в поле: точка обмена создана на десктопе и открыта с телефона, стоя в отмеченной точке — реальная GPS-позиция, реальная сеть, реальный шум точности.
  • NFR «замок на сервере» проверен на границе API: для позиции снаружи эндпоинт чтения закрытой точки обмена не возвращает контент вовсе — он не спрятан в UI, он просто не приходит.
  • Опциональный ключ проверен в обе стороны: точка обмена с ключом отдала контент изнутри зоны при верном ключе и отказала при неверном, даже когда проверка позиции прошла.
  • Срок действия проверен переводом expires_at тестовой точки обмена в прошлое: она перестала появляться в запросе обнаружения, а запрос открытия вернул ноль строк — без отдельной ветки кода для каждого случая.

08 · Retrospective

08 · Ретроспектива

If I rebuilt it

  • A minimum unlock-radius floor, derived from typical phone GPS accuracy. A 10-meter zone plus 25 meters of GPS noise means standing inside and reading "outside" — the current design lets creators configure a frustration machine.
  • Show the accuracy circle on the map during an unlock attempt. When the gate says no, the user should see why: their reported position and its uncertainty, next to the zone.
  • One integration test with a known real-world coordinate, written on day one. It would have caught the coordinate-order bug before it cost an afternoon — the cheapest test in the whole project would have paid for the most expensive bug.
  • Rate limiting on the discovery and unlock endpoints. A spoofable position means an attacker can probe for nearby drops from an armchair; a per-IP limit makes the lazy version of that attack boring.
  • Next step, not a regret: a freemium tier where paid accounts can reserve a unique title. Titles are unenforced for uniqueness today by choice — turning that into a feature rather than patching it defensively is the plan, not a gap that slipped through.

Если бы строил заново

  • Минимальный порог радиуса открытия, выведенный из типичной точности GPS телефона. Зона в 10 метров плюс 25 метров GPS-шума — это «вы снаружи», когда стоишь внутри: текущий дизайн позволяет автору настроить машину фрустрации.
  • Показывать круг точности на карте при попытке открытия. Когда замок говорит «нет», пользователь должен видеть почему: свою заявленную позицию и её неопределённость рядом с зоной.
  • Один интеграционный тест с известной реальной координатой, написанный в первый день. Он поймал бы баг с порядком координат до того, как тот стоил вечера: самый дешёвый тест проекта окупил бы самый дорогой баг.
  • Rate limiting на эндпоинтах обнаружения и открытия. Подделываемая позиция позволяет прощупывать точки обмена из кресла; лимит по IP делает ленивую версию этой атаки скучной.
  • Следующий шаг, а не сожаление: freemium-тариф, где платные аккаунты смогут резервировать уникальное название. Уникальность названий сегодня не проверяется осознанно — превратить это в фичу, а не защитно залатать, и есть план.