15 — Guía de integración para sistemas terceros
ID: GS-INT-001 · Versión: 0.2.1 · Requisitos: FR-INT-001..004, NFR-DX-001
Esta guía es normativa en los flujos y en las reglas para integradores; los fragmentos de código son ILUSTRATIVOS y sus versiones ejecutables viven en examples/ (F8), probadas en CI.
1. Qué hace cada parte
| Parte | Responsabilidades |
|---|---|
| Backend del integrador | Autenticar a sus usuarios; guardar la API key; emitir tokens de dispositivo (POST /v1/tokens); opcionalmente crear sesiones y miembros; recibir webhooks; opcionalmente consumir el stream con locations:read |
| App del integrador | Integrar el SDK; implementar CredentialProvider contra su backend; obtener consentimiento del usuario antes de sharing.start; pedir permisos de ubicación con buena UX; mostrar el estado de compartición |
| GeoStream | Autorizar, distribuir, revocar, notificar; no guardar historial por defecto |
2. Integración paso a paso
- Credenciales: el operador ejecuta
geostream admin tenant create,app createykey create(16§3). Guarda la key en el gestor de secretos del backend. - Endpoint de tokens en tu backend (§6.1): recibe el
installation_iddesde tu app autenticada, llamaPOST /v1/tokenscon elexternal_subject_idde tu usuario y devuelve{access_token, expires_at}. CredentialProvideren la app: llama a ese endpoint con la sesión de tu usuario.- Sesiones: créalas desde tu backend (recomendado para flujos de negocio: viajes, pedidos) o desde la app (flujos sociales: familia, eventos).
- Compartir y mostrar:
sharing.start/sessions.watchen la app. - Webhooks (opcional): registra un endpoint y verifica firmas (§6.2).
- Stream del lado servidor (opcional): para despacho o monitoreo, con una key que tenga
stream:connectylocations:read(§5).
3. Patrones de integración
3.1 Ride-hailing (UC-01)
sequenceDiagram
participant BE as Backend integrador
participant GS as GeoStream
participant D as App conductor
participant R as App pasajero
BE->>GS: POST /v1/sessions {ONE_TO_ONE, initial_members:[driver, rider], expires_in_seconds}
BE-->>D: push/WS propio: "viaje asignado" + session_id
BE-->>R: idem
D->>GS: PUT …/members/me/sharing {true}
R->>GS: PUT …/members/me/sharing {true} (opcional)
D->>GS: stream: Subscribe + PublishLocation…
R->>GS: stream: Subscribe
GS-->>R: LocationUpdate (conductor)
BE->>GS: POST …/close (viaje terminado)
GS-->>D: SubscriptionEnded SESSION_CLOSED
GS-->>R: SubscriptionEnded SESSION_CLOSED
Reglas: usar Idempotency-Key derivada del ID del viaje; cerrar siempre la sesión al terminar (no depender solo de la expiración); expires_in_seconds = duración máxima razonable del viaje.
3.2 Delivery con seguimiento para el cliente (UC-02)
- Sesión
ONE_TO_MANYcreada por el backend con el repartidor como miembro conpublish(+readsi debe ver al cliente, normalmente no). - El cliente puede ser:
- usuario de la app del integrador → alta directa con capacidades
{read}; - visitante web sin cuenta → el backend crea un subject invitado (
external_subject_id = "guest:<id aleatorio del pedido>"), lo agrega con{read}y le emite un tokenplatform=WEBcon scopes mínimos (stream:connect). Nunca exponer elsession_idcomo único secreto de un enlace público: el enlace debe llevar a una página del integrador que obtenga el token.
- usuario de la app del integrador → alta directa con capacidades
- Seguimiento en vivo para el cliente (decisión DR-20261102-01, opción 1). El snapshot REST solo lo lee una aplicación con
locations:read: el navegador del cliente no puede. La página de seguimiento consulta el backend del integrador (cada 2–5 s) y este lee el snapshot con su API key y devuelve solo lo necesario (posición, rumbo y antigüedad del repartidor; nuncamember_id, otros miembros ni la API key). Ejemplo ejecutable:examples/tracking-relay-node(§6.3). El tiempo real por WebSocket llega con T-F8-05. - El backend puede leer la ubicación con
locations:read(snapshot) para calcular ETA con su proveedor de rutas. - Geocerca de destino (F11) para notificar "llegó" por webhook.
3.3 Familia o equipo (UC-03)
- Sesión
MANY_TO_MANYcreada desde la app (dispositivo consessions:create), con expiración larga dentro de la política del tenant (max_session_ttl_seconds), extendida periódicamente conPATCH. - Invitaciones por código (
max_uses= tamaño del grupo, expiración corta). - Cada persona decide si comparte; la UI debe mostrar claramente quién está compartiendo y permitir dejar de compartir en un toque.
- Recomendado: teselas propias por privacidad (
13§7).
3.4 Flota (UC-04)
- Sesión propiedad de la aplicación (
owner_external_subject_idausente)MANY_TO_MANYoONE_TO_MANYconmax_membersalto (dentro demax_members_cap). - Conductores con capacidades
{publish}(sinread: no se ven entre sí). - El backend consume por stream con un principal de aplicación (§5).
- Si la flota supera
max_members_cap, particionar en varias sesiones (por zona o turno).
3.5 Evento temporal (UC-05)
Sesión con expiración de horas, invitación multiuso, snapshot_ttl_seconds corto. La expiración automática limpia todo.
3.6 IoT / gateways (UC-08, F8)
- Cada equipo es un subject (o un subject por vehículo) con dispositivo
platform=OTHER. - Token con scope
ingest:write; publica porPOST /v1/ingest/locationsen lotes ≤max_samples_per_publish_http, respetandomin_publish_interval_ms. - Debe activar
sharing=truecon su propio token antes de publicar (es su consentimiento técnico; el integrador es responsable de la base legal).
4. Manejo de errores recomendado
- Respetar
Retry-Afteryretry_after_ms. - Reintentar solo errores marcados como reintentables en
09§6, con backoff exponencial y jitter. - Usar
Idempotency-Keyen todoPOSTque el backend pueda repetir. - Tratar
404como "no existe o no tienes relación": nunca deducir existencia. - Ante
401en llamadas de dispositivo: pedir token nuevo al backend una vez; si vuelve a fallar, cerrar sesión de usuario en la app.
5. Consumidor de stream del lado servidor
- Key con
stream:connect+locations:read. POST /v1/stream/ticketscon la API key → conectar →ClientHello(platform = PLATFORM_SERVER) →Subscribea las sesiones de interés.- Reconectar antes de
auth_expires_at(GS_STREAM_APP_MAX_CONNECTION_AGE_S) abriendo una conexión nueva antes de cerrar la anterior (las conexiones de aplicación no se desplazan entre sí). - Las suscripciones de aplicación quedan auditadas (
locations.read_by_application). - Ejemplo ejecutable en
examples/stream-consumer/(F8): cliente WebSocket + código generado desdecontracts/proto.
6. Ejemplos (ILUSTRATIVOS)
6.1 Endpoint de tokens (Node.js / Express)
// ILUSTRATIVO — versión ejecutable en examples/token-backend-node (F8)
app.post("/geostream/token", requireUserSession, async (req, res) => {
const { installation_id, platform } = req.body; // del SDK
const r = await fetch(`${GS_BASE_URL}/v1/tokens`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.GS_API_KEY}`, // nunca en la app
"Content-Type": "application/json",
},
body: JSON.stringify({
external_subject_id: req.user.opaqueId, // ID opaco, nunca email
installation_id,
platform,
ttl_seconds: 900,
}),
});
if (!r.ok) return res.status(502).json({ error: "token_unavailable" });
const t = await r.json();
res.json({ access_token: t.access_token, expires_at: t.expires_at });
});
6.3 Relevo de seguimiento para el cliente final (Node.js, ejecutable)
examples/tracking-relay-node/relay.mjs (sin dependencias, Node ≥ 20). Reglas que implementa y que debes mantener:
- El enlace del cliente lleva un token opaco propio del pedido, nunca el
session_idde GeoStream; un token desconocido o caducado da 404. - Una lectura a GeoStream por sesión cada
CACHE_MS(2 s), aunque miren cien clientes: el límite de la API key (GS_REST_RATE_LIMIT_APP_RPM, 6000 por minuto) es de todo el proyecto. La posición que ve el cliente puede ir hastaCACHE_MSpor detrás. - Estados:
waiting(el repartidor aún no comparte),live(lat,lon,heading,age_s),finished(sesión cerrada, expirada o inexistente:404/409de GeoStream). Cualquier otro fallo es502 tracking_unavailable, nunca un dato inventado. - La API key necesita
locations:ready no sale del servidor.
6.2 Verificación de webhooks (Node.js)
// ILUSTRATIVO — versión ejecutable en examples/webhook-receiver-node (F8)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: Buffer, headers: Record<string, string>, secret: string): boolean {
const id = headers["gs-webhook-id"];
const ts = headers["gs-webhook-timestamp"];
const sigHeader = headers["gs-webhook-signature"] ?? "";
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(`${id}.${ts}.`).update(rawBody).digest("hex");
return sigHeader.split(" ").some((part) => {
const [v, sig] = part.split("=");
return v === "v1" && sig?.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}
El receptor DEBE deduplicar por GS-Webhook-Id y responder 2xx rápido (procesar en segundo plano).
6.1 Integración de la pista E
Los cuatro patrones que cambian con la pista E, con su requisito previo.
6.1.1 Flota con ETA y asignación (E4 + E5)
- El backend crea un
POOLconpools:manage. - Cada dispositivo entra solo, activa
sharingy marcaavailability = BUSYal aceptar un encargo. - El backend llama a
GET /v1/pools/{id}/nearby?rank=etacon origen y destino, y aPOST /v1/tools/assignmentspara repartir las tareas. - El cliente recibe las herramientas por el mismo stream (
SessionView), no por una llamada extra.
6.1.2 Historial y analítica (E6)
- El tenant activa
history_allowedy la DPIA (tarea T-E6-11) está cerrada. - El backend crea sesiones con
history_mode = TRACE_AND_ANALYTICS. - El usuario concede el consentimiento por separado para
HISTORYy paraANALYTICS, desde su dispositivo. El backend no puede concederlo (INV-SHR-5). - Se consulta con
history:read/analytics:read, que son scopes distintos y se conceden uno a uno.
6.1.3 BYOK de proveedor (E5)
PUT /v1/providers/ROUTE_MATRIXcon la credencial del tenant yterms.checked_on.- Se revisa
GET /v1/analytics/provider-usagepara controlar coste y volumen. - Si el contrato con el proveedor no es aceptable, se usa el grafo vial propio (
GS_GRAPH_PATHS) y ninguna coordenada sale de la infraestructura del tenant.
6.1.4 Tags BLE (E8)
- El usuario escanea el tag (la
EIKse queda en el tag). - Se registra con
tags:manage: modelo, nombre, nada más. - Buscadores autorizados envían reportes cifrados; el servidor los almacena sin poder leerlos.
- El dueño consulta sus sightings con
edge:lookupy los descifra en su cliente.
7. Checklist de producción del integrador
- La API key solo existe en el backend (gestor de secretos), con los scopes mínimos.
-
external_subject_idopaco (sin emails ni teléfonos). - TTL de tokens ≤ 15 min salvo justificación.
- Consentimiento explícito antes de
sharing.start, con texto que explique quién verá la ubicación y durante cuánto tiempo. - Botón visible para dejar de compartir.
- Declaraciones de ubicación en segundo plano en Google Play / App Store si aplica.
- Política de privacidad actualizada (incluye proveedor de mapas,
13§7). - Webhooks verificados y deduplicados.
- Sesiones cerradas explícitamente al terminar el flujo de negocio.
- Manejo de
Supersededen la app (otra instancia del mismo dispositivo). - Si usa historial: DPIA cerrada, textos de consentimiento por propósito aprobados, retención declarada y endpoint de borrado funcionando.
- Si usa
POOL: la app de conductor nunca recibenearby/rings; solodensitysin identificadores, y el conductor no puede ver a los demás. - Si usa BYOK: términos del proveedor revisados y aceptados; coste monitorizado en
provider-usage. - Si usa tags: el texto de privacidad informa del despliegue de la red de búsqueda y ofrece desactivación.
8. Antipatrones
- Incluir la API key en la app o en el frontend web.
- Usar el
session_idcomo credencial de un enlace público. - Consultar el snapshot REST en bucle en lugar de usar el stream.
- Tokens de larga duración "para no molestar".
- Activar
sharingautomáticamente al unirse sin acción del usuario. - Mezclar geocodificación de Google con mapas MapLibre.
- Guardar por su cuenta el historial de ubicaciones recibidas sin base legal ni política de retención.
9. Antipatrones de la pista E
Los cinco anteriores siguen siendo antipatrones. Estos se añaden:
- Conceder
HISTORYoANALYTICSdesde el backend. Es imposible por diseño (INV-SHR-5) y, si se intenta, falla. El consentimiento es del sujeto, no del tenant. - Gravar una celda H3, una ETA o un perfil como etiqueta de Prometheus. Enruta el comportamiento de un usuario concreto a un sistema de métricas al que muchos tienen acceso. Los agregados se guardan en PostgreSQL (
13§8.3). - Degradar en silencio a la estimación propia cuando el proveedor falla. Devuelve
503 PROVIDER_UNAVAILABLE: un integrador que eligió proveedor por cobertura necesita distinguir "no hay ruta" de "no supe". - Enviar la predicción visual como si fuera una medición. Se rechaza con
INVALID_LOCATION. La predicción es para dibujar; el dato persistente es la medición. - Tratar
densitycomonearby.densityestá deliberadamente empobrecido (sin identificadores, con desplazamiento y retraso). Pedirnearbypara un pasajero es pedir una surveillance de la ubicación de los conductores; por eso exigepools:query, que es de aplicación.
10. Consumir el despliegue gestionado (geostream.gravitalcloud.com)
Esta sección es operativa: describe cómo un proyecto (propio o de terceros) consume la instancia de Railway con lo que ya está implementado. Lo que aún requiere decisión está enlazado al final.
10.1 Puntos de entrada
| Uso | URL |
|---|---|
| REST | https://geostream.gravitalcloud.com/v1/… (contrato: contracts/openapi/geostream.v1.yaml) |
| Stream | wss://geostream.gravitalcloud.com/v1/stream?ticket=<gst_…>, subprotocolo gs.v1.proto (§08 §2). gs.v1.json está apagado en prod (17, GS_STREAM_JSON_ENABLED) |
| Salud | GET /health/live, GET /health/ready |
10.2 Alta de un proyecto consumidor
Cada proyecto es una aplicación dentro de un tenant, con su propia API key y sus scopes mínimos (06 §3.4).
Hoy el alta la hace el operador por CLI (16 §3); no hay registro de cuentas por HTTP (FR-ADM-004 es F15).
geostream admin tenant create --name <proyecto>
geostream admin app create --tenant ten_… --name <proyecto>-movil \
--device-scopes sessions:create,invitations:redeem,stream:connect,sharing:write,ingest:write
geostream admin key create --app app_… --scopes tokens:mint,sessions:write,members:write,sessions:read
Proyectos distintos van en tenants distintos: toda consulta filtra por tenant_id y un tenant nunca ve datos
de otro (06 §8).
10.3 Autenticación de usuarios con la identidad que ya tiene cada proyecto
GeoStream no gestiona cuentas de usuario final ni contraseñas: confía en el backend de cada proyecto, que ya autentica a sus usuarios (OTP, WebAuthn/passkeys, lo que use). El patrón es un intercambio de token:
sequenceDiagram
participant App as App del proyecto
participant BE as Backend del proyecto (ya autentica usuarios)
participant GS as GeoStream
App->>BE: POST /geo/token (sesión propia del proyecto, installation_id)
BE->>BE: verifica la sesión con SU identidad
BE->>GS: POST /v1/tokens (API key, external_subject_id opaco)
GS-->>BE: access_token (≤ GS_TOKEN_MAX_TTL_S)
BE-->>App: access_token, expires_at
App->>GS: REST / stream con el token de dispositivo
- El usuario no se registra dos veces ni ve GeoStream: el inicio de sesión es el del proyecto.
external_subject_ides el ID interno del usuario en el proyecto, opaco (nunca correo ni teléfono,05§2).- No compartas con GeoStream el secreto con que tu identidad firma sus propios tokens, ni reutilices esos tokens como credencial de GeoStream: quien tuviera ese secreto podría acuñar sesiones de tu producto.
- La federación directa (GeoStream verifica tokens firmados por tu identidad con JWKS, FR-AUTH-009) exige firma
asimétrica en el emisor; aprobada como siguiente paso en
docs/decisions/accepted/DR-20261031-01.md.
10.4 Dispositivos: móvil, microcontrolador o gateway
Toda muestra lleva coordenada y su radio de confianza en metros; sin horizontal_accuracy_m válido la muestra
se rechaza (09 §3).
| Dispositivo | Transporte recomendado | Notas |
|---|---|---|
| App móvil | SDK (F5) o apps/flutter_app como referencia |
Ingesta REST o stream |
| Microcontrolador con IP (ESP32 + GNSS, módulo LTE-M) | Stream gs.v1.proto: una conexión persistente y PublishLocation binario; Protobuf con nanopb |
Una muestra con posición, precisión, secuencia, instante y calidad ocupa del orden de 60 bytes. El dispositivo pide su token a tu backend (nunca guarda una API key) y lo renueva antes de expires_at con AuthRefresh (08 §6.6) |
| Microcontrolador sin stream | POST /v1/ingest/locations (JSON, hasta 50 muestras por lote) |
Más simple; útil para lotes tras pérdida de cobertura |
| Rastreador LPWAN/celular | A través del servidor de red del operador (25 §4) |
Pendiente de DR-20261018-04 |
Campos que un dispositivo debe rellenar con honestidad (contracts/proto/geostream/v1/location.proto):
latitude, longitude, horizontal_accuracy_m (el radio de precisión que da el receptor GNSS en cada medición, nunca un valor fijo), captured_at
(instante de la medición, no de envío), sequence (monotónica y persistida, 12 §9), quality.source (GNSS,
NETWORK, FUSED…) y quality.is_mock si la plataforma lo sabe. Opcionales: velocidad, rumbo, altitud y sus
precisiones.
10.5 Precisión y ubicaciones falsas: qué hace hoy el servidor
| Mecanismo | Estado | Dónde |
|---|---|---|
| Rechazar muestras con precisión peor que un umbral por tenant | Implementado. max_horizontal_accuracy_m, rango 5..=100000 m |
geostream admin policy set --tenant ten_… max_horizontal_accuracy_m=25 |
Rechazar 0,0 (Null Island), coordenadas no finitas, secuencias repetidas, muestras del futuro |
Implementado | 09 §3 |
Señales de plausibilidad por muestra (MOCK_FLAGGED, velocidad o aceleración imposibles, fix congelado, solo red, baja precisión) y puntuación plausibility |
Implementado: viajan en estimate de cada LocationUpdate del stream |
22 §2.2, 22 §2.7 |
Rechazar ubicaciones simuladas (is_mock = true) |
Implementado, opcional por tenant | geostream admin policy set --tenant ten_… reject_mock_samples=true (09 §3.1 regla 17) |
| Rechazar muestras solo de red (sin GNSS) | Implementado, opcional por tenant | reject_network_only=true (regla 18) |
| Atestación del dispositivo (Play Integrity, App Attest, elemento seguro) | Pendiente de ADR | docs/decisions/accepted/DR-20261031-02.md fase 2 |
Ninguna técnica del lado servidor impide del todo que un dispositivo rooteado o un firmware modificado invente
coordenadas: lo que se puede hacer es encarecer el engaño (atestación de la app, coherencia física de la
trayectoria) y dejar la sanción al negocio del integrador. Una muestra rechazada vuelve con status = REJECTED, error_code = INVALID_LOCATION y field (quality.is_mock, quality.source o position.horizontal_accuracy_m).