openapi: 3.1.0 info: title: GeoStream API version: 1.0.0-draft.2 summary: Plano de control REST, emisión de credenciales y webhooks de GeoStream v1. description: | Contrato máquina normativo de la API REST v1 de GeoStream. - Semántica: docs/07_REST_API.md. Validación: docs/09_VALIDATION_ERRORS.md. Autorización: docs/06_IDENTITY_AUTH.md. - Extensiones propias: - `x-gs-phase`: fase del roadmap en la que la operación debe existir. - `x-gs-console`: rol mínimo en la organización para las operaciones de la consola (`none`, `session`, `VIEWER`, `ADMIN`, `OWNER`); - `x-gs-auth.application` / `x-gs-auth.device`: scope requerido para ese principal; `none` = permitido sin scope adicional; `forbidden` = principal no permitido. - `x-gs-capability`: capacidad requerida en la sesión cuando el principal es un dispositivo. - Enums REST: nombre del valor Protobuf sin el prefijo del enum (SESSION_MODE_ONE_TO_ONE -> ONE_TO_ONE). - Estado: borrador hasta el hito M2; desde M2 los cambios incompatibles requieren /v2. servers: - url: https://geostream.example.com description: Ejemplo. Sustituir por el host del despliegue. tags: - name: Credentials description: Tokens de dispositivo y tickets de stream. - name: Sessions description: Ciclo de vida de sesiones de compartición. - name: Members description: 'Membresías, capacidades y compartición.' - name: Invitations description: Invitaciones canjeables por código. - name: Locations description: Snapshot e ingesta HTTP de ubicaciones. - name: Subjects description: 'Subjects del integrador (consulta, revocación, borrado y exportación).' - name: Devices description: Dispositivos (instalaciones) de un subject. - name: Webhooks description: Endpoints de webhooks firmados. - name: Geofences description: Geocercas de tenant o de sesión (F11). - name: Pools description: 'Pools de disponibilidad (E4). Sesiones `POOL`: los conductores no se ven entre sí.' - name: Tools description: 'Herramientas de movilidad: ETA, asignación por lotes y ruta (E5).' - name: Providers description: Provider Gateway con credenciales BYOK del tenant (E5, ADR-0028). - name: History description: Trazas, viajes y exportaciones del historial opt-in (E6). - name: Analytics description: Agregados k-anónimos y lugares aprendidos (E6/E7). - name: Edge description: Tags BLE, reportes cifrados e ingesta LPWAN (E8). - name: Console description: 'Consola de desarrolladores: cuentas, organizaciones, proyectos y API keys (F15, ADR-0036). Solo con sesión de consola.' - name: Health description: Liveness y readiness. paths: /health/live: get: operationId: healthLive tags: [Health] summary: Liveness del proceso. x-gs-phase: F2 x-gs-auth: { application: none, device: none } security: [] responses: '200': description: El proceso responde. content: application/json: schema: { $ref: '#/components/schemas/HealthStatus' } /health/ready: get: operationId: healthReady tags: [Health] summary: Readiness (dependencias disponibles, no drenando). x-gs-phase: F2 x-gs-auth: { application: none, device: none } security: [] responses: '200': description: Lista para recibir tráfico. content: application/json: schema: { $ref: '#/components/schemas/ReadinessStatus' } '503': description: No lista. content: application/json: schema: { $ref: '#/components/schemas/ReadinessStatus' } /v1/tokens: post: operationId: mintToken tags: [Credentials] summary: Emitir un token de dispositivo para un subject. description: Crea o reutiliza el subject y el dispositivo. Solo server-to-server con API key. x-gs-phase: F2 x-gs-auth: { application: 'tokens:mint', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TokenRequest' } responses: '200': description: Token emitido. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/json: schema: { $ref: '#/components/schemas/TokenResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /v1/stream/tickets: post: operationId: createStreamTicket tags: [Credentials] summary: Emitir un ticket de un solo uso para abrir el stream. x-gs-phase: F4 x-gs-auth: { application: 'stream:connect', device: 'stream:connect' } security: - applicationKey: [] - deviceToken: [] responses: '201': description: Ticket emitido. content: application/json: schema: { $ref: '#/components/schemas/StreamTicketResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /v1/stream: get: operationId: openStream tags: [Credentials] summary: Upgrade a WebSocket (protocolo en docs/08_STREAM_PROTOCOL.md). description: | No es una operación REST: es el handshake WebSocket. Requiere `Upgrade: websocket` y `Sec-WebSocket-Protocol` con `gs.v1.proto` y/o `gs.v1.json`. El ticket autentica la conexión. x-gs-phase: F4 x-gs-auth: { application: 'stream:connect', device: 'stream:connect' } security: [] parameters: - name: ticket in: query required: true description: Ticket `gst_…` de un solo uso. Se redacta en todos los logs. schema: { $ref: '#/components/schemas/StreamTicket' } - name: Sec-WebSocket-Protocol in: header required: true schema: type: string examples: ['gs.v1.proto, gs.v1.json'] responses: '101': description: Switching Protocols. Continúa el protocolo de stream. '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '429': { $ref: '#/components/responses/TooManyRequests' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /v1/sessions: post: operationId: createSession tags: [Sessions] summary: Crear una sesión. x-gs-phase: F3 x-gs-auth: { application: 'sessions:write', device: 'sessions:create' } security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateSessionRequest' } responses: '201': description: Sesión creada. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/Session' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } get: operationId: listSessions tags: [Sessions] summary: Listar sesiones visibles para el principal. description: Dispositivo = sesiones donde su subject es miembro activo. Aplicación = sesiones del tenant. x-gs-phase: F3 x-gs-auth: { application: 'sessions:read', device: none } security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: status in: query required: false schema: { $ref: '#/components/schemas/SessionStatus' } - name: external_subject_id in: query required: false description: Solo aplicación. Sesiones donde ese subject es miembro activo. schema: { $ref: '#/components/schemas/ExternalSubjectId' } responses: '200': description: Página de sesiones. content: application/json: schema: { $ref: '#/components/schemas/SessionList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getSession tags: [Sessions] summary: Obtener una sesión. x-gs-phase: F3 x-gs-auth: { application: 'sessions:read', device: none } x-gs-capability: active_member security: - applicationKey: [] - deviceToken: [] responses: '200': description: Sesión. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/Session' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } patch: operationId: updateSession tags: [Sessions] summary: Actualizar una sesión activa. x-gs-phase: F3 x-gs-auth: { application: 'sessions:write', device: none } x-gs-capability: manage security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UpdateSessionRequest' } responses: '200': description: Sesión actualizada. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/Session' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '412': { $ref: '#/components/responses/PreconditionFailed' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/close: parameters: - $ref: '#/components/parameters/SessionIdPath' post: operationId: closeSession tags: [Sessions] summary: Cerrar una sesión (idempotente). x-gs-phase: F3 x-gs-auth: { application: 'sessions:write', device: none } x-gs-capability: manage security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' responses: '200': description: Sesión cerrada (o ya cerrada/expirada). content: application/json: schema: { $ref: '#/components/schemas/Session' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/leave: parameters: - $ref: '#/components/parameters/SessionIdPath' post: operationId: leaveSession tags: [Members] summary: El dispositivo abandona la sesión en nombre de su subject. x-gs-phase: F3 x-gs-auth: { application: forbidden, device: none } x-gs-capability: active_member security: - deviceToken: [] responses: '204': description: Membresía terminada. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/members: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: listMembers tags: [Members] summary: Listar miembros activos con su estado. x-gs-phase: F3 x-gs-auth: { application: 'sessions:read', device: none } x-gs-capability: read_or_manage security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Página de miembros. content: application/json: schema: { $ref: '#/components/schemas/MemberList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } post: operationId: addMember tags: [Members] summary: Agregar un subject como miembro (solo aplicación). x-gs-phase: F3 x-gs-auth: { application: 'members:write', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/AddMemberRequest' } responses: '201': description: Miembro agregado. content: application/json: schema: { $ref: '#/components/schemas/Member' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/members/{member_id}: parameters: - $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/MemberIdOrMePath' get: operationId: getMember tags: [Members] summary: Obtener un miembro (o `me`). x-gs-phase: F3 x-gs-auth: { application: 'sessions:read', device: none } x-gs-capability: read_or_manage_or_self security: - applicationKey: [] - deviceToken: [] responses: '200': description: Miembro. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/Member' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } patch: operationId: updateMember tags: [Members] summary: Cambiar capacidades o rol de un miembro. x-gs-phase: F3 x-gs-auth: { application: 'members:write', device: none } x-gs-capability: manage security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UpdateMemberRequest' } responses: '200': description: Miembro actualizado. content: application/json: schema: { $ref: '#/components/schemas/Member' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '412': { $ref: '#/components/responses/PreconditionFailed' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: revokeMember tags: [Members] summary: Revocar un miembro. x-gs-phase: F3 x-gs-auth: { application: 'members:write', device: none } x-gs-capability: manage security: - applicationKey: [] - deviceToken: [] responses: '204': description: Miembro revocado (idempotente si ya estaba revocado). '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/members/{member_id}/sharing: parameters: - $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/MemberIdOrMePath' put: operationId: setSharing tags: [Members] summary: Activar o detener la compartición de ubicación. description: | `sharing=true` solo para `member_id=me` desde un dispositivo con capacidad `publish`. `sharing=false` para `me`, para miembros con `manage`, o por la aplicación. x-gs-phase: F3 x-gs-auth: { application: 'members:write', device: 'sharing:write' } x-gs-capability: self_publish_or_manage security: - applicationKey: [] - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SharingRequest' } responses: '200': description: Estado de compartición aplicado. content: application/json: schema: { $ref: '#/components/schemas/Member' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/invitations: parameters: - $ref: '#/components/parameters/SessionIdPath' post: operationId: createInvitation tags: [Invitations] summary: Crear una invitación. El código se devuelve una sola vez. x-gs-phase: F3 x-gs-auth: { application: 'invitations:write', device: none } x-gs-capability: invite security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateInvitationRequest' } responses: '201': description: Invitación creada. content: application/json: schema: { $ref: '#/components/schemas/InvitationCreated' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } get: operationId: listInvitations tags: [Invitations] summary: Listar invitaciones (sin códigos). x-gs-phase: F3 x-gs-auth: { application: 'invitations:write', device: none } x-gs-capability: manage_or_invite_own security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Página de invitaciones. content: application/json: schema: { $ref: '#/components/schemas/InvitationList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/invitations/{invitation_id}: parameters: - $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/InvitationIdPath' delete: operationId: revokeInvitation tags: [Invitations] summary: Revocar una invitación (idempotente). x-gs-phase: F3 x-gs-auth: { application: 'invitations:write', device: none } x-gs-capability: manage_or_issuer security: - applicationKey: [] - deviceToken: [] responses: '204': description: Invitación revocada. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/invitations/redeem: post: operationId: redeemInvitation tags: [Invitations] summary: Canjear un código de invitación. x-gs-phase: F3 x-gs-auth: { application: forbidden, device: 'invitations:redeem' } security: - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RedeemInvitationRequest' } responses: '201': description: Membresía creada. content: application/json: schema: { $ref: '#/components/schemas/RedeemInvitationResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/snapshot: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getSnapshot tags: [Locations] summary: Últimas ubicaciones retenidas de los miembros que comparten. x-gs-phase: F4 x-gs-auth: { application: 'locations:read', device: none } x-gs-capability: read security: - applicationKey: [] - deviceToken: [] responses: '200': description: Snapshot (puede estar vacío). headers: Cache-Control: schema: { type: string, const: 'no-store' } content: application/json: schema: { $ref: '#/components/schemas/SessionSnapshot' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /v1/ingest/locations: post: operationId: ingestLocations tags: [Locations] summary: Ingesta HTTP de muestras (mismas reglas que el stream). x-gs-phase: F8 x-gs-auth: { application: forbidden, device: 'ingest:write' } x-gs-capability: publish_sharing_publisher security: - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/IngestRequest' } responses: '200': description: Resultado por muestra. content: application/json: schema: { $ref: '#/components/schemas/IngestResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /v1/subjects: get: operationId: findSubjects tags: [Subjects] summary: Buscar un subject por su ID externo. x-gs-phase: F3 x-gs-auth: { application: 'subjects:manage', device: forbidden } security: - applicationKey: [] parameters: - name: external_subject_id in: query required: true schema: { $ref: '#/components/schemas/ExternalSubjectId' } responses: '200': description: Lista con 0 o 1 elemento. content: application/json: schema: { $ref: '#/components/schemas/SubjectList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/subjects/{subject_id}: parameters: - $ref: '#/components/parameters/SubjectIdPath' get: operationId: getSubject tags: [Subjects] summary: Obtener un subject. x-gs-phase: F3 x-gs-auth: { application: 'subjects:manage', device: forbidden } security: - applicationKey: [] responses: '200': description: Subject. content: application/json: schema: { $ref: '#/components/schemas/Subject' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: eraseSubject tags: [Subjects] summary: Borrar un subject (derecho de supresión). description: Revoca tokens, termina membresías, borra snapshots y anonimiza auditoría. Irreversible. x-gs-phase: F10 x-gs-auth: { application: 'subjects:manage', device: forbidden } security: - applicationKey: [] responses: '204': description: Subject borrado. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/subjects/{subject_id}/export: parameters: - $ref: '#/components/parameters/SubjectIdPath' get: operationId: exportSubject tags: [Subjects] summary: Exportar los datos que GeoStream guarda de un subject. x-gs-phase: F10 x-gs-auth: { application: 'subjects:manage', device: forbidden } security: - applicationKey: [] responses: '200': description: Exportación. content: application/json: schema: { $ref: '#/components/schemas/SubjectExport' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/subjects/{subject_id}/revoke-tokens: parameters: - $ref: '#/components/parameters/SubjectIdPath' post: operationId: revokeSubjectTokens tags: [Subjects] summary: Invalidar todos los tokens del subject (incrementa token_epoch). x-gs-phase: F3 x-gs-auth: { application: 'subjects:manage', device: forbidden } security: - applicationKey: [] responses: '200': description: Época incrementada. content: application/json: schema: { $ref: '#/components/schemas/Subject' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/devices/{device_id}: parameters: - $ref: '#/components/parameters/DeviceIdPath' get: operationId: getDevice tags: [Devices] summary: Obtener un dispositivo. x-gs-phase: F3 x-gs-auth: { application: 'subjects:manage', device: none } x-gs-capability: own_subject_device security: - applicationKey: [] - deviceToken: [] responses: '200': description: Dispositivo. content: application/json: schema: { $ref: '#/components/schemas/Device' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: deactivateDevice tags: [Devices] summary: Desactivar un dispositivo (cierre de sesión del integrador). description: Invalida sus tokens, cierra sus conexiones y detiene la compartición donde sea publicador. x-gs-phase: F3 x-gs-auth: { application: 'subjects:manage', device: none } x-gs-capability: own_device security: - applicationKey: [] - deviceToken: [] responses: '204': description: Dispositivo desactivado (idempotente). '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/webhooks: post: operationId: createWebhook tags: [Webhooks] summary: Registrar un endpoint de webhooks. El secreto se devuelve una sola vez. x-gs-phase: F8 x-gs-auth: { application: 'webhooks:manage', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateWebhookRequest' } responses: '201': description: Endpoint creado. content: application/json: schema: { $ref: '#/components/schemas/WebhookEndpointCreated' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } get: operationId: listWebhooks tags: [Webhooks] summary: Listar endpoints de webhooks. x-gs-phase: F8 x-gs-auth: { application: 'webhooks:manage', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Página de endpoints. content: application/json: schema: { $ref: '#/components/schemas/WebhookList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/webhooks/{webhook_id}: parameters: - $ref: '#/components/parameters/WebhookIdPath' get: operationId: getWebhook tags: [Webhooks] summary: Obtener un endpoint de webhooks. x-gs-phase: F8 x-gs-auth: { application: 'webhooks:manage', device: forbidden } security: - applicationKey: [] responses: '200': description: Endpoint. content: application/json: schema: { $ref: '#/components/schemas/WebhookEndpoint' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: deleteWebhook tags: [Webhooks] summary: Eliminar un endpoint de webhooks. x-gs-phase: F8 x-gs-auth: { application: 'webhooks:manage', device: forbidden } security: - applicationKey: [] responses: '204': description: Eliminado. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/webhooks/{webhook_id}/test: parameters: - $ref: '#/components/parameters/WebhookIdPath' post: operationId: testWebhook tags: [Webhooks] summary: Encolar un evento `webhook.test` para el endpoint. x-gs-phase: F8 x-gs-auth: { application: 'webhooks:manage', device: forbidden } security: - applicationKey: [] responses: '202': description: Evento de prueba encolado. content: application/json: schema: type: object required: [event_id] properties: event_id: { $ref: '#/components/schemas/EventId' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/geofences: post: operationId: createGeofence tags: [Geofences] summary: Crear una geocerca de tenant o de sesión. x-gs-phase: F11 x-gs-auth: { application: 'geofences:manage', device: none } x-gs-capability: manage_session_scoped_only security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateGeofenceRequest' } responses: '201': description: Geocerca creada. content: application/json: schema: { $ref: '#/components/schemas/Geofence' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } get: operationId: listGeofences tags: [Geofences] summary: Listar geocercas. description: Aplicación = todas las del tenant (filtrable por sesión). Dispositivo = solo las de una sesión donde tiene `manage` (session_id obligatorio). x-gs-phase: F11 x-gs-auth: { application: 'geofences:manage', device: none } x-gs-capability: manage_session_scoped_only security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: session_id in: query required: false schema: { $ref: '#/components/schemas/SessionId' } responses: '200': description: Página de geocercas. content: application/json: schema: { $ref: '#/components/schemas/GeofenceList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/geofences/{geofence_id}: parameters: - $ref: '#/components/parameters/GeofenceIdPath' get: operationId: getGeofence tags: [Geofences] summary: Obtener una geocerca. x-gs-phase: F11 x-gs-auth: { application: 'geofences:manage', device: none } x-gs-capability: manage_session_scoped_only security: - applicationKey: [] - deviceToken: [] responses: '200': description: Geocerca. content: application/json: schema: { $ref: '#/components/schemas/Geofence' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: deleteGeofence tags: [Geofences] summary: Eliminar una geocerca. x-gs-phase: F11 x-gs-auth: { application: 'geofences:manage', device: none } x-gs-capability: manage_session_scoped_only security: - applicationKey: [] - deviceToken: [] responses: '204': description: Eliminada. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } # ========================================================================== # Pista E — motor de ubicación y movilidad. # # Todas estas operaciones son ADITIVAS bajo /v1 (docs/07_REST_API.md §11): # no renumeran ni rompen ningún campo existente. Un cliente escrito antes de # la pista E sigue funcionando sin cambios (NFR-COMPAT-003). # # Cada capacidad nueva está detrás de su interruptor de política del tenant # (docs/17_CONFIGURATION.md §4.1). Sin él, la respuesta es 403 FEATURE_DISABLED: # el permiso es correcto, la capacidad no está contratada. # ========================================================================== /v1/pools: post: operationId: createPool tags: [Pools] summary: Crear un pool de disponibilidad. description: | Alias de sugar sintáctico de `POST /v1/sessions` con `mode = POOL` forzado. No duplica reglas: la respuesta es la misma `Session` con su `ETag`. Un pool es una sesión de disponibilidad, no un concepto nuevo: reutiliza membresías, consentimiento, revocación en vivo, publicador único, secuencias y snapshot (docs/05_DOMAIN_MODEL.md §4.2.1). En un pool, ningún miembro **dispositivo** puede tener `read`, `invite` ni `manage` (INV-POOL-1): los conductores no se ven entre sí. x-gs-phase: E0 x-gs-auth: { application: 'pools:manage', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreatePoolRequest' } responses: '201': description: Pool creado. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/Session' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getPool tags: [Pools] summary: Obtener un pool (su `Session` con `mode = POOL`). x-gs-phase: E0 x-gs-auth: { application: 'sessions:read', device: forbidden } security: - applicationKey: [] responses: '200': description: Pool. content: application/json: schema: { $ref: '#/components/schemas/Session' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}/nearby: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getPoolNearby tags: [Pools] summary: Miembros conectados cerca de un punto. description: | Solo aplicación. Con `rank = eta` se aplica el prefiltrado `pool_eta_prefilter` antes de estimar el tiempo de llegada. Devuelve `member_id`, `distance_m`, `age_ms`, `availability`, `attributes`, `position` y, con `rank = eta`, `eta_s` y `mode`. Excluye a quien está `PAUSED` o desconectado, salvo que `max_age_s` lo permita explícitamente. x-gs-phase: E0 x-gs-auth: { application: 'pools:query', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/Latitude' - $ref: '#/components/parameters/Longitude' - name: radius_m in: query required: true schema: { type: number, format: double, minimum: 1 } - name: limit in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 20 } - name: rank in: query required: false schema: { type: string, enum: [distance, eta], default: distance } - name: max_age_s in: query required: false schema: { type: integer, minimum: 0, default: 0 } - name: filter.attributes in: query required: false description: Filtro de igualdad sobre atributos; hasta 3 claves `k=v` separadas por `&`. schema: { type: string } responses: '200': description: Miembros cercanos. content: application/json: schema: { $ref: '#/components/schemas/NearbyResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}/rings: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getPoolRings tags: [Pools] summary: Anillos concéntricos de miembros alrededor de un punto. description: Exactamente uno de `ring_step_m` o `k`. `per_ring_limit` acota cada anillo. x-gs-phase: E0 x-gs-auth: { application: 'pools:query', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/Latitude' - $ref: '#/components/parameters/Longitude' - name: ring_step_m in: query required: false schema: { type: number, format: double, minimum: 1 } - name: k in: query required: false schema: { type: integer, minimum: 1 } - name: per_ring_limit in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 20 } responses: '200': description: Anillos. content: application/json: schema: { $ref: '#/components/schemas/RingsResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}/density: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getPoolDensity tags: [Pools] summary: Marcadores aproximados y anónimos de densidad. description: | Única consulta de pool accesible a un dispositivo, y existe precisamente para que un pasajero NO necesite ver conductores individuales (FR-POOL-006). Sin `member_id`, sin `subject_id`, sin `device_id` y sin marcas de tiempo exactas. Cada marcador lleva un conteo, un centroide de celda H3 desplazado de forma aleatoria pero acotada, rumbo cuantizado a 8 sectores y `delay_ms >= pool_density_delay_s`. x-gs-phase: E0 x-gs-auth: { application: 'pools:query', device: 'pools:density' } security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/Latitude' - $ref: '#/components/parameters/Longitude' - name: radius_m in: query required: true schema: { type: number, format: double, minimum: 1 } - name: resolution in: query required: false schema: { type: integer, minimum: 7, maximum: 10 } responses: '200': description: Marcadores de densidad. content: application/json: schema: { $ref: '#/components/schemas/DensityResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}/members/{member_id}/availability: parameters: - $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/MemberIdPath' put: operationId: setOwnAvailability tags: [Pools] summary: Declarar la disponibilidad de un miembro del pool. description: | Un dispositivo solo puede cambiar la suya (`me`). La aplicación con `pools:manage` puede cambiar la de cualquier miembro. `availability` y `attributes` **solo** se proyectan a principales de aplicación; para una conexión de dispositivo se omiten siempre (INV-PRS-2). x-gs-phase: E0 x-gs-auth: { application: none, device: 'sharing:write' } security: - applicationKey: [] - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SetAvailabilityRequest' } responses: '200': description: Disponibilidad actualizada. content: application/json: schema: { $ref: '#/components/schemas/Member' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}/members/{member_id}/attributes: parameters: - $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/MemberIdPath' put: operationId: setPoolMemberAttributes tags: [Pools] summary: Declarar los atributos de un miembro del pool (solo aplicación). description: | Ejemplos: `vehicle_type = van`, `seats = 4`. Hasta `pool_max_attributes` entradas; clave `^[a-z0-9_]{1,32}$`, valor de 64 caracteres como máximo. x-gs-phase: E0 x-gs-auth: { application: 'pools:manage', device: forbidden } security: - applicationKey: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SetAttributesRequest' } responses: '200': description: Atributos actualizados. content: application/json: schema: { $ref: '#/components/schemas/Member' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/pools/{session_id}/reset-odometer: parameters: - $ref: '#/components/parameters/SessionIdPath' post: operationId: resetOdometer tags: [Pools] summary: Reiniciar el odómetro estimado de un miembro. x-gs-phase: E0 x-gs-auth: { application: 'members:write', device: forbidden } security: - applicationKey: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ResetOdometerRequest' } responses: '200': description: Odómetro reiniciado a 0. content: application/json: schema: { $ref: '#/components/schemas/Odometer' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/tools/eta: post: operationId: estimateEta tags: [Tools] summary: Matriz de tiempos de llegada origen-destino. description: | `source_preference` elige la fuente. Con `PROVIDER` y el proveedor caído la respuesta es `503 PROVIDER_UNAVAILABLE` con `Retry-After`: **nunca** se degrada silenciosamente a la estimación propia, porque el integrador pidió el proveedor explícitamente. x-gs-phase: E0 x-gs-auth: { application: 'tools:use', device: 'tools:use' } security: - applicationKey: [] - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/EtaRequest' } responses: '200': description: Matriz de ETAs. content: application/json: schema: { $ref: '#/components/schemas/EtaResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '503': { $ref: '#/components/responses/ProviderUnavailable' } '500': { $ref: '#/components/responses/InternalError' } /v1/tools/assignments: post: operationId: assignBatch tags: [Tools] summary: Asignación por lotes de tareas a trabajadores. description: | `algorithm = hungarian` exige `capacity = 1`. `min_cost_flow` admite capacidades mayores. `size <= assignment_max_size`. x-gs-phase: E0 x-gs-auth: { application: 'tools:use', device: 'tools:use' } security: - applicationKey: [] - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/AssignmentRequest' } responses: '200': description: Asignación. content: application/json: schema: { $ref: '#/components/schemas/AssignmentResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '503': { $ref: '#/components/responses/ProviderUnavailable' } '500': { $ref: '#/components/responses/InternalError' } /v1/tools/routes: post: operationId: computeRoute tags: [Tools] summary: Ruta sobre el grafo vial propio o por Provider Gateway. description: | Si `GS_GRAPH_PATHS` tiene grafos cargados, la ruta se calcula localmente (ADR-0032) y ninguna coordenada sale de la infraestructura del tenant. Si no, se delega en el proveedor con la credencial BYOK del tenant. x-gs-phase: E0 x-gs-auth: { application: 'tools:use', device: forbidden } security: - applicationKey: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RouteRequest' } responses: '200': description: Ruta. content: application/json: schema: { $ref: '#/components/schemas/RouteResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '503': { $ref: '#/components/responses/ProviderUnavailable' } '500': { $ref: '#/components/responses/InternalError' } /v1/providers/{port}: parameters: - $ref: '#/components/parameters/ProviderPortPath' get: operationId: getProvider tags: [Providers] summary: Consultar la configuración de un puerto del Provider Gateway. description: | La respuesta **nunca** incluye la credencial: solo su versión y la fecha de la última rotación. x-gs-phase: E0 x-gs-auth: { application: 'providers:manage', device: forbidden } security: - applicationKey: [] responses: '200': description: Configuración del puerto. content: application/json: schema: { $ref: '#/components/schemas/Provider' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } put: operationId: putProvider tags: [Providers] summary: Configurar o rotar un proveedor (BYOK). description: | La credencial es **del tenant** y se cifra en reposo con `GS_DATA_ENCRYPTION_KEY`. Repetirla la rota. `terms.checked_on` es la fecha en la que el tenant confirmó los términos del proveedor. Efecto inmediato: rotar o borrar invalida el uso de esa credencial en el gateway (docs/06_IDENTITY_AUTH.md §7). x-gs-phase: E0 x-gs-auth: { application: 'providers:manage', device: forbidden } security: - applicationKey: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PutProviderRequest' } responses: '200': description: Proveedor configurado. content: application/json: schema: { $ref: '#/components/schemas/Provider' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: deleteProvider tags: [Providers] summary: Retirar un proveedor. x-gs-phase: E0 x-gs-auth: { application: 'providers:manage', device: forbidden } security: - applicationKey: [] responses: '204': description: Proveedor retirado. Idempotente. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/history: parameters: - $ref: '#/components/parameters/SessionIdPath' post: operationId: ingestHistory tags: [History] summary: Ingestar muestras retroactivas de historial. description: | Exige triple llave (docs/24_HISTORY_ANALYTICS.md §1): política del tenant con `history_allowed`, sesión con `history_mode != OFF` y `HISTORY` en los `consent_purposes` del propio subject. La antigüedad máxima es `history_backfill_max_age_s`. Aceptar más es escribir historia inventada. x-gs-phase: E0 x-gs-auth: { application: forbidden, device: 'ingest:write' } x-gs-capability: publish_and_sharing security: - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/IngestHistoryRequest' } responses: '200': description: Resultados por muestra, alineados con `samples`. content: application/json: schema: { $ref: '#/components/schemas/IngestHistoryResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/trace: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: getTrace tags: [History] summary: Polilínea del historial de una sesión. description: | `format = gsp1` devuelve la polilínea en formato compacto; `simplify_m` aplica Douglas-Peucker. La simplificación nunca aumenta la tolerancia de `trip_simplify_tolerance_m` por debajo de lo acordado. Un dispositivo solo puede leer la traza de una sesión donde es miembro con `read`, la sesión está `ACTIVE` y `HISTORY` está en sus `consent_purposes`. x-gs-phase: E0 x-gs-auth: { application: 'history:read', device: none } x-gs-capability: read_and_session_active security: - applicationKey: [] - deviceToken: [] parameters: - name: from in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: to in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: simplify_m in: query required: false schema: { type: number, format: double, minimum: 0, maximum: 1000, default: 0 } - name: format in: query required: false schema: { type: string, enum: [json, polyline, gsp1], default: json } responses: '200': description: Traza. content: application/json: schema: { $ref: '#/components/schemas/TraceResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/sessions/{session_id}/trips: parameters: - $ref: '#/components/parameters/SessionIdPath' get: operationId: listTrips tags: [History] summary: Listar los viajes derivados de una sesión. x-gs-phase: E0 x-gs-auth: { application: 'history:read', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Página de viajes. content: application/json: schema: { $ref: '#/components/schemas/TripList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/trips/{trip_id}: parameters: - $ref: '#/components/parameters/TripIdPath' get: operationId: getTrip tags: [History] summary: Obtener un viaje con su polilínea y sus paradas. x-gs-phase: E0 x-gs-auth: { application: 'history:read', device: forbidden } security: - applicationKey: [] responses: '200': description: Viaje. content: application/json: schema: { $ref: '#/components/schemas/Trip' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/analytics/heatmap: get: operationId: getHeatmap tags: [Analytics] summary: Mapa de calor k-anónimo. description: | `metric` ∈ {presence, stops, speed}. Toda respuesta incluye `k_anonymity_applied` y `suppressed_cells` para que el integrador sepa qué se ocultó, **sin revelar los valores suprimidos** (INV-HIS-3). x-gs-phase: E0 x-gs-auth: { application: 'analytics:read', device: forbidden } security: - applicationKey: [] parameters: - name: bbox in: query required: true description: '`minLon,minLat,maxLon,maxLat`' schema: { type: string } - name: from in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: to in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: metric in: query required: false schema: { type: string, enum: [presence, stops, speed], default: presence } - name: resolution in: query required: false schema: { type: integer, minimum: 5, maximum: 12 } responses: '200': description: Mapa de calor. content: application/json: schema: { $ref: '#/components/schemas/HeatmapResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/analytics/od-matrix: get: operationId: getOdMatrix tags: [Analytics] summary: Matriz origen-destino k-anónima. x-gs-phase: E0 x-gs-auth: { application: 'analytics:read', device: forbidden } security: - applicationKey: [] parameters: - name: from in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: to in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: resolution in: query required: false schema: { type: integer, minimum: 4, maximum: 12 } responses: '200': description: Matriz OD. content: application/json: schema: { $ref: '#/components/schemas/OdMatrixResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/analytics/speed-profile: get: operationId: getSpeedProfile tags: [Analytics] summary: Percentiles de velocidad por celda y franja horaria. description: Exactamente uno de `cell` o `bbox`. Supresión k-anónima por celda. x-gs-phase: E0 x-gs-auth: { application: 'analytics:read', device: forbidden } security: - applicationKey: [] parameters: - name: cell in: query required: false schema: { type: string } - name: bbox in: query required: false schema: { type: string } - name: resolution in: query required: true schema: { type: integer, minimum: 5, maximum: 12 } - name: from in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } - name: to in: query required: true schema: { $ref: '#/components/schemas/Timestamp' } responses: '200': description: Perfil de velocidad. content: application/json: schema: { $ref: '#/components/schemas/SpeedProfileResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/analytics/mobility-profile: get: operationId: getMobilityProfile tags: [Analytics] summary: Perfil de movilidad de un sujeto o de una cohorte. description: | Para un sujeto concreto exige que ese subject haya otorgado `ANALYTICS`. Un tenant NO puede otorgarlo en nombre de otro (INV-SHR-5): intentarlo devuelve `403 PERMISSION_DENIED`, no un resultado vacío. x-gs-phase: E0 x-gs-auth: { application: 'analytics:read', device: forbidden } security: - applicationKey: [] parameters: - name: subject_id in: query required: false description: Si se omite, devuelve el perfil agregado k-anónimo del tenant. schema: { $ref: '#/components/schemas/SubjectId' } responses: '200': description: Perfil de movilidad. content: application/json: schema: { $ref: '#/components/schemas/MobilityProfileResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/analytics/places: get: operationId: getPlaces tags: [Analytics] summary: Lugares aprendidos (DBSCAN sobre centros de parada). description: | Derivados y **anónimos**: no guardan IDs de subject ni de sesión, y se suprimen los que no alcanzan `analytics_k_anonymity` subjects distintos (INV-HIS-3). x-gs-phase: E0 x-gs-auth: { application: 'analytics:read', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/Latitude' - $ref: '#/components/parameters/Longitude' - name: radius_m in: query required: true schema: { type: number, format: double, minimum: 1 } - name: limit in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 20 } responses: '200': description: Lugares aprendidos. content: application/json: schema: { $ref: '#/components/schemas/PlacesResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/exports: post: operationId: createExport tags: [History] summary: Solicitar una exportación asíncrona de historial. x-gs-phase: E0 x-gs-auth: { application: 'history:read', device: forbidden } security: - applicationKey: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateExportRequest' } responses: '202': description: Exportación en curso. content: application/json: schema: { $ref: '#/components/schemas/Export' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/exports/{export_id}: parameters: - $ref: '#/components/parameters/ExportIdPath' get: operationId: getExport tags: [History] summary: Estado de una exportación y URL de descarga de TTL corto. x-gs-phase: E0 x-gs-auth: { application: 'history:read', device: forbidden } security: - applicationKey: [] responses: '200': description: Exportación. content: application/json: schema: { $ref: '#/components/schemas/Export' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/tags: post: operationId: createTag tags: [Edge] summary: Registrar un tag BLE. description: | El cuerpo **nunca** acepta ni devuelve la `EIK`: la clave de instalación vive en el tag y en el dispositivo dueño, y el servidor no la recibe (INV-EDGE-1, docs/25_EDGE_DEVICES.md §2.2). x-gs-phase: E0 x-gs-auth: { application: 'tags:manage', device: 'tags:manage' } security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateTagRequest' } responses: '201': description: Tag registrado. content: application/json: schema: { $ref: '#/components/schemas/Tag' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } get: operationId: listTags tags: [Edge] summary: Listar tags. x-gs-phase: E0 x-gs-auth: { application: 'tags:manage', device: 'tags:manage' } security: - applicationKey: [] - deviceToken: [] parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: state in: query required: false schema: { type: string, enum: [ACTIVE, SEPARATED, REMOVED] } responses: '200': description: Página de tags. content: application/json: schema: { $ref: '#/components/schemas/TagList' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/tags/{tag_id}: parameters: - $ref: '#/components/parameters/TagIdPath' get: operationId: getTag tags: [Edge] summary: Obtener un tag. x-gs-phase: E0 x-gs-auth: { application: 'tags:manage', device: 'tags:manage' } security: - applicationKey: [] - deviceToken: [] responses: '200': description: Tag. content: application/json: schema: { $ref: '#/components/schemas/Tag' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: deleteTag tags: [Edge] summary: Dar de baja un tag. x-gs-phase: E0 x-gs-auth: { application: 'tags:manage', device: 'tags:manage' } security: - applicationKey: [] - deviceToken: [] responses: '204': description: Tag dado de baja. Idempotente. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/edge/reports: post: operationId: postEdgeReport tags: [Edge] summary: Enviar sightings cifrados de un tag. description: | El blob es opaco para el servidor: se almacena tal cual, con TTL, y solo el dueño del tag puede descifrarlo. La coherencia temporal es obligatoria (`time_bucket` de resolución horaria, no más antigua que `edge_rotation_s`). El reporte **no** almacena `device_id` ni `subject_id` del buscador junto al blob (T-28): solo un `hashed_eid` y una marca temporal gruesa. x-gs-phase: E0 x-gs-auth: { application: forbidden, device: 'edge:report' } security: - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/EdgeReportRequest' } responses: '202': description: Reporte aceptado para almacenamiento opaco. '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/edge/reports/lookup: post: operationId: lookupEdgeReports tags: [Edge] summary: Consultar los reportes de los tags propios. description: | El servidor entrega los blobs **sin poder descifrarlos**: el descifrado ocurre en el cliente del dueño, con la `EIK`. Por eso el coste está acotado por `edge_lookup_max_ids` y por un bucket de tasa por (dueño, `hashed_eid`). x-gs-phase: E0 x-gs-auth: { application: forbidden, device: 'edge:lookup' } security: - deviceToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/EdgeReportsLookupRequest' } responses: '200': description: Reportes recuperados por el cliente. content: application/json: schema: { $ref: '#/components/schemas/EdgeReportsLookupResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/edge/lpwan/{integration_id}/webhook: parameters: - $ref: '#/components/parameters/LpwanIntegrationIdPath' post: operationId: ingestLpwan tags: [Edge] summary: Ingesta de posiciones de una red LPWAN. description: | No usa scopes: se autentica con la **firma HMAC de la integración** sobre el cuerpo bruto, con el secreto cuyo hash guarda `edge_lpwan_integrations`. Cada modelo registrado tiene su decodificador; un modelo sin decodificador devuelve `422`, nunca un best-effort con datos mal interpretados. x-gs-phase: E0 x-gs-auth: { application: none, device: forbidden } security: [] parameters: - name: X-Gs-Lpwan-Signature in: header required: true schema: { type: string } - name: X-Gs-Lpwan-Timestamp in: header required: true schema: { type: string } requestBody: required: true content: application/json: schema: { type: object } responses: '202': description: Posiciones aceptadas. content: application/json: schema: { $ref: '#/components/schemas/LpwanIngestResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } # ---------- Consola de desarrolladores (F15, ADR-0033 §3, ADR-0036) ---------- /v1/console/auth/code: post: operationId: requestConsoleCode tags: [Console] summary: Pedir un código de un solo uso por correo. description: 'Registro e inicio de sesión son la misma ceremonia (ADR-0036 §2). Responde siempre 202 con el mismo cuerpo, exista o no la cuenta: no revela qué correos están registrados. Si se pidió otro código hace menos de `GS_CONSOLE_OTP_COOLDOWN_S`, no se envía correo y la respuesta es la misma.' x-gs-phase: F15 x-gs-auth: { application: none, device: none } x-gs-console: none security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ConsoleCodeRequest' } responses: '202': description: Aceptado; si el correo es válido recibirá un código. content: application/json: schema: { $ref: '#/components/schemas/ConsoleCodeAccepted' } '400': { $ref: '#/components/responses/BadRequest' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/auth/verify: post: operationId: verifyConsoleCode tags: [Console] summary: Canjear el código de registro, fijar la contraseña y abrir sesión. description: 'Segundo paso de crear una cuenta o de recuperar el acceso a una existente. Si la cuenta no existe se crea (exige `accept_terms: true`); si ya existe, `password` **sustituye** a la contraseña (el control del correo es la prueba, ADR-0037). La contraseña se valida antes de gastar el código: si no cumple la política, responde 422 y el mismo código sigue valiendo. Todo fallo del código —incorrecto, caducado, ya usado o con intentos agotados— responde igual: 401 `UNAUTHENTICATED`.' x-gs-phase: F15 x-gs-auth: { application: none, device: none } x-gs-console: none security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ConsoleVerifyRequest' } responses: '200': description: Sesión abierta. content: application/json: schema: { $ref: '#/components/schemas/ConsoleSession' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/auth/login: post: operationId: requestConsoleLogin tags: [Console] summary: Primer paso de iniciar sesión, con correo y contraseña. description: 'Si la contraseña es correcta, envía por correo el código del segundo paso. Responde siempre 202 con el mismo cuerpo —contraseña incorrecta, cuenta inexistente, cuenta sin contraseña o deshabilitada—: no revela qué correos están registrados. Una cuenta inexistente cuesta el mismo trabajo que una existente.' x-gs-phase: F15 x-gs-auth: { application: none, device: none } x-gs-console: none security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ConsoleLoginRequest' } responses: '202': description: Aceptado; si los datos son correctos recibirá un código. content: application/json: schema: { $ref: '#/components/schemas/ConsoleCodeAccepted' } '400': { $ref: '#/components/responses/BadRequest' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/auth/login/verify: post: operationId: verifyConsoleLogin tags: [Console] summary: Segundo paso de iniciar sesión, con el código recibido. description: 'Canjea el código enviado tras una contraseña correcta y abre la sesión. Un código de registro no sirve aquí, ni uno de acceso sirve para registrarse. Todo fallo responde igual: 401 `UNAUTHENTICATED`.' x-gs-phase: F15 x-gs-auth: { application: none, device: none } x-gs-console: none security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ConsoleLoginVerifyRequest' } responses: '200': description: Sesión abierta. content: application/json: schema: { $ref: '#/components/schemas/ConsoleSession' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/auth/logout: post: operationId: logoutConsole tags: [Console] summary: Cerrar la sesión de consola. x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: session security: - consoleSession: [] responses: '204': description: Sesión revocada. '401': { $ref: '#/components/responses/Unauthorized' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/me: get: operationId: getConsoleAccount tags: [Console] summary: Cuenta de la sesión. x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: session security: - consoleSession: [] responses: '200': description: Cuenta. content: application/json: schema: { $ref: '#/components/schemas/ConsoleAccount' } '401': { $ref: '#/components/responses/Unauthorized' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } delete: operationId: deleteConsoleAccount tags: [Console] summary: Borrar la cuenta y sus datos personales. description: 'Elimina el correo y las sesiones. Rechaza con 409 si la cuenta es la única `OWNER` de una organización con proyectos activos: hay que transferir o deshabilitar antes.' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: session security: - consoleSession: [] responses: '204': description: Cuenta borrada. '401': { $ref: '#/components/responses/Unauthorized' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/me/password: put: operationId: changeConsolePassword tags: [Console] summary: Cambiar la contraseña de la cuenta. description: 'Exige la contraseña actual (una cuenta que nunca tuvo una queda exenta: su sesión es la prueba) y aplica la política. Cierra todas las demás sesiones de la cuenta; la que cambia la contraseña sigue abierta. Contraseña actual incorrecta: 403 `PERMISSION_DENIED`.' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: session security: - consoleSession: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ChangeConsolePasswordRequest' } responses: '204': description: Contraseña cambiada. '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/organizations: get: operationId: listConsoleOrganizations tags: [Console] summary: Organizaciones de la cuenta. x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: session security: - consoleSession: [] responses: '200': description: Organizaciones. content: application/json: schema: { $ref: '#/components/schemas/ConsoleOrganizationList' } '401': { $ref: '#/components/responses/Unauthorized' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } post: operationId: createConsoleOrganization tags: [Console] summary: Crear una organización. description: 'Crea un tenant con la política por defecto (capacidades opcionales apagadas) y hace `OWNER` a la cuenta. Limitado por `GS_CONSOLE_MAX_ORGS_PER_ACCOUNT`: al superarlo responde 429.' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: session security: - consoleSession: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateConsoleOrganizationRequest' } responses: '201': description: Organización creada. content: application/json: schema: { $ref: '#/components/schemas/ConsoleOrganization' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/organizations/{organization_id}: get: operationId: getConsoleOrganization tags: [Console] summary: Una organización. x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: VIEWER security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' responses: '200': description: Organización. content: application/json: schema: { $ref: '#/components/schemas/ConsoleOrganization' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/organizations/{organization_id}/projects: get: operationId: listConsoleProjects tags: [Console] summary: Proyectos de una organización. x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: VIEWER security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' responses: '200': description: Proyectos. content: application/json: schema: { $ref: '#/components/schemas/ConsoleProjectList' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } post: operationId: createConsoleProject tags: [Console] summary: Crear un proyecto. description: 'Un proyecto es una aplicación del tenant. Los scopes de dispositivo que se le permiten acotan los que sus tokens podrán pedir.' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: ADMIN security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateConsoleProjectRequest' } responses: '201': description: Proyecto creado. content: application/json: schema: { $ref: '#/components/schemas/ConsoleProject' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/organizations/{organization_id}/projects/{project_id}: get: operationId: getConsoleProject tags: [Console] summary: Un proyecto. x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: VIEWER security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' - $ref: '#/components/parameters/ProjectIdPath' responses: '200': description: Proyecto. content: application/json: schema: { $ref: '#/components/schemas/ConsoleProject' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } patch: operationId: updateConsoleProject tags: [Console] summary: Cambiar nombre, estado, orígenes o scopes de dispositivo. description: 'Deshabilitar un proyecto bloquea su REST y cierra sus conexiones de stream (FR-ADM-003).' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: ADMIN security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' - $ref: '#/components/parameters/ProjectIdPath' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UpdateConsoleProjectRequest' } responses: '200': description: Proyecto actualizado. content: application/json: schema: { $ref: '#/components/schemas/ConsoleProject' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/organizations/{organization_id}/projects/{project_id}/keys: get: operationId: listConsoleApiKeys tags: [Console] summary: API keys de un proyecto (sin secretos). x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: VIEWER security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' - $ref: '#/components/parameters/ProjectIdPath' responses: '200': description: API keys. content: application/json: schema: { $ref: '#/components/schemas/ConsoleApiKeyList' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } post: operationId: createConsoleApiKey tags: [Console] summary: Crear una API key. description: 'La key en claro (`secret`) se devuelve **una sola vez**, solo en esta respuesta: no se guarda y ninguna otra operación vuelve a mostrarla. Sin `scopes` explícitos no se crea la key: se concede un scope a uno (`06` §3.4). `locations:read` y los de la pista E exigen pedirse expresamente.' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: ADMIN security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' - $ref: '#/components/parameters/ProjectIdPath' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateConsoleApiKeyRequest' } responses: '201': description: API key creada. headers: Cache-Control: { schema: { type: string, enum: [no-store] } } content: application/json: schema: { $ref: '#/components/schemas/ConsoleApiKeyCreated' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/UnprocessableEntity' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } /v1/console/organizations/{organization_id}/projects/{project_id}/keys/{key_id}: delete: operationId: revokeConsoleApiKey tags: [Console] summary: Revocar una API key. description: 'Deja de autenticar en el plazo de NFR-SEC-002.' x-gs-phase: F15 x-gs-auth: { application: forbidden, device: forbidden } x-gs-console: ADMIN security: - consoleSession: [] parameters: - $ref: '#/components/parameters/OrganizationIdPath' - $ref: '#/components/parameters/ProjectIdPath' - $ref: '#/components/parameters/ConsoleKeyIdPath' responses: '204': description: API key revocada. '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/TooManyRequests' } '500': { $ref: '#/components/responses/InternalError' } webhooks: sessionEvent: post: operationId: onSessionEvent summary: session.created | session.updated | session.closed | session.expired x-gs-phase: F8 security: [] # el receptor verifica la firma HMAC (docs/07_REST_API.md §10.3) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SessionWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. memberEvent: post: operationId: onMemberEvent summary: member.joined | member.updated | member.left | member.revoked x-gs-phase: F8 security: [] # el receptor verifica la firma HMAC (docs/07_REST_API.md §10.3) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/MemberWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. sharingEvent: post: operationId: onSharingEvent summary: sharing.started | sharing.stopped x-gs-phase: F8 security: [] # el receptor verifica la firma HMAC (docs/07_REST_API.md §10.3) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SharingWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. geofenceEvent: post: operationId: onGeofenceEvent summary: geofence.entered | geofence.exited | geofence.dwell x-gs-phase: F11 security: [] # el receptor verifica la firma HMAC (docs/07_REST_API.md §10.3) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/GeofenceWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. trackEvent: post: operationId: onTrackEvent summary: track.stop_started | track.stop_ended x-gs-phase: E0 description: | Sin coordenadas y **sin centroide de parada**: un webhook describe qué pasó, no dónde (FR-WHK-005, docs/10 §8.2). security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TrackWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. tripEvent: post: operationId: onTripEvent summary: trip.completed x-gs-phase: E0 description: Sin polilínea ni paradas con coordenadas. security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TripWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. poolEvent: post: operationId: onPoolEvent summary: pool.member_online | pool.member_offline | pool.member_availability_changed x-gs-phase: E0 security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PoolWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. edgeEvent: post: operationId: onEdgeEvent summary: edge.tag_registered | edge.tag_removed x-gs-phase: E0 description: Nunca incluye la `EIK` (INV-EDGE-1). security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/EdgeWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. testEvent: post: operationId: onTestEvent summary: webhook.test x-gs-phase: F8 security: [] # el receptor verifica la firma HMAC (docs/07_REST_API.md §10.3) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TestWebhookEvent' } responses: '200': description: Cualquier 2xx confirma la entrega. components: securitySchemes: applicationKey: type: http scheme: bearer bearerFormat: 'gsk_<32hex>.<43 base64url>' description: API key de aplicación. Solo server-to-server. Nunca en apps. deviceToken: type: http scheme: bearer bearerFormat: JWT description: Token de dispositivo EdDSA emitido por POST /v1/tokens. consoleSession: type: http scheme: bearer bearerFormat: 'gcs_<43 base64url>' description: Sesión de consola (token opaco) emitida por POST /v1/console/auth/verify. No sirve para ninguna otra ruta. headers: RequestId: description: Identificador opaco de correlación (req_…). schema: { type: string, pattern: '^req_[0-9a-f]{32}$' } ETag: description: Versión del recurso, formato "v". schema: { type: string, pattern: '^"v[0-9]+"$' } RetryAfter: description: Segundos a esperar antes de reintentar. schema: { type: integer, minimum: 1 } parameters: OrganizationIdPath: name: organization_id in: path required: true description: Organización (tenant) de la consola. schema: { $ref: '#/components/schemas/TenantId' } ProjectIdPath: name: project_id in: path required: true description: Proyecto (aplicación) de la consola. schema: { $ref: '#/components/schemas/ApplicationId' } ConsoleKeyIdPath: name: key_id in: path required: true schema: { $ref: '#/components/schemas/ApiKeyId' } SessionIdPath: name: session_id in: path required: true schema: { $ref: '#/components/schemas/SessionId' } MemberIdOrMePath: name: member_id in: path required: true description: ID de miembro o el literal `me` (solo dispositivos). schema: type: string pattern: '^(me|mem_[0-9a-f]{32})$' InvitationIdPath: name: invitation_id in: path required: true schema: { $ref: '#/components/schemas/InvitationId' } SubjectIdPath: name: subject_id in: path required: true schema: { $ref: '#/components/schemas/SubjectId' } DeviceIdPath: name: device_id in: path required: true schema: { $ref: '#/components/schemas/DeviceId' } WebhookIdPath: name: webhook_id in: path required: true schema: { $ref: '#/components/schemas/WebhookId' } GeofenceIdPath: name: geofence_id in: path required: true schema: { $ref: '#/components/schemas/GeofenceId' } MemberIdPath: name: member_id in: path required: true description: ID de miembro o el literal `me` (solo dispositivos, en `availability`). schema: type: string pattern: '^(me|mem_[0-9a-f]{32})$' ProviderPortPath: name: port in: path required: true schema: { $ref: '#/components/schemas/ProviderPort' } TripIdPath: name: trip_id in: path required: true schema: { $ref: '#/components/schemas/TripId' } ExportIdPath: name: export_id in: path required: true schema: { $ref: '#/components/schemas/ExportId' } TagIdPath: name: tag_id in: path required: true schema: { $ref: '#/components/schemas/TagId' } LpwanIntegrationIdPath: name: integration_id in: path required: true schema: { $ref: '#/components/schemas/LpwanIntegrationId' } Latitude: name: lat in: query required: true schema: { type: number, format: double, minimum: -90, maximum: 90 } Longitude: name: lon in: query required: true schema: { type: number, format: double, minimum: -180, maximum: 180 } Limit: name: limit in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 50 } Cursor: name: cursor in: query required: false schema: { type: string, maxLength: 512 } IdempotencyKey: name: Idempotency-Key in: header required: false schema: { type: string, minLength: 1, maxLength: 255, pattern: '^[A-Za-z0-9_\-:.]+$' } IfMatch: name: If-Match in: header required: false schema: { type: string, pattern: '^"v[0-9]+"$' } responses: BadRequest: description: Petición malformada o protocolo no soportado. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Unauthorized: description: Credencial ausente, inválida, expirada o revocada. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Forbidden: description: Principal, scope o capacidad insuficientes; cuenta deshabilitada. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } NotFound: description: Recurso inexistente o sin relación con el principal. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Conflict: description: Estado incompatible. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } PreconditionFailed: description: If-Match no coincide. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } PayloadTooLarge: description: Cuerpo o lote demasiado grande. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } UnprocessableEntity: description: Validación fallida. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } TooManyRequests: description: Rate limit excedido. headers: Retry-After: { $ref: '#/components/headers/RetryAfter' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } InternalError: description: Error interno. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } ServiceUnavailable: description: Dependencia no disponible, drenaje o sobrecarga. headers: Retry-After: { $ref: '#/components/headers/RetryAfter' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } ProviderUnavailable: description: | Se exigía `source_preference = provider` y el proveedor no respondió. El servidor NO degrada a su estimación propia: un integrador que eligió proveedor por cobertura o por contrato necesita distinguir "no hay ruta" de "no supe" (docs/23_MOBILITY.md §4). headers: Retry-After: { $ref: '#/components/headers/RetryAfter' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } schemas: # ---------- Identificadores ---------- TenantId: { type: string, pattern: '^ten_[0-9a-f]{32}$' } AccountId: { type: string, pattern: '^acc_[0-9a-f]{32}$' } ApiKeyId: { type: string, pattern: '^key_[0-9a-f]{32}$' } ApplicationId: { type: string, pattern: '^app_[0-9a-f]{32}$' } SubjectId: { type: string, pattern: '^sub_[0-9a-f]{32}$' } DeviceId: { type: string, pattern: '^dev_[0-9a-f]{32}$' } SessionId: { type: string, pattern: '^ses_[0-9a-f]{32}$' } MemberId: { type: string, pattern: '^mem_[0-9a-f]{32}$' } InvitationId: { type: string, pattern: '^inv_[0-9a-f]{32}$' } WebhookId: { type: string, pattern: '^whk_[0-9a-f]{32}$' } EventId: { type: string, pattern: '^evt_[0-9a-f]{32}$' } GeofenceId: { type: string, pattern: '^geo_[0-9a-f]{32}$' } ExternalSubjectId: type: string pattern: '^[A-Za-z0-9._:\-]{1,128}$' description: ID opaco del integrador. No usar correos ni teléfonos. InstallationId: type: string pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' StreamTicket: { type: string, pattern: '^gst_[A-Za-z0-9_-]{43}$' } Timestamp: type: string format: date-time description: RFC 3339 UTC con sufijo Z, preferentemente con milisegundos. # ---------- Errores ---------- ErrorCode: type: string enum: - UNAUTHENTICATED - TOKEN_EXPIRED - PERMISSION_DENIED - INSUFFICIENT_SCOPE - ACCOUNT_DISABLED - NOT_FOUND - CONFLICT - VALIDATION_FAILED - INVALID_LOCATION - RATE_LIMITED - PAYLOAD_TOO_LARGE - SESSION_CLOSED - SESSION_EXPIRED - SESSION_FULL - MEMBER_REVOKED - NOT_SHARING - NOT_PUBLISHER - INVITATION_INVALID - IDEMPOTENCY_KEY_REUSED - PRECONDITION_FAILED - PROTOCOL_VERSION_UNSUPPORTED - PROTOCOL_ERROR - TOO_MANY_SUBSCRIPTIONS - TEMPORARILY_UNAVAILABLE - INTERNAL # Pista E. El permiso es correcto pero la capacidad no está contratada: # no se degrada a PERMISSION_DENIED. - FEATURE_DISABLED # Falta el propósito de consentimiento del sujeto (HISTORY / ANALYTICS). - CONSENT_REQUIRED # Se exigía source_preference = provider y el proveedor no respondió. - PROVIDER_UNAVAILABLE FieldError: type: object additionalProperties: false required: [field, reason] properties: field: { type: string, maxLength: 200 } reason: type: string enum: [required, invalid_format, out_of_range, not_finite, too_long, too_short, too_many, not_allowed, unknown_field, invalid_enum, conflict] Problem: type: object description: RFC 9457 Problem Details. required: [type, title, status, code, request_id] properties: type: { type: string, description: 'URI del tipo: https://geostream.dev/problems/' } title: { type: string, maxLength: 200 } status: { type: integer, minimum: 400, maximum: 599 } code: { $ref: '#/components/schemas/ErrorCode' } detail: { type: string, maxLength: 200 } request_id: { type: string, pattern: '^req_[0-9a-f]{32}$' } errors: type: array maxItems: 50 items: { $ref: '#/components/schemas/FieldError' } # ---------- Salud ---------- HealthStatus: type: object required: [status] properties: status: { type: string, const: ok } ReadinessStatus: type: object required: [status, checks] properties: status: { type: string, enum: [ready, not_ready] } checks: type: object additionalProperties: type: string enum: [ok, failing, draining, not_configured] # ---------- Credenciales ---------- Platform: type: string enum: [ANDROID, IOS, WEB, SERVER, OTHER] DeviceScope: type: string enum: ['sessions:create', 'invitations:redeem', 'stream:connect', 'sharing:write', 'ingest:write'] TokenRequest: type: object additionalProperties: false required: [external_subject_id, installation_id, platform] properties: external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } installation_id: { $ref: '#/components/schemas/InstallationId' } platform: { $ref: '#/components/schemas/Platform' } app_version: { type: string, maxLength: 32 } sdk_version: { type: string, maxLength: 32 } scopes: type: array uniqueItems: true items: { $ref: '#/components/schemas/DeviceScope' } ttl_seconds: { type: integer, minimum: 60, maximum: 86400, description: 'Rango efectivo: GS_TOKEN_MIN_TTL_S..GS_TOKEN_MAX_TTL_S.' } TokenResponse: type: object required: [access_token, token_type, expires_at, subject_id, device_id, scopes] properties: access_token: { type: string } token_type: { type: string, const: Bearer } expires_at: { $ref: '#/components/schemas/Timestamp' } subject_id: { $ref: '#/components/schemas/SubjectId' } device_id: { $ref: '#/components/schemas/DeviceId' } scopes: type: array items: { $ref: '#/components/schemas/DeviceScope' } StreamTicketResponse: type: object required: [ticket, expires_at, stream_url] properties: ticket: { $ref: '#/components/schemas/StreamTicket' } expires_at: { $ref: '#/components/schemas/Timestamp' } stream_url: { type: string, format: uri, description: 'wss:///v1/stream (sin query).' } # ---------- Sesiones ---------- SessionMode: type: string enum: [ONE_TO_ONE, ONE_TO_MANY, MANY_TO_MANY, POOL] description: | `POOL` (E4) es una sesión de disponibilidad, no un concepto nuevo: reutiliza membresías, consentimiento, revocación, secuencias y snapshot. En un pool ningún miembro **dispositivo** puede tener `read`, `invite` ni `manage` (INV-POOL-1). Un cliente que no conozca `POOL` debe tratarlo como modo no reconocido y no asumir nada; no es un error por sí mismo. SessionStatus: type: string enum: [ACTIVE, CLOSED, EXPIRED] ClosedReason: type: string enum: [CLOSED_BY_MEMBER, CLOSED_BY_APPLICATION, OWNER_LEFT, ADMIN] Metadata: type: object maxProperties: 32 propertyNames: { pattern: '^[a-z0-9_.-]{1,64}$' } additionalProperties: { type: string, maxLength: 512 } description: Datos opacos del integrador. No incluir datos personales. Capabilities: type: object additionalProperties: false required: [publish, read, invite, manage] properties: publish: { type: boolean } read: { type: boolean } invite: { type: boolean } manage: { type: boolean } Actor: type: object required: [type, id] properties: type: { type: string, enum: [DEVICE, APPLICATION] } id: { type: string, description: 'dev_… o app_…' } Session: type: object required: [id, mode, status, created_by, expires_at, max_members, snapshot_ttl_seconds, stale_after_seconds, metadata, active_member_count, created_at, updated_at, version] properties: id: { $ref: '#/components/schemas/SessionId' } mode: { $ref: '#/components/schemas/SessionMode' } status: { $ref: '#/components/schemas/SessionStatus' } owner_member_id: oneOf: - { $ref: '#/components/schemas/MemberId' } - { type: 'null' } created_by: { $ref: '#/components/schemas/Actor' } expires_at: { $ref: '#/components/schemas/Timestamp' } max_members: { type: integer, minimum: 2 } snapshot_ttl_seconds: { type: integer, minimum: 0 } stale_after_seconds: { type: integer, minimum: 5, maximum: 3600 } history_mode: allOf: [{ $ref: '#/components/schemas/HistoryMode' }] description: E6. `OFF` cuando el tenant no tiene historial habilitado. metadata: { $ref: '#/components/schemas/Metadata' } active_member_count: { type: integer, minimum: 0 } closed_reason: oneOf: - { $ref: '#/components/schemas/ClosedReason' } - { type: 'null' } closed_at: oneOf: - { $ref: '#/components/schemas/Timestamp' } - { type: 'null' } created_at: { $ref: '#/components/schemas/Timestamp' } updated_at: { $ref: '#/components/schemas/Timestamp' } version: { type: integer, minimum: 1 } InitialMember: type: object additionalProperties: false required: [external_subject_id] properties: external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } capabilities: { $ref: '#/components/schemas/Capabilities' } CreateSessionRequest: type: object additionalProperties: false required: [mode] properties: mode: { $ref: '#/components/schemas/SessionMode' } expires_in_seconds: { type: integer, minimum: 60 } max_members: { type: integer, minimum: 2 } snapshot_ttl_seconds: { type: integer, minimum: 0 } stale_after_seconds: { type: integer, minimum: 5, maximum: 3600 } metadata: { $ref: '#/components/schemas/Metadata' } owner_external_subject_id: $ref: '#/components/schemas/ExternalSubjectId' description: Solo aplicación. Ese subject queda como propietario. initial_members: type: array maxItems: 100 description: Solo aplicación. items: { $ref: '#/components/schemas/InitialMember' } UpdateSessionRequest: type: object additionalProperties: false minProperties: 1 properties: expires_at: { $ref: '#/components/schemas/Timestamp' } max_members: { type: integer, minimum: 2 } snapshot_ttl_seconds: { type: integer, minimum: 0 } stale_after_seconds: { type: integer, minimum: 5, maximum: 3600 } metadata: oneOf: - { $ref: '#/components/schemas/Metadata' } - { type: 'null' } SessionList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Session' } next_cursor: { type: ['string', 'null'] } # ---------- Miembros ---------- MemberRole: type: string enum: [OWNER, MEMBER] MemberStatusValue: type: string enum: [ACTIVE, LEFT, REVOKED] Connectivity: type: string enum: [UNSPECIFIED, ONLINE, OFFLINE] Member: type: object required: [id, session_id, subject_id, external_subject_id, role, capabilities, status, sharing, connectivity, joined_at, joined_via, version] properties: id: { $ref: '#/components/schemas/MemberId' } session_id: { $ref: '#/components/schemas/SessionId' } subject_id: { $ref: '#/components/schemas/SubjectId' } external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } role: { $ref: '#/components/schemas/MemberRole' } capabilities: { $ref: '#/components/schemas/Capabilities' } status: { $ref: '#/components/schemas/MemberStatusValue' } sharing: { type: boolean } connectivity: { $ref: '#/components/schemas/Connectivity' } last_location_at: oneOf: - { $ref: '#/components/schemas/Timestamp' } - { type: 'null' } joined_at: { $ref: '#/components/schemas/Timestamp' } ended_at: oneOf: - { $ref: '#/components/schemas/Timestamp' } - { type: 'null' } joined_via: { type: string, enum: [CREATOR, DIRECT_ADD, INVITATION] } purposes: type: array items: { $ref: '#/components/schemas/ConsentPurpose' } description: | E6. Solo el propio subject puede cambiarlos (INV-SHR-5). Una aplicación con `members:write` puede leerlos, pero escribirlos devuelve `403 PERMISSION_DENIED`: conceder consentimiento en nombre de otro no es una operación que exista. availability: allOf: [{ $ref: '#/components/schemas/Availability' }] description: E4. Presente solo en sesiones `POOL`. attributes: type: object description: E4. Presente solo en sesiones `POOL`. Hasta `pool_max_attributes` entradas. additionalProperties: { type: string, maxLength: 64 } version: { type: integer, minimum: 1 } MemberList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Member' } next_cursor: { type: ['string', 'null'] } AddMemberRequest: type: object additionalProperties: false required: [external_subject_id] properties: external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } role: { $ref: '#/components/schemas/MemberRole' } capabilities: { $ref: '#/components/schemas/Capabilities' } UpdateMemberRequest: type: object additionalProperties: false minProperties: 1 properties: capabilities: { $ref: '#/components/schemas/Capabilities' } role: $ref: '#/components/schemas/MemberRole' description: Solo aplicación. OWNER transfiere la propiedad. SharingRequest: type: object additionalProperties: false required: [sharing] properties: sharing: { type: boolean } purposes: type: array description: | E6. Subconjunto de {HISTORY, ANALYTICS}, sin duplicados. Solo el propio subject, desde su propio dispositivo, con `sharing:write`. Conceder `HISTORY` con `history_mode = OFF` devuelve `422 CONSENT_REQUIRED`: no se acepta un consentimiento que no puede surtir efecto. items: { $ref: '#/components/schemas/ConsentPurpose' } # ---------- Invitaciones ---------- Invitation: type: object required: [id, session_id, grants, max_uses, uses, expires_at, status, created_at] properties: id: { $ref: '#/components/schemas/InvitationId' } session_id: { $ref: '#/components/schemas/SessionId' } grants: { $ref: '#/components/schemas/Capabilities' } max_uses: { type: integer, minimum: 1 } uses: { type: integer, minimum: 0 } expires_at: { $ref: '#/components/schemas/Timestamp' } status: { type: string, enum: [ACTIVE, EXHAUSTED, EXPIRED, REVOKED] } created_by: { $ref: '#/components/schemas/Actor' } created_at: { $ref: '#/components/schemas/Timestamp' } revoked_at: oneOf: - { $ref: '#/components/schemas/Timestamp' } - { type: 'null' } InvitationCreated: allOf: - $ref: '#/components/schemas/Invitation' - type: object required: [code] properties: code: type: string pattern: '^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$' description: Se muestra solo en esta respuesta. CreateInvitationRequest: type: object additionalProperties: false properties: grants: { $ref: '#/components/schemas/Capabilities' } max_uses: { type: integer, minimum: 1 } expires_in_seconds: { type: integer, minimum: 60 } InvitationList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Invitation' } next_cursor: { type: ['string', 'null'] } RedeemInvitationRequest: type: object additionalProperties: false required: [code] properties: code: { type: string, minLength: 1, maxLength: 32 } RedeemInvitationResponse: type: object required: [session, member] properties: session: { $ref: '#/components/schemas/Session' } member: { $ref: '#/components/schemas/Member' } # ---------- Ubicación ---------- Position: type: object additionalProperties: false required: [latitude, longitude, horizontal_accuracy_m] properties: latitude: { type: number, minimum: -90, maximum: 90 } longitude: { type: number, minimum: -180, maximum: 180 } horizontal_accuracy_m: { type: number, exclusiveMinimum: 0 } altitude_m: { type: number, minimum: -1000, maximum: 50000 } vertical_accuracy_m: { type: number, exclusiveMinimum: 0, maximum: 10000 } speed_mps: { type: number, minimum: 0, maximum: 300 } speed_accuracy_mps: { type: number, exclusiveMinimum: 0, maximum: 300 } heading_deg: { type: number, minimum: 0, exclusiveMaximum: 360 } heading_accuracy_deg: { type: number, exclusiveMinimum: 0, maximum: 180 } LocationQuality: type: object additionalProperties: false required: [kind] properties: source: { type: string, enum: [UNSPECIFIED, GNSS, NETWORK, FUSED, MANUAL, EXTERNAL_DEVICE], default: UNSPECIFIED } kind: { type: string, enum: [MEASURED, FILTERED, INTERPOLATED, PREDICTED, MAP_MATCHED] } is_mock: { type: boolean } motion: { type: string, enum: [UNSPECIFIED, STATIONARY, WALKING, RUNNING, CYCLING, AUTOMOTIVE], default: UNSPECIFIED } LocationSample: type: object additionalProperties: false required: [sequence, captured_at, position, quality] properties: sequence: { type: integer, minimum: 1, maximum: 9007199254740991 } captured_at: { $ref: '#/components/schemas/Timestamp' } position: { $ref: '#/components/schemas/Position' } quality: { $ref: '#/components/schemas/LocationQuality' } SnapshotItem: type: object required: [member_id, external_subject_id, sample, received_at, age_ms] properties: member_id: { $ref: '#/components/schemas/MemberId' } external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } sample: { $ref: '#/components/schemas/LocationSample' } received_at: { $ref: '#/components/schemas/Timestamp' } age_ms: { type: integer, minimum: 0, description: 'generated_at - captured_at en milisegundos.' } estimate: allOf: [{ $ref: '#/components/schemas/LocationEstimate' }] description: | E2. Estimación canónica del motor de trayectoria. INVARIANTE P-E3: `sample` viaja **intacto** y es la verdad. `estimate` es un dato derivado y opcional; un cliente que no lo entienda sigue funcionando igual. Si el motor no pudo estimar, el campo va ausente y la lectura no se ve afectada. SessionSnapshot: type: object required: [session_id, generated_at, items] properties: session_id: { $ref: '#/components/schemas/SessionId' } generated_at: { $ref: '#/components/schemas/Timestamp' } items: type: array items: { $ref: '#/components/schemas/SnapshotItem' } SampleStatus: type: string enum: [ACCEPTED, IGNORED_DUPLICATE, IGNORED_STALE, REJECTED] SampleResult: type: object required: [sequence, status] properties: sequence: { type: integer, minimum: 0 } status: { $ref: '#/components/schemas/SampleStatus' } error_code: oneOf: - { $ref: '#/components/schemas/ErrorCode' } - { type: 'null' } field: { type: ['string', 'null'] } IngestRequest: type: object additionalProperties: false required: [session_id, samples] properties: session_id: { $ref: '#/components/schemas/SessionId' } samples: type: array minItems: 1 maxItems: 50 items: { $ref: '#/components/schemas/LocationSample' } IngestResponse: type: object required: [session_id, results] properties: session_id: { $ref: '#/components/schemas/SessionId' } results: type: array items: { $ref: '#/components/schemas/SampleResult' } # ---------- Subjects y dispositivos ---------- Subject: type: object required: [id, external_subject_id, status, token_epoch, created_at] properties: id: { $ref: '#/components/schemas/SubjectId' } external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } status: { type: string, enum: [ACTIVE, ERASED] } token_epoch: { type: integer, minimum: 0 } created_at: { $ref: '#/components/schemas/Timestamp' } SubjectList: type: object required: [data, next_cursor] properties: data: type: array maxItems: 1 items: { $ref: '#/components/schemas/Subject' } next_cursor: { type: 'null' } Device: type: object required: [id, subject_id, application_id, platform, status, created_at, last_seen_at] properties: id: { $ref: '#/components/schemas/DeviceId' } subject_id: { $ref: '#/components/schemas/SubjectId' } application_id: { $ref: '#/components/schemas/ApplicationId' } platform: { $ref: '#/components/schemas/Platform' } app_version: { type: ['string', 'null'] } sdk_version: { type: ['string', 'null'] } status: { type: string, enum: [ACTIVE, INACTIVE] } created_at: { $ref: '#/components/schemas/Timestamp' } last_seen_at: { $ref: '#/components/schemas/Timestamp' } SubjectExport: type: object required: [subject, devices, memberships, audit_events, generated_at] description: No contiene coordenadas (GeoStream no las conserva salvo historial opt-in, F13). properties: subject: { $ref: '#/components/schemas/Subject' } devices: type: array items: { $ref: '#/components/schemas/Device' } memberships: type: array items: { $ref: '#/components/schemas/Member' } audit_events: type: array items: type: object required: [id, at, action] properties: id: { type: string } at: { $ref: '#/components/schemas/Timestamp' } action: { type: string } target_type: { type: string } target_id: { type: string } generated_at: { $ref: '#/components/schemas/Timestamp' } # ---------- Webhooks ---------- WebhookEventType: type: string enum: - session.created - session.updated - session.closed - session.expired - member.joined - member.updated - member.left - member.revoked - sharing.started - sharing.stopped - geofence.entered - geofence.exited - webhook.test WebhookEndpoint: type: object required: [id, url, event_types, status, created_at] properties: id: { $ref: '#/components/schemas/WebhookId' } url: { type: string, format: uri, maxLength: 2048 } event_types: type: array minItems: 1 uniqueItems: true items: { $ref: '#/components/schemas/WebhookEventType' } description: { type: ['string', 'null'], maxLength: 200 } status: { type: string, enum: [ACTIVE, DISABLED] } consecutive_failures: { type: integer, minimum: 0 } created_at: { $ref: '#/components/schemas/Timestamp' } WebhookEndpointCreated: allOf: - $ref: '#/components/schemas/WebhookEndpoint' - type: object required: [secret] properties: secret: type: string pattern: '^whsec_[A-Za-z0-9_-]{43}$' description: Secreto HMAC. Se muestra solo en esta respuesta. CreateWebhookRequest: type: object additionalProperties: false required: [url, event_types] properties: url: { type: string, format: uri, maxLength: 2048 } event_types: type: array minItems: 1 uniqueItems: true items: { $ref: '#/components/schemas/WebhookEventType' } description: { type: string, maxLength: 200 } WebhookList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/WebhookEndpoint' } next_cursor: { type: ['string', 'null'] } WebhookEnvelopeBase: type: object required: [id, type, created_at, tenant_id, data] properties: id: { $ref: '#/components/schemas/EventId' } type: { $ref: '#/components/schemas/WebhookEventType' } created_at: { $ref: '#/components/schemas/Timestamp' } tenant_id: { $ref: '#/components/schemas/TenantId' } SessionWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [session.created, session.updated, session.closed, session.expired] } data: type: object required: [session] properties: session: { $ref: '#/components/schemas/Session' } MemberWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [member.joined, member.updated, member.left, member.revoked] } data: type: object required: [member] properties: member: { $ref: '#/components/schemas/Member' } SharingWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [sharing.started, sharing.stopped] } data: type: object required: [session_id, member_id, external_subject_id, sharing, changed_at] properties: session_id: { $ref: '#/components/schemas/SessionId' } member_id: { $ref: '#/components/schemas/MemberId' } external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } sharing: { type: boolean } changed_at: { $ref: '#/components/schemas/Timestamp' } GeofenceWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [geofence.entered, geofence.exited] } data: type: object required: [geofence_id, session_id, member_id, external_subject_id, transition, occurred_at] properties: geofence_id: { $ref: '#/components/schemas/GeofenceId' } session_id: { $ref: '#/components/schemas/SessionId' } member_id: { $ref: '#/components/schemas/MemberId' } external_subject_id: { $ref: '#/components/schemas/ExternalSubjectId' } transition: { $ref: '#/components/schemas/GeofenceTransitionValue' } occurred_at: { $ref: '#/components/schemas/Timestamp' } dwell_seconds: type: ['integer', 'null'] description: Solo para `geofence.dwell`. Sin centroide. anchor_member_id: type: ['string', 'null'] description: Solo en geocercas dinámicas. Prohibido en sesiones `POOL`. TrackWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [track.stop_started, track.stop_ended] } data: type: object required: [session_id, member_id, kind, occurred_at, stop_index] properties: session_id: { $ref: '#/components/schemas/SessionId' } member_id: { $ref: '#/components/schemas/MemberId' } kind: type: string enum: [STOP_STARTED, STOP_ENDED, TRACKING_LOST, TRACKING_RESUMED] occurred_at: { $ref: '#/components/schemas/Timestamp' } stop_index: { type: integer, minimum: 0 } duration_s: type: ['integer', 'null'] description: Solo para `track.stop_ended`. TripWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [trip.completed] } data: type: object required: [trip_id, session_id, member_id, started_at, ended_at, distance_m, stop_count] properties: trip_id: { $ref: '#/components/schemas/TripId' } session_id: { $ref: '#/components/schemas/SessionId' } member_id: { $ref: '#/components/schemas/MemberId' } started_at: { $ref: '#/components/schemas/Timestamp' } ended_at: { $ref: '#/components/schemas/Timestamp' } distance_m: { type: number, format: double, minimum: 0 } stop_count: { type: integer, minimum: 0 } PoolWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: enum: [pool.member_online, pool.member_offline, pool.member_availability_changed] data: type: object required: [session_id, member_id] properties: session_id: { $ref: '#/components/schemas/SessionId' } member_id: { $ref: '#/components/schemas/MemberId' } from: { type: ['string', 'null'] } to: { type: ['string', 'null'] } EdgeWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { enum: [edge.tag_registered, edge.tag_removed] } data: type: object required: [tag_id, model] properties: tag_id: { $ref: '#/components/schemas/TagId' } model: { type: string } TestWebhookEvent: allOf: - $ref: '#/components/schemas/WebhookEnvelopeBase' - type: object properties: type: { const: webhook.test } data: type: object required: [webhook_id] properties: webhook_id: { $ref: '#/components/schemas/WebhookId' } # ---------- Geocercas (F11) ---------- LatLng: type: object additionalProperties: false required: [latitude, longitude] properties: latitude: { type: number, minimum: -90, maximum: 90 } longitude: { type: number, minimum: -180, maximum: 180 } CircleShape: type: object additionalProperties: false required: [type, center, radius_m] properties: type: { const: circle } center: { $ref: '#/components/schemas/LatLng' } radius_m: { type: number, minimum: 25, maximum: 50000 } PolygonShape: type: object additionalProperties: false required: [type, ring] properties: type: { const: polygon } ring: type: array minItems: 4 maxItems: 101 description: Anillo exterior cerrado (primer punto = último), sin autointersecciones. items: { $ref: '#/components/schemas/LatLng' } GeofenceShape: oneOf: - $ref: '#/components/schemas/CircleShape' - $ref: '#/components/schemas/PolygonShape' discriminator: propertyName: type mapping: circle: '#/components/schemas/CircleShape' polygon: '#/components/schemas/PolygonShape' Geofence: type: object required: [id, name, shape, metadata, created_at] properties: id: { $ref: '#/components/schemas/GeofenceId' } name: { type: string, minLength: 1, maxLength: 100 } session_id: oneOf: - { $ref: '#/components/schemas/SessionId' } - { type: 'null' } shape: { $ref: '#/components/schemas/GeofenceShape' } metadata: { $ref: '#/components/schemas/Metadata' } created_at: { $ref: '#/components/schemas/Timestamp' } CreateGeofenceRequest: type: object additionalProperties: false required: [name, shape] properties: name: { type: string, minLength: 1, maxLength: 100 } session_id: { $ref: '#/components/schemas/SessionId' } shape: { $ref: '#/components/schemas/GeofenceShape' } metadata: { $ref: '#/components/schemas/Metadata' } GeofenceList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Geofence' } next_cursor: { type: ['string', 'null'] } # ========================================================================== # Pista E — motor de ubicación y movilidad. # Semántica: docs/22..26. Validación: docs/09_VALIDATION_ERRORS.md §5.1. # ========================================================================== # ---------- Enums nuevos ---------- HistoryMode: type: string enum: [OFF, TRACE, TRACE_AND_ANALYTICS] description: | E6. `OFF` es el valor por defecto y el que tiene toda sesión cuyo tenant no tiene `history_allowed`. No es un valor "pendiente de activar": es la garantía de que no se escribe nada. ConsentPurpose: type: string enum: [HISTORY, ANALYTICS] Availability: type: string enum: [AVAILABLE, BUSY, PAUSED] ProviderPort: type: string enum: [ROUTE_MATRIX, ROUTE, MAP_MATCHING, GEOCODING] SourcePreference: type: string enum: [PROVIDER, LEARNED, ESTIMATED] EstimateKind: type: string enum: [FILTERED, MAP_MATCHED] description: | Nunca `MEASURED` (dato crudo) ni `PREDICTED` (predicción visual, exclusivamente local, docs/08_STREAM_PROTOCOL.md §7.6). PlausibilityFlag: type: string enum: - EXCESSIVE_SPEED - OUTLIER_JUMP - LOW_ACCURACY - MOCK_FLAGGED - FROZEN_FIX - IMPLAUSIBLE_ACCEL - NETWORK_ONLY description: | Señales de bajo nivel. GeoStream **no** puntúa fraude, no lista y no sanciona (D-10): expone la señal y la decisión es del integrador. GeofenceTransitionValue: type: string enum: [ENTERED, EXITED, DWELL] TagState: type: string enum: [ACTIVE, SEPARATED, REMOVED] # ---------- Identificadores nuevos ---------- TripId: { type: string, pattern: '^trp_[0-9a-f]{32}$' } PlaceId: { type: string, pattern: '^plc_[0-9a-f]{32}$' } ExportId: { type: string, pattern: '^exp_[0-9a-f]{32}$' } TagId: { type: string, pattern: '^tag_[0-9a-f]{32}$' } LpwanIntegrationId: { type: string, pattern: '^lpw_[0-9a-f]{32}$' } H3Cell: type: string pattern: '^[0-9a-f]{15,16}$' description: | Celda H3 en su representación canónica. **Es una coordenada** a efectos de redacción: está prohibida en logs, métricas y trazas (docs/10 §8.2). # ---------- Estimación (E2) ---------- LocationEstimate: type: object additionalProperties: false required: [kind, position, horizontal_accuracy_m, confidence, odometer_m, flags] properties: kind: { $ref: '#/components/schemas/EstimateKind' } position: { $ref: '#/components/schemas/Position' } speed_mps: type: ['number', 'null'] description: Ausente si el motor no pudo derivarla. heading_deg: type: ['number', 'null'] description: Ausente si no hay velocidad suficiente. horizontal_accuracy_m: type: number format: double minimum: 0 description: | Desviación típica (1 sigma) de la posición estimada. **No** es la precisión declarada por el dispositivo. confidence: { type: number, format: double, minimum: 0, maximum: 1 } odometer_m: { type: number, format: double, minimum: 0 } flags: type: array items: { $ref: '#/components/schemas/PlausibilityFlag' } road_id: type: ['string', 'null'] description: Solo si `kind = MAP_MATCHED`. road_distance_m: type: ['number', 'null'] description: Solo si `kind = MAP_MATCHED`. # ---------- Pools (E4) ---------- CreatePoolRequest: type: object additionalProperties: false properties: expires_in_seconds: { type: integer, minimum: 60 } max_members: { type: integer, minimum: 2 } snapshot_ttl_seconds: { type: integer, minimum: 0 } stale_after_seconds: { type: integer, minimum: 5, maximum: 3600 } metadata: { $ref: '#/components/schemas/Metadata' } description: | `mode` es implícitamente `POOL` y no se envía. `owner_external_subject_id` e `initial_members` están prohibidos: los conductores entran solos. SetAvailabilityRequest: type: object additionalProperties: false required: [availability] properties: availability: { $ref: '#/components/schemas/Availability' } SetAttributesRequest: type: object additionalProperties: false required: [attributes] properties: attributes: type: object maxProperties: 16 propertyNames: { pattern: '^[a-z0-9_]{1,32}$' } additionalProperties: { type: string, maxLength: 64 } NearbyMember: type: object required: [member_id, distance_m, age_ms, availability, position] properties: member_id: { $ref: '#/components/schemas/MemberId' } distance_m: { type: number, format: double, minimum: 0 } age_ms: { type: integer, minimum: 0 } availability: { $ref: '#/components/schemas/Availability' } attributes: type: object additionalProperties: { type: string, maxLength: 64 } position: { $ref: '#/components/schemas/Position' } eta_s: type: ['number', 'null'] description: Solo con `rank = eta`. mode: type: ['string', 'null'] description: Solo con `rank = eta`. NearbyResponse: type: object required: [data] properties: data: type: array items: { $ref: '#/components/schemas/NearbyMember' } RingsResponse: type: object required: [rings] properties: rings: type: array items: type: object required: [ring, inner_radius_m, outer_radius_m, members, truncated] properties: ring: { type: integer, minimum: 0 } inner_radius_m: { type: number, format: double, minimum: 0 } outer_radius_m: { type: number, format: double, minimum: 0 } truncated: { type: boolean } members: type: array items: { $ref: '#/components/schemas/NearbyMember' } DensityMarker: type: object additionalProperties: false required: [position, count, delay_ms] properties: position: allOf: [{ $ref: '#/components/schemas/Position' }] description: | Centroide de celda H3 **desplazado aleatoriamente dentro de un radio acotado** y rumbo **cuantizado a 8 sectores**. Deliberadamente no es la posición real de nadie (FR-POOL-006, T-25). count: { type: integer, minimum: 1 } resolution: { type: integer, minimum: 7, maximum: 10 } heading_sector: type: ['integer', 'null'] description: Sector de rumbo 0..7, o ausente. delay_ms: type: integer minimum: 0 description: '`>= pool_density_delay_s`. Retraso mínimo antes de mostrar el marcador.' DensityResponse: type: object additionalProperties: false required: [markers] properties: markers: type: array maxItems: 50 items: { $ref: '#/components/schemas/DensityMarker' } description: | Nunca contiene `member_id`, `subject_id`, `device_id` ni marcas de tiempo exactas. Es la **única** consulta de pool accesible a un dispositivo, y existe precisamente para que un pasajero no necesite ver conductores. ResetOdometerRequest: type: object additionalProperties: false required: [member_id] properties: member_id: { $ref: '#/components/schemas/MemberId' } Odometer: type: object required: [member_id, distance_m, reset_at] properties: member_id: { $ref: '#/components/schemas/MemberId' } distance_m: { type: number, format: double, minimum: 0 } reset_at: { $ref: '#/components/schemas/Timestamp' } # ---------- Herramientas de movilidad (E5) ---------- Point: type: object additionalProperties: false required: [lat, lon] properties: lat: { type: number, format: double, minimum: -90, maximum: 90 } lon: { type: number, format: double, minimum: -180, maximum: 180 } TravelMode: type: string enum: [DRIVING, TWO_WHEELER, BICYCLE, WALKING] EtaRequest: type: object additionalProperties: false required: [origins, destinations, mode] properties: origins: type: array minItems: 1 items: { $ref: '#/components/schemas/Point' } destinations: type: array minItems: 1 items: { $ref: '#/components/schemas/Point' } mode: { $ref: '#/components/schemas/TravelMode' } source_preference: { $ref: '#/components/schemas/SourcePreference' } description: | `len(origins) * len(destinations) <= eta_max_matrix_cells`. Con `source_preference = PROVIDER` y el proveedor caído la respuesta es `503 PROVIDER_UNAVAILABLE`, no una estimación propia. EtaResponse: type: object required: [matrix, source, mode] properties: matrix: type: array items: type: array items: type: ['number', 'null'] description: Segundos. `null` = sin ruta conocida. source: type: string enum: [PROVIDER, LEARNED, ESTIMATED] mode: { $ref: '#/components/schemas/TravelMode' } Worker: type: object additionalProperties: false required: [id, position] properties: id: { type: string, minLength: 1, maxLength: 64 } position: { $ref: '#/components/schemas/Point' } capacity: { type: integer, minimum: 1, default: 1 } cost_per_unit: { type: number, format: double, minimum: 0, default: 0 } Task: type: object additionalProperties: false required: [id, position] properties: id: { type: string, minLength: 1, maxLength: 64 } position: { $ref: '#/components/schemas/Point' } demand: { type: integer, minimum: 1, default: 1 } AssignmentRequest: type: object additionalProperties: false required: [workers, tasks] properties: workers: type: array minItems: 1 maxItems: 2000 items: { $ref: '#/components/schemas/Worker' } tasks: type: array minItems: 1 maxItems: 2000 items: { $ref: '#/components/schemas/Task' } algorithm: type: string enum: [hungarian, min_cost_flow] default: hungarian description: '`hungarian` exige `capacity = 1` en todos los trabajadores.' max_cost: type: number format: double minimum: 0 description: Pares por encima se dejan sin asignar. source_preference: { $ref: '#/components/schemas/SourcePreference' } Assignment: type: object required: [task_id, worker_id, cost] properties: task_id: { type: string } worker_id: { type: string } cost: { type: number, format: double, minimum: 0 } AssignmentResponse: type: object required: [assignments, unassigned, total_cost] properties: assignments: type: array items: { $ref: '#/components/schemas/Assignment' } unassigned: type: array items: { type: string } total_cost: { type: number, format: double, minimum: 0 } source: type: string enum: [PROVIDER, LEARNED, ESTIMATED] RouteRequest: type: object additionalProperties: false required: [origin, destination, mode] properties: origin: { $ref: '#/components/schemas/Point' } destination: { $ref: '#/components/schemas/Point' } mode: { $ref: '#/components/schemas/TravelMode' } source_preference: { $ref: '#/components/schemas/SourcePreference' } RouteResponse: type: object required: [geometry, distance_m, source] properties: geometry: type: array items: { $ref: '#/components/schemas/Point' } distance_m: { type: number, format: double, minimum: 0 } duration_s: type: ['number', 'null'] source: type: string enum: [PROVIDER, OWN_GRAPH] description: | `OWN_GRAPH` significa que la ruta se calculó con el grafo local (ADR-0032): ninguna coordenada salió de la infraestructura del tenant. # ---------- Provider Gateway (E5) ---------- Provider: type: object required: [port, configured, terms_checked_on] properties: port: { $ref: '#/components/schemas/ProviderPort' } configured: { type: boolean } terms_checked_on: type: ['string', 'null'] format: date credential_version: type: ['integer', 'null'] description: 'Nunca el valor de la credencial: solo su versión de rotación.' rotated_at: type: ['string', 'null'] format: date-time PutProviderRequest: type: object additionalProperties: false required: [url, credential, terms_checked_on] properties: url: type: string format: uri pattern: '^https://' description: Solo `https://` en producción. credential: type: string minLength: 1 maxLength: 4096 description: | Credencial **del tenant** (BYOK). Se cifra en reposo y nunca se devuelve ni se registra. terms_checked_on: type: string format: date description: Fecha en la que el tenant confirmó los términos del proveedor. # ---------- Historial (E6) ---------- IngestHistoryRequest: type: object additionalProperties: false required: [samples] properties: samples: type: array minItems: 1 items: { $ref: '#/components/schemas/LocationSample' } IngestHistoryResponse: type: object required: [session_id, results] properties: session_id: { $ref: '#/components/schemas/SessionId' } results: type: array items: { $ref: '#/components/schemas/SampleResult' } TraceResponse: type: object required: [session_id, from, to, points] properties: session_id: { $ref: '#/components/schemas/SessionId' } from: { $ref: '#/components/schemas/Timestamp' } to: { $ref: '#/components/schemas/Timestamp' } points: type: array items: { $ref: '#/components/schemas/Point' } encoded: type: ['string', 'null'] description: Polilínea en el formato pedido (`polyline` o `gsp1`), si lo fue. TripStop: type: object required: [started_at, ended_at, duration_s, position, radius_m, point_count] properties: started_at: { $ref: '#/components/schemas/Timestamp' } ended_at: { $ref: '#/components/schemas/Timestamp' } duration_s: { type: integer, minimum: 1 } position: { $ref: '#/components/schemas/Position' } radius_m: { type: number, format: double, minimum: 0 } point_count: { type: integer, minimum: 1 } Trip: type: object required: [id, session_id, member_id, started_at, ended_at, duration_s, distance_m, point_count, stops] properties: id: { $ref: '#/components/schemas/TripId' } session_id: { $ref: '#/components/schemas/SessionId' } member_id: { $ref: '#/components/schemas/MemberId' } started_at: { $ref: '#/components/schemas/Timestamp' } ended_at: { $ref: '#/components/schemas/Timestamp' } duration_s: { type: integer, minimum: 1 } distance_m: { type: number, format: double, minimum: 0 } point_count: { type: integer, minimum: 1 } mode: { type: ['string', 'null'] } stops: type: array items: { $ref: '#/components/schemas/TripStop' } track_gsp1: type: ['string', 'null'] description: Polilínea simplificada. No contiene más precisión que `trip_simplify_tolerance_m`. TripList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Trip' } next_cursor: { type: ['string', 'null'] } CreateExportRequest: type: object additionalProperties: false required: [from, to, format] properties: from: { $ref: '#/components/schemas/Timestamp' } to: { $ref: '#/components/schemas/Timestamp' } format: { type: string, enum: [ndjson, csv] } subject_ids: type: array description: Si se omite, exporta todo el historial del tenant con consentimiento vigente. items: { $ref: '#/components/schemas/SubjectId' } Export: type: object required: [id, format, status, created_at] properties: id: { $ref: '#/components/schemas/ExportId' } format: { type: string, enum: [ndjson, csv] } status: { type: string, enum: [PENDING, RUNNING, READY, FAILED, EXPIRED] } row_count: { type: ['integer', 'null'] } download_url: type: ['string', 'null'] description: TTL corto. No es una credencial permanente. expires_at: type: ['string', 'null'] format: date-time created_at: { $ref: '#/components/schemas/Timestamp' } # ---------- Analítica k-anónima (E6/E7) ---------- HeatmapCell: type: object required: [cell, value] properties: cell: { $ref: '#/components/schemas/H3Cell' } value: { type: number, format: double, minimum: 0 } HeatmapResponse: type: object required: [metric, resolution, cells, k_anonymity_applied, suppressed_cells] properties: metric: { type: string, enum: [presence, stops, speed] } resolution: { type: integer, minimum: 5, maximum: 12 } cells: type: array items: { $ref: '#/components/schemas/HeatmapCell' } k_anonymity_applied: type: integer description: | El umbral real que se aplicó. Se publica para que el integrador sepa qué nivel de garantía tiene, no para que pueda reconstruir lo suprimido. suppressed_cells: type: integer minimum: 0 description: | Cuántas celdas se ocultaron por estar por debajo de *k*. **No** se publica su valor ni su ubicación. OdPair: type: object required: [origin_cell, destination_cell, count] properties: origin_cell: { $ref: '#/components/schemas/H3Cell' } destination_cell: { $ref: '#/components/schemas/H3Cell' } count: { type: integer, minimum: 1 } OdMatrixResponse: type: object required: [resolution, pairs, k_anonymity_applied, suppressed_pairs] properties: resolution: { type: integer, minimum: 4, maximum: 12 } pairs: type: array items: { $ref: '#/components/schemas/OdPair' } k_anonymity_applied: { type: integer } suppressed_pairs: { type: integer, minimum: 0 } SpeedBucket: type: object required: [cell, hour, p50_mps, p90_mps, count] properties: cell: { $ref: '#/components/schemas/H3Cell' } hour: { type: integer, minimum: 0, maximum: 23 } p50_mps: { type: number, format: double, minimum: 0 } p90_mps: { type: number, format: double, minimum: 0 } count: { type: integer, minimum: 1 } SpeedProfileResponse: type: object required: [resolution, buckets, k_anonymity_applied, suppressed_buckets] properties: resolution: { type: integer, minimum: 5, maximum: 12 } buckets: type: array items: { $ref: '#/components/schemas/SpeedBucket' } k_anonymity_applied: { type: integer } suppressed_buckets: { type: integer, minimum: 0 } MobilityProfileResponse: type: object required: [scope, day_types, k_anonymity_applied] properties: scope: type: string enum: [SUBJECT, COHORT] description: | `SUBJECT` solo es posible si ese subject otorgó `ANALYTICS`. `COHORT` es el agregado k-anónimo del tenant. day_types: type: object additionalProperties: type: object properties: day_type: { type: ['string', 'null'] } typical_departure: { type: ['string', 'null'] } median_trips: { type: ['number', 'null'] } median_distance_m: { type: ['number', 'null'] } home_area_cells: type: array items: { $ref: '#/components/schemas/H3Cell' } k_anonymity_applied: { type: integer } LearnedPlace: type: object required: [id, position, radius_m, visits] properties: id: { $ref: '#/components/schemas/PlaceId' } position: { $ref: '#/components/schemas/Position' } radius_m: { type: number, format: double, minimum: 0 } visits: { type: integer, minimum: 1 } label: type: ['string', 'null'] description: Etiqueta derivada; nunca escrita por el usuario a mano. PlacesResponse: type: object required: [places, k_anonymity_applied, suppressed_places] properties: places: type: array items: { $ref: '#/components/schemas/LearnedPlace' } k_anonymity_applied: { type: integer } suppressed_places: { type: integer, minimum: 0 } # ---------- Edge (E8) ---------- CreateTagRequest: type: object additionalProperties: false required: [model] properties: model: { type: string, minLength: 1, maxLength: 100 } name: { type: string, minLength: 1, maxLength: 100 } description: | **No** acepta `eik`, `eid` ni ninguna clave. La `EIK` se queda en el tag y en el dispositivo dueño (INV-EDGE-1). Tag: type: object required: [id, model, state, created_at, updated_at] properties: id: { $ref: '#/components/schemas/TagId' } model: { type: string } name: { type: ['string', 'null'] } state: { $ref: '#/components/schemas/TagState' } separated_at: type: ['string', 'null'] format: date-time description: | Estado *separado*: el tag entra en protección DULT y ralentiza la rotación del identificador a 24 h (docs/25_EDGE_DEVICES.md §3). created_at: { $ref: '#/components/schemas/Timestamp' } updated_at: { $ref: '#/components/schemas/Timestamp' } TagList: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Tag' } next_cursor: { type: ['string', 'null'] } EdgeReportRequest: type: object additionalProperties: false required: [tag_id, hashed_eid, time_bucket, finder_ephemeral_pub, payload_salt, ciphertext] properties: tag_id: { $ref: '#/components/schemas/TagId' } hashed_eid: type: string description: 16 bytes en base64url o hex. Es un pseudónimo, no una identidad. time_bucket: allOf: [{ $ref: '#/components/schemas/Timestamp' }] description: | Marca temporal **gruesa**. Deliberadamente no se guarda la posición del buscador junto al blob (T-28). finder_ephemeral_pub: type: string description: Punto P-256 comprimido del buscador (33 bytes SEC1, base64url). payload_salt: { type: string, description: 'Sal de un solo uso, base64url.' } ciphertext: type: string maxLength: 5464 description: | Blob cifrado para el dueño del tag. El servidor lo almacena y **no puede leerlo**: por eso T-22 cambia de respuesta para esta parte del producto. EdgeReportBlob: type: object required: [tag_id, hashed_eid, time_bucket, payload_salt, ciphertext] properties: tag_id: { $ref: '#/components/schemas/TagId' } hashed_eid: { type: string } time_bucket: { $ref: '#/components/schemas/Timestamp' } payload_salt: { type: string } ciphertext: type: string description: Opaco para el servidor. El descifrado ocurre en el cliente del dueño. EdgeReportsLookupRequest: type: object additionalProperties: false required: [hashed_eids, from, to] properties: hashed_eids: type: array minItems: 1 maxItems: 5000 description: Sin duplicados. Acotado por `edge_lookup_max_ids`. items: { type: string } from: { $ref: '#/components/schemas/Timestamp' } to: { $ref: '#/components/schemas/Timestamp' } EdgeReportsLookupResponse: type: object required: [reports] properties: reports: type: array items: { $ref: '#/components/schemas/EdgeReportBlob' } LpwanIngestResponse: type: object required: [accepted, rejected] properties: accepted: { type: integer, minimum: 0 } rejected: { type: integer, minimum: 0 } # ---------- Consola (F15, ADR-0036) ---------- ApplicationScope: type: string description: Scopes de una API key de aplicación (docs/06 §3.4). enum: - 'tokens:mint' - 'sessions:read' - 'sessions:write' - 'members:write' - 'invitations:write' - 'locations:read' - 'stream:connect' - 'subjects:manage' - 'webhooks:manage' - 'geofences:manage' ConsoleRole: type: string enum: [OWNER, ADMIN, VIEWER] ConsoleCodeRequest: type: object additionalProperties: false required: [email] properties: email: { type: string, format: email, maxLength: 254 } ConsoleCodeAccepted: type: object required: [accepted] properties: accepted: { type: boolean, enum: [true] } ConsoleVerifyRequest: type: object additionalProperties: false required: [email, code, password] properties: email: { type: string, format: email, maxLength: 254 } code: { type: string, pattern: '^[0-9]{6,10}$' } password: type: string minLength: 10 maxLength: 128 writeOnly: true description: 'Contraseña de la cuenta, de 10 a 128 caracteres. Se rechazan una sola letra repetida, el propio correo y las contraseñas más comunes. No hay reglas de clases de caracteres: cuenta la longitud.' accept_terms: type: boolean description: Obligatorio como `true` cuando la cuenta aún no existe. ConsoleLoginRequest: type: object additionalProperties: false required: [email, password] properties: email: { type: string, format: email, maxLength: 254 } password: { type: string, minLength: 1, maxLength: 128, writeOnly: true } ConsoleLoginVerifyRequest: type: object additionalProperties: false required: [email, code] properties: email: { type: string, format: email, maxLength: 254 } code: { type: string, pattern: '^[0-9]{6,10}$' } ChangeConsolePasswordRequest: type: object additionalProperties: false required: [current_password, new_password] properties: current_password: { type: string, minLength: 0, maxLength: 128, writeOnly: true } new_password: { type: string, minLength: 10, maxLength: 128, writeOnly: true } ConsoleAccount: type: object required: [id, email, status, has_password, created_at] properties: id: { $ref: '#/components/schemas/AccountId' } email: { type: string, format: email } status: { type: string, enum: [ACTIVE, DISABLED] } has_password: { type: boolean, description: 'Falsa en una cuenta anterior a las contraseñas, hasta que recupere el acceso con el código del correo.' } created_at: { $ref: '#/components/schemas/Timestamp' } ConsoleSession: type: object required: [token, expires_at, account, created] properties: token: type: string pattern: '^gcs_[A-Za-z0-9_-]{43}$' description: Token opaco. Se guarda solo como SHA-256 en el servidor. expires_at: { $ref: '#/components/schemas/Timestamp' } account: { $ref: '#/components/schemas/ConsoleAccount' } created: { type: boolean, description: '`true` si esta verificación creó la cuenta.' } ConsoleOrganization: type: object required: [id, name, role, created_at] properties: id: { $ref: '#/components/schemas/TenantId' } name: { type: string, minLength: 1, maxLength: 80 } role: { $ref: '#/components/schemas/ConsoleRole' } created_at: { $ref: '#/components/schemas/Timestamp' } ConsoleOrganizationList: type: object required: [data] properties: data: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/ConsoleOrganization' } } CreateConsoleOrganizationRequest: type: object additionalProperties: false required: [name] properties: name: { type: string, minLength: 1, maxLength: 80 } ConsoleProject: type: object required: [id, organization_id, name, status, allowed_device_scopes, allowed_origins, created_at, updated_at] properties: id: { $ref: '#/components/schemas/ApplicationId' } organization_id: { $ref: '#/components/schemas/TenantId' } name: { type: string, minLength: 1, maxLength: 80 } status: { type: string, enum: [ACTIVE, DISABLED] } allowed_device_scopes: type: array uniqueItems: true items: { $ref: '#/components/schemas/DeviceScope' } allowed_origins: type: array maxItems: 20 items: { type: string, format: uri, maxLength: 200 } created_at: { $ref: '#/components/schemas/Timestamp' } updated_at: { $ref: '#/components/schemas/Timestamp' } ConsoleProjectList: type: object required: [data] properties: data: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/ConsoleProject' } } CreateConsoleProjectRequest: type: object additionalProperties: false required: [name] properties: name: { type: string, minLength: 1, maxLength: 80 } allowed_device_scopes: type: array uniqueItems: true items: { $ref: '#/components/schemas/DeviceScope' } allowed_origins: type: array maxItems: 20 items: { type: string, format: uri, maxLength: 200 } UpdateConsoleProjectRequest: type: object additionalProperties: false minProperties: 1 properties: name: { type: string, minLength: 1, maxLength: 80 } status: { type: string, enum: [ACTIVE, DISABLED] } allowed_device_scopes: type: array uniqueItems: true items: { $ref: '#/components/schemas/DeviceScope' } allowed_origins: type: array maxItems: 20 items: { type: string, format: uri, maxLength: 200 } ConsoleApiKey: type: object required: [id, label, prefix, scopes, created_at] properties: id: { $ref: '#/components/schemas/ApiKeyId' } label: { type: string, minLength: 1, maxLength: 64 } prefix: type: string description: Primeros caracteres de la key, para reconocerla. No permite usarla. maxLength: 16 scopes: { type: array, uniqueItems: true, items: { $ref: '#/components/schemas/ApplicationScope' } } created_at: { $ref: '#/components/schemas/Timestamp' } expires_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] } revoked_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] } ConsoleApiKeyList: type: object required: [data] properties: data: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/ConsoleApiKey' } } CreateConsoleApiKeyRequest: type: object additionalProperties: false required: [label, scopes] properties: label: { type: string, minLength: 1, maxLength: 64 } scopes: type: array minItems: 1 uniqueItems: true items: { $ref: '#/components/schemas/ApplicationScope' } expires_in_days: { type: integer, minimum: 1, maximum: 365 } ConsoleApiKeyCreated: type: object required: [key, secret] properties: key: { $ref: '#/components/schemas/ConsoleApiKey' } secret: type: string pattern: '^gsk_[0-9a-f]{32}\.[A-Za-z0-9_-]{43}$' description: La API key en claro. Se devuelve una sola vez; no se vuelve a mostrar.