@dotrino/geo 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seyacat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @dotrino/geo
2
+
3
+ Índice de **descubrimiento georreferenciado** del ecosistema Dotrino:
4
+ `geo.dotrino.com`. Una identidad publica un **pin firmado** (lat/lng + payload)
5
+ con TTL corto; cualquiera consulta los pins dentro de un **radio** (PostGIS hace
6
+ la query espacial). Pensado para casos tipo "Uber", delivery, "cerca de mí",
7
+ marketplace local, eventos.
8
+
9
+ Es el **cuarto pilar** del ecosistema, complementario a los tres existentes:
10
+
11
+ | Pilar | Paquete | Rol |
12
+ |-------|---------|-----|
13
+ | Identidad | `dotrino-identity` | clave del vault, firma |
14
+ | Transporte | `dotrino-proxy-client` | mensajería, canales, WebRTC |
15
+ | Almacenamiento | `dotrino-store` | datos del usuario en el navegador |
16
+ | **Descubrimiento geo** | **`dotrino-geo`** | **encontrar identidades cercanas** |
17
+
18
+ ## El pin es un anuncio efímero, no almacenamiento
19
+
20
+ Una publicación geo **no es persistente**: es un **anuncio** que apunta a otro
21
+ servicio (el messenger) donde ocurre la transacción real. El pin lleva el
22
+ **pubkey** de la identidad, que es el handle con el que se la contacta por el
23
+ proxy. El dato durable de la transacción vive en el messenger, no acá.
24
+
25
+ Por eso el **techo de vida de un pin es 24 h**, alineado con la ventana de
26
+ **mensajes offline del proxy**: sin que la identidad lo *hidrate* (republicar),
27
+ un anuncio no debe sobrevivir más que un mensaje offline. Apps que necesiten
28
+ presencia "ahora mismo" usan TTL cortos (default 10 min); anuncios tipo
29
+ clasificado pueden llegar al cap de 24 h. El server purga los expirados.
30
+
31
+ ## Principio de diseño: descubrir acá, hablar por el proxy
32
+
33
+ Este índice es **SOLO descubrimiento**. El flujo correcto es:
34
+
35
+ 1. **Publicás** un pin firmado con `publishPin()` (opt-in, TTL corto).
36
+ 2. Otra identidad te **encuentra** con `queryRadius()`.
37
+ 3. A partir de ahí, **todo el contacto en vivo va por el proxy**
38
+ (`@dotrino/proxy-client`): el "pedido de viaje", el handshake
39
+ y el **streaming de ubicación en tiempo real** se hacen con `sendByPubkey` /
40
+ canal / WebRTC. **No** hagas streaming de posición a alta frecuencia contra
41
+ este índice — eso es transporte y ya está resuelto.
42
+
43
+ Cada pin (y cada query) trae el **pubkey JWK** de la identidad: ese es el handle
44
+ con el que el proxy enruta (`sendByPubkey`). Identidad de descubrimiento e
45
+ identidad de transporte coinciden, como manda el ecosistema.
46
+
47
+ ## Identidad: el vault es la única fuente
48
+
49
+ El cliente **no genera ni guarda claves**. Le inyectás `signData` y
50
+ `getPublicKeyJwk` desde `@dotrino/identity` (el vault
51
+ `id.dotrino.com`). Mismo sobre firmado `{data, signature}` y misma
52
+ serialización canónica que el proxy-client.
53
+
54
+ ## Instalación
55
+
56
+ ```bash
57
+ npm i @dotrino/geo
58
+ ```
59
+
60
+ ## Uso (cliente)
61
+
62
+ ```js
63
+ import { createGeoClient } from '@dotrino/geo'
64
+ import identity from '@dotrino/identity' // el vault
65
+
66
+ const geo = createGeoClient({
67
+ signData: identity.signData,
68
+ getPublicKeyJwk: identity.getPublicKeyJwk,
69
+ // baseUrl: 'https://geo.dotrino.com' // default
70
+ })
71
+
72
+ // 1) Publicar mi pin (opt-in). Se reemplaza el anterior; no hay historial.
73
+ await geo.publishPin({
74
+ lat: -2.1709, lng: -79.9224,
75
+ payload: { role: 'driver', seats: 4 },
76
+ tags: ['comida', 'vegano'], // etiquetas de búsqueda (se normalizan)
77
+ ttlMs: 10 * 60 * 1000 // 10 min (el server lo capa a 1h)
78
+ })
79
+
80
+ // 2) Buscar cerca, por radio + tags (overlap: alguna de las etiquetas) y/o filter
81
+ const { pins } = await geo.queryRadius({
82
+ lat: -2.1700, lng: -79.9200,
83
+ radiusMeters: 3000,
84
+ tags: ['comida', 'bici'], // pins con ALGUNA de estas etiquetas
85
+ filter: { role: 'driver' } // containment JSONB sobre payload (opcional)
86
+ })
87
+ // pins: [{ publickey, lat, lng, geohash, payload, tags, distanceMeters, expiresAt }, ...]
88
+
89
+ // 3) Contactar al más cercano POR EL PROXY (no por este índice)
90
+ // proxyClient.sendByPubkey([pins[0].publickey], { type: 'ride-request', ... })
91
+
92
+ // 4) Retirar mi pin antes de tiempo
93
+ await geo.removePin()
94
+ ```
95
+
96
+ ### Privacidad (no rompe la filosofía Dotrino)
97
+
98
+ Un índice de ubicación consultable es lo más sensible del ecosistema. Por eso:
99
+
100
+ - **Un pin por identidad, con overwrite** — no se guarda historial de ubicaciones.
101
+ - **TTL corto** por diseño (default 10 min, cap del server 1 h). Un job purga
102
+ expirados cada minuto.
103
+ - **Opt-in** siempre: publicar es una acción explícita; `removePin()` lo retira.
104
+ - **Geohash grueso** para descubrimiento (default 7 chars ≈ 76 m; bajalo con
105
+ `geohashPrecision` para exponer menos). La recomendación es descubrir por
106
+ bucket y **negociar las coords exactas P2P por el proxy** tras consentimiento.
107
+ - **Sólo metadata de app en `payload`** — nunca datos personales del usuario.
108
+
109
+ ## API del servicio (HTTP/JSON)
110
+
111
+ | Método | Ruta | Auth | Descripción |
112
+ |--------|------|------|-------------|
113
+ | `PUT` | `/pins` | sobre firmado | publica/reemplaza el pin de la identidad |
114
+ | `DELETE` | `/pins` | sobre firmado | retira el pin (tombstone) |
115
+ | `GET` | `/pins?lat&lng&r&limit&filter` | pública | pins dentro del radio, por distancia |
116
+ | `GET` | `/health` | — | liveness |
117
+
118
+ El servidor verifica la firma ECDSA P-256 (JWK del vault) sobre el `data`
119
+ canónico, chequea frescura (`issuedAt` dentro de ±5 min, anti-replay) y capa el
120
+ TTL. Ver [`server/`](./server) y [`DEPLOY.md`](./DEPLOY.md).
121
+
122
+ ## Estructura
123
+
124
+ ```
125
+ src/ cliente npm (@dotrino/geo)
126
+ index.js createGeoClient: publishPin / queryRadius / removePin
127
+ geohash.js encode base32 (compartido)
128
+ canonical.js serialización canónica (igual que el resto del ecosistema)
129
+ server/ servicio geo.dotrino.com
130
+ server.js HTTP/JSON nativo
131
+ db.js capa PostGIS (pg)
132
+ schema.sql tabla pins (geography + GiST)
133
+ signature.js verificación de sobres firmados
134
+ ```
135
+
136
+ ## Licencia
137
+
138
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@dotrino/geo",
3
+ "version": "0.2.0",
4
+ "description": "Cliente del índice geo de Dotrino (geo.dotrino.com): pins georreferenciados firmados con el vault, consulta por radio (PostGIS)",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "types": "src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test"
22
+ },
23
+ "keywords": [
24
+ "geolocation",
25
+ "geospatial",
26
+ "postgis",
27
+ "geohash",
28
+ "dotrino",
29
+ "discovery",
30
+ "p2p"
31
+ ],
32
+ "author": "seyacat",
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@dotrino/identity": "^0.10.0"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/imdotrino/dotrino-geo.git"
40
+ }
41
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Serialización JSON canónica (claves ordenadas recursivamente).
3
+ * Necesaria para que las firmas coincidan entre cliente y servidor.
4
+ * Idéntica a la del proxy-client / proxy-server del ecosistema.
5
+ */
6
+ export function canonicalStringify (value) {
7
+ if (value === null || typeof value !== 'object') {
8
+ return JSON.stringify(value)
9
+ }
10
+ if (Array.isArray(value)) {
11
+ return '[' + value.map(canonicalStringify).join(',') + ']'
12
+ }
13
+ const keys = Object.keys(value).sort()
14
+ const parts = keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(value[k]))
15
+ return '{' + parts.join(',') + '}'
16
+ }
package/src/geohash.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Geohash encode (base32, Gustavo Niemeyer). Se usa para descubrimiento por
3
+ * "bucket" grueso sin exponer coordenadas exactas: publicás un geohash de
4
+ * precisión baja (p.ej. 6 chars ≈ 1.2 km) y las coords finas se intercambian
5
+ * P2P por el proxy recién tras consentimiento mutuo.
6
+ *
7
+ * Precisión aproximada por longitud:
8
+ * 5 → ±2.4 km 6 → ±0.61 km 7 → ±0.076 km 8 → ±0.019 km
9
+ */
10
+ const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'
11
+
12
+ export function encodeGeohash (lat, lng, precision = 7) {
13
+ let idx = 0
14
+ let bit = 0
15
+ let evenBit = true
16
+ let geohash = ''
17
+ let latMin = -90, latMax = 90
18
+ let lngMin = -180, lngMax = 180
19
+
20
+ while (geohash.length < precision) {
21
+ if (evenBit) {
22
+ const mid = (lngMin + lngMax) / 2
23
+ if (lng >= mid) { idx = (idx << 1) + 1; lngMin = mid } else { idx = idx << 1; lngMax = mid }
24
+ } else {
25
+ const mid = (latMin + latMax) / 2
26
+ if (lat >= mid) { idx = (idx << 1) + 1; latMin = mid } else { idx = idx << 1; latMax = mid }
27
+ }
28
+ evenBit = !evenBit
29
+ if (++bit === 5) {
30
+ geohash += BASE32[idx]
31
+ bit = 0
32
+ idx = 0
33
+ }
34
+ }
35
+ return geohash
36
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export interface GeoClientOptions {
2
+ /** Firma canónica → base64. Inyectada desde el vault (`id.signData`). */
3
+ signData: (data: object) => Promise<string>
4
+ /** Pubkey JWK string. Inyectada desde el vault. */
5
+ getPublicKeyJwk: () => Promise<string>
6
+ /** Default https://geo.dotrino.com */
7
+ baseUrl?: string
8
+ /** fetch inyectable para tests/SSR */
9
+ fetch?: typeof fetch
10
+ }
11
+
12
+ export interface PublishPinInput {
13
+ lat: number
14
+ lng: number
15
+ payload?: Record<string, unknown>
16
+ /** Etiquetas para búsqueda (máx 10, slug ≤32, se normalizan). */
17
+ tags?: string[]
18
+ ttlMs?: number
19
+ geohashPrecision?: number
20
+ now?: number
21
+ }
22
+
23
+ export interface PublishPinResult {
24
+ ok: true
25
+ expiresAt: number
26
+ geohash: string
27
+ }
28
+
29
+ export interface QueryRadiusInput {
30
+ lat: number
31
+ lng: number
32
+ radiusMeters: number
33
+ limit?: number
34
+ filter?: string | Record<string, unknown>
35
+ /** Buscar pins que tengan ALGUNA de estas etiquetas (overlap). */
36
+ tags?: string[]
37
+ }
38
+
39
+ export interface Pin {
40
+ publickey: string
41
+ lat: number
42
+ lng: number
43
+ geohash: string
44
+ payload: Record<string, unknown>
45
+ tags: string[]
46
+ distanceMeters: number
47
+ expiresAt: number
48
+ }
49
+
50
+ export interface QueryRadiusResult {
51
+ pins: Pin[]
52
+ }
53
+
54
+ export interface GeoClient {
55
+ publishPin (input: PublishPinInput): Promise<PublishPinResult>
56
+ queryRadius (input: QueryRadiusInput): Promise<QueryRadiusResult>
57
+ removePin (input?: { now?: number }): Promise<{ ok: true }>
58
+ }
59
+
60
+ export function createGeoClient (opts: GeoClientOptions): GeoClient
61
+ export function encodeGeohash (lat: number, lng: number, precision?: number): string
62
+ export function normalizeTags (tags: string[]): string[]
63
+ export function canonicalStringify (value: unknown): string
package/src/index.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @dotrino/geo — cliente del índice geo `geo.dotrino.com`.
3
+ *
4
+ * Cuarto pilar del ecosistema: DESCUBRIMIENTO georreferenciado. Una identidad
5
+ * publica un pin firmado (lat/lng + payload) con TTL corto; cualquiera puede
6
+ * consultar pins dentro de un radio. PostGIS hace la query espacial.
7
+ *
8
+ * REGLAS DEL ECOSISTEMA QUE RESPETA:
9
+ * - Identidad = el vault (`id.dotrino.com`). Este cliente NO genera ni guarda
10
+ * claves: recibe `signData` y `getPublicKeyJwk` inyectados desde
11
+ * `@dotrino/identity` (única fuente de identidad). Mismo
12
+ * patrón de sobre firmado `{data, signature}` que el proxy-client.
13
+ * - Transporte = el proxy. Este índice es SOLO descubrimiento. Una vez que
14
+ * encontrás al peer cercano, el handshake y la ubicación en vivo van por
15
+ * `@dotrino/proxy-client` (sendByPubkey / canal / WebRTC).
16
+ * NO hagas streaming de posición a alta frecuencia contra este índice.
17
+ * - Privacidad: un pin por identidad (upsert: overwrite, sin historial), TTL
18
+ * corto, y geohash grueso para descubrimiento. Publicar es siempre opt-in.
19
+ */
20
+
21
+ import { canonicalStringify } from './canonical.js'
22
+ import { encodeGeohash } from './geohash.js'
23
+
24
+ export { encodeGeohash } from './geohash.js'
25
+
26
+ const DEFAULT_BASE = 'https://geo.dotrino.com'
27
+ const DEFAULT_TTL_MS = 10 * 60 * 1000 // 10 min: pins efímeros por diseño
28
+
29
+ /**
30
+ * Crea un cliente del índice geo.
31
+ *
32
+ * @param {object} opts
33
+ * @param {(data:object)=>Promise<string>} opts.signData firma canónica → base64 (del vault)
34
+ * @param {()=>Promise<string>} opts.getPublicKeyJwk pubkey JWK string (del vault)
35
+ * @param {string} [opts.baseUrl] default https://geo.dotrino.com
36
+ * @param {typeof fetch} [opts.fetch] fetch inyectable (tests/SSR)
37
+ */
38
+ export function createGeoClient ({ signData, getPublicKeyJwk, baseUrl = DEFAULT_BASE, fetch: f } = {}) {
39
+ if (typeof signData !== 'function' || typeof getPublicKeyJwk !== 'function') {
40
+ throw new Error('dotrino-geo: signData y getPublicKeyJwk son requeridos (inyectalos desde el vault de identidad)')
41
+ }
42
+ const doFetch = f || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null)
43
+ if (!doFetch) throw new Error('dotrino-geo: no hay fetch disponible; inyectalo en opts.fetch')
44
+ const base = baseUrl.replace(/\/+$/, '')
45
+
46
+ /**
47
+ * Publica (o reemplaza) el pin de esta identidad.
48
+ * Upsert por pubkey: SIEMPRE pisa el pin anterior — no se guarda historial.
49
+ *
50
+ * @param {object} p
51
+ * @param {number} p.lat
52
+ * @param {number} p.lng
53
+ * @param {object} [p.payload] datos de app (rol, capacidad, etc). Mantenelo chico.
54
+ * @param {string[]} [p.tags] etiquetas para búsqueda (máx 10, slug ≤32). Se buscan por overlap.
55
+ * @param {number} [p.ttlMs] vida del pin (default 10 min)
56
+ * @param {number} [p.geohashPrecision] precisión del geohash público (default 7)
57
+ * @returns {Promise<{ok:true, expiresAt:number, geohash:string}>}
58
+ */
59
+ async function publishPin ({ lat, lng, payload = {}, tags, ttlMs = DEFAULT_TTL_MS, geohashPrecision = 7, now } = {}) {
60
+ assertLatLng(lat, lng)
61
+ const publickey = await getPublicKeyJwk()
62
+ const ts = now ?? Date.now()
63
+ const data = {
64
+ publickey,
65
+ lat: round(lat, 6),
66
+ lng: round(lng, 6),
67
+ geohash: encodeGeohash(lat, lng, geohashPrecision),
68
+ payload,
69
+ issuedAt: ts,
70
+ expiresAt: ts + ttlMs
71
+ }
72
+ const cleanTags = normalizeTags(tags)
73
+ if (cleanTags.length) data.tags = cleanTags
74
+ const signature = await signData(data)
75
+ const res = await doFetch(`${base}/pins`, {
76
+ method: 'PUT',
77
+ headers: { 'content-type': 'application/json' },
78
+ body: JSON.stringify({ data, signature })
79
+ })
80
+ return handle(res)
81
+ }
82
+
83
+ /**
84
+ * Consulta pins dentro de un radio. NO requiere firma (lectura pública).
85
+ *
86
+ * @param {object} q
87
+ * @param {number} q.lat
88
+ * @param {number} q.lng
89
+ * @param {number} q.radiusMeters
90
+ * @param {number} [q.limit] default 50, máx 200
91
+ * @param {string} [q.filter] JSON string opcional para filtrar por payload (igualdad exacta de claves)
92
+ * @param {string[]} [q.tags] buscar pins que tengan ALGUNA de estas etiquetas (overlap)
93
+ * @returns {Promise<{pins:Array<{publickey,lat,lng,geohash,payload,tags,distanceMeters,expiresAt}>}>}
94
+ */
95
+ async function queryRadius ({ lat, lng, radiusMeters, limit = 50, filter, tags } = {}) {
96
+ assertLatLng(lat, lng)
97
+ if (!(radiusMeters > 0)) throw new Error('dotrino-geo: radiusMeters debe ser > 0')
98
+ const params = new URLSearchParams({
99
+ lat: String(lat), lng: String(lng), r: String(radiusMeters), limit: String(limit)
100
+ })
101
+ if (filter) params.set('filter', typeof filter === 'string' ? filter : JSON.stringify(filter))
102
+ const cleanTags = normalizeTags(tags)
103
+ if (cleanTags.length) params.set('tags', cleanTags.join(','))
104
+ const res = await doFetch(`${base}/pins?${params.toString()}`)
105
+ return handle(res)
106
+ }
107
+
108
+ /**
109
+ * Retira el pin de esta identidad antes de que expire (firma un tombstone).
110
+ * @returns {Promise<{ok:true}>}
111
+ */
112
+ async function removePin ({ now } = {}) {
113
+ const publickey = await getPublicKeyJwk()
114
+ const data = { publickey, action: 'remove', issuedAt: now ?? Date.now() }
115
+ const signature = await signData(data)
116
+ const res = await doFetch(`${base}/pins`, {
117
+ method: 'DELETE',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify({ data, signature })
120
+ })
121
+ return handle(res)
122
+ }
123
+
124
+ return { publishPin, queryRadius, removePin }
125
+ }
126
+
127
+ // ----- helpers -----------------------------------------------------------
128
+
129
+ function assertLatLng (lat, lng) {
130
+ if (typeof lat !== 'number' || lat < -90 || lat > 90) throw new Error('dotrino-geo: lat inválida')
131
+ if (typeof lng !== 'number' || lng < -180 || lng > 180) throw new Error('dotrino-geo: lng inválida')
132
+ }
133
+
134
+ function round (n, decimals) {
135
+ const f = Math.pow(10, decimals)
136
+ return Math.round(n * f) / f
137
+ }
138
+
139
+ // Normaliza etiquetas: lowercase, trim, slug unicode (letras/números/_/-), ≤32
140
+ // chars, dedup, máx 10. Descarta las inválidas en silencio.
141
+ export function normalizeTags (tags) {
142
+ if (!Array.isArray(tags)) return []
143
+ const out = []
144
+ for (const t of tags) {
145
+ if (typeof t !== 'string') continue
146
+ const s = t.trim().toLowerCase()
147
+ if (!/^[\p{L}\p{N}_-]{1,32}$/u.test(s)) continue
148
+ if (!out.includes(s)) out.push(s)
149
+ if (out.length >= 10) break
150
+ }
151
+ return out
152
+ }
153
+
154
+ async function handle (res) {
155
+ let body = null
156
+ try { body = await res.json() } catch (_) {}
157
+ if (!res.ok) {
158
+ const msg = (body && body.error) || `HTTP ${res.status}`
159
+ throw new Error(`dotrino-geo: ${msg}`)
160
+ }
161
+ return body
162
+ }
163
+
164
+ export { canonicalStringify }