@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 +21 -0
- package/README.md +138 -0
- package/package.json +41 -0
- package/src/canonical.js +16 -0
- package/src/geohash.js +36 -0
- package/src/index.d.ts +63 -0
- package/src/index.js +164 -0
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
|
+
}
|
package/src/canonical.js
ADDED
|
@@ -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 }
|