Saltar a contenido

Contrato propuesto — Commerce ↔ TimeLiber Connect

Estado: linking y lectura interna implementados; handoff DEV pendiente de validación real
Base pública: https://commerce.timeliber.com.co
Threat model: conexión Shopify

Principios

  • La API pública autentica usuarios TimeLiber.
  • La API interna autentica exclusivamente workloads autorizados.
  • Los tokens Shopify pertenecen exclusivamente al session store cifrado de TimeLiber Connect; no atraviesan el contrato con Commerce.
  • Todas las escrituras incluyen organization_id derivado del intent, no del request del conector.
  • Idempotency-Key es obligatorio en operaciones internas mutantes.
  • Los errores públicos no revelan si una tienda pertenece a otro tenant.

API de usuario TimeLiber

Estas rutas ya están montadas en Commerce. El enlace interno está implementado; OAuth sigue pendiente de validación con Shopify real.

La sesión de usuario se establece mediante commerce_access y commerce_refresh, cookies HttpOnly, SameSite=Lax y Secure en producción. El header Bearer también se acepta para clientes API controlados.

POST /api/v1/organizations/{organization_id}/integrations/intents

Requiere sesión TimeLiber y capability CONNECT_CHANNELS.

Respuesta 201:

{
  "intent_id": "uuid",
  "expires_at": "RFC3339",
  "next_action": "continue_in_shopify",
  "installation_url": "shopify-owned URL or null"
}

En DEV, la respuesta contiene un secreto de 256 bits con TTL de diez minutos. El frontend lo empaqueta con versión, organización e intent como un código de un solo uso. El usuario lo copia en la app embebida ya autenticada por Shopify. El código viaja sólo en el cuerpo TLS del formulario y después server-to-server; no se coloca en URLs, logs ni almacenamiento persistente del navegador.

Este handoff manual permite validar el aislamiento antes de disponer de la ficha pública. La experiencia objetivo posterior a la aprobación es un handoff opaco con retorno automático, manteniendo las mismas garantías de consumo único.

El callback debe usar además un state opaco, aleatorio y de un solo uso, guardado temporalmente en Redis con el intent_id y el dominio Shopify normalizado. El callback consume el registro con GETDEL; no acepta un state reutilizado, vacío o asociado a otro dominio.

GET /api/v1/organizations/{organization_id}/integrations

Devuelve estado allowlist:

{
  "items": [
    {
      "id": "uuid",
      "provider": "shopify",
      "account_label": "store.myshopify.com",
      "status": "connected",
      "granted_scopes": ["read_orders"],
      "connected_at": "RFC3339",
      "last_verified_at": "RFC3339|null"
    }
  ]
}

Nunca incluye ciphertext, expiración de refresh token, errores internos o PII.

DELETE /api/v1/organizations/{organization_id}/integrations/{connection_id}

Requiere DISCONNECT_CHANNELS, confirmación explícita e idempotencia. Desconectar TimeLiber no sustituye desinstalar en Shopify; la UI debe explicar ambos estados.

API interna del conector

No se expone al navegador. Traefik debe enrutarla sólo al workload autorizado o exigir autenticación de servicio verificable.

Antes de persistir una instalación, Commerce debe normalizar el dominio canónico *.myshopify.com, rechazar hosts/path/query ambiguos, permitir sólo los scopes read-only declarados para el MVP y validar una versión GraphQL YYYY-MM. Esta validación vive en el adaptador Shopify, no en la UI ni en el conector, para que todo canal de entrada aplique la misma política.

POST /internal/v1/shopify/installations/link

Headers:

  • Authorization: Bearer <short-lived service credential>
  • Idempotency-Key: <uuid>
  • X-Correlation-ID: <uuid>

Payload lógico:

{
  "organization_id": "uuid",
  "intent_id": "uuid",
  "intent_secret": "one-time secret",
  "shop_id": "Shopify canonical ID",
  "shop_domain": "store.myshopify.com",
  "scopes": ["read_orders"],
  "api_version": "YYYY-MM"
}

Respuesta 200/201 sólo contiene connection_id, status y timestamps. Commerce consume el intent atómicamente y persiste identidad, scopes y estado, pero ninguna credencial Shopify.

La operación de dominio está implementada para normalizar shop/scopes/version, consumir el intent bajo lock, derivar connected_by_user_id del propio intent y crear integration_connections + shopify_installations dentro de la misma transacción. La ruta HTTP interna está montada con JWT de servicio de vida máxima de 60 segundos, audience e issuer obligatorios, y establece el contexto RLS antes del acceso a instalaciones. Sigue pendiente almacenar el jti para protección global contra replay y validar el flujo con el conector real.

Errores objetivo; sólo INVALID_CONNECTION_INTENT está implementado hoy en el contrato HTTP canónico:

  • INVALID_OR_EXPIRED_INTENT
  • SHOP_ALREADY_LINKED
  • SCOPES_NOT_ALLOWED
  • INSTALLATION_IDENTITY_INVALID
  • IDEMPOTENCY_CONFLICT

POST /internal/v1/shopify/orders

Esta ruta vive en TimeLiber Connect y no se publica en Traefik. Commerce envía únicamente shop_domain después de resolver una conexión dentro del tenant y firma un JWT de servicio con vida máxima de 60 segundos. El conector recupera su sesión offline mediante el SDK oficial, que refresca el token expirable en el mismo session store cuando corresponde, y devuelve la proyección sin PII.

El JWT incluye además organization_id, shop y subject exacto shopify-orders-read; el conector rechaza si el dominio firmado no coincide con el body. Linking exige shopify-installation-link.

No existe endpoint de sincronización de token: duplicar la credencial crearía dos fuentes de verdad y una condición de carrera durante refresh.

Webhook ingress público

POST /webhooks/* en TimeLiber Connect

  • sin sesión TimeLiber;
  • autenticado mediante HMAC Shopify sobre bytes crudos;
  • body limitado;
  • topic/shop/webhook ID tomados de headers verificados;
  • respuesta rápida 2xx tras autenticar;
  • privacidad y app/uninstalled son manejados por el SDK oficial;
  • el inbox persistente y la cola para eventos comerciales continúan pendientes.

La verificación se hace sobre los bytes originales del request con HMAC-SHA256 y comparación en tiempo constante. Los headers de shop, topic, delivery ID y versión se normalizan/allowlistean antes de crear el receipt; un JSON ya parseado, un topic no registrado o un dominio no canónico no son una base válida para autenticación ni para idempotencia.

No se reutiliza la autenticación service-to-service para webhooks de Shopify.

Lectura inicial de pedidos

El cliente GraphQL consulta únicamente id, número visible, timestamps, cancelación, displayFinancialStatus, displayFulfillmentStatus, total, moneda y las líneas compradas. De cada línea permite id, nombre/variante, SKU, cantidad original/actual y precios unitario/total. La query no solicita customer, email, phone ni direcciones y no sigue la relación al catálogo.

Usa únicamente read_orders, ventana inicial de 60 días y paginación por cursor. Rechaza de forma explícita un pedido con más de 250 líneas en lugar de devolverlo truncado. Cualquier histórico mayor queda detrás de la aprobación separada de read_all_orders.

Commerce no persiste esta proyección durante el MVP nivel 1. El frontend la consulta en vivo, la mantiene sólo en memoria, refresca cada minuto mientras la vista está abierta y permite refresco manual. La respuesta pública usa Cache-Control: private, no-store.

Autenticación de servicio

La primera implementación usa JWT HS256 de servicio con secreto exclusivo en ambos sentidos. Linking usa issuer timeliber-connect y audience timeliber-commerce; pedidos invierte emisor/audiencia. Ambos incluyen sub, jti, iat, exp y vida máxima de 60 segundos. Sin secreto se falla cerrado; un token ausente, vencido o con claims incorrectos recibe 401.

Orden de evolución para evaluar con la infraestructura real:

  1. workload identity/mTLS gestionado;
  2. JWT de servicio de vida corta con audience, issuer, jti y rotación;
  3. JWT HMAC rotatorio de vida corta como transición actual.

Una API key estática sin audience ni expiración no es aceptable para transportar tokens Shopify. La idempotencia y consumo único del intent protegen la operación; el almacenamiento de jti para replay global sigue pendiente antes de PROD.

Compatibilidad

  • Prefijo interno versionado /internal/v1.
  • Campos nuevos son aditivos; remover/renombrar exige nueva versión.
  • schema_version obligatorio en eventos asíncronos.
  • Timeouts, reintentos e idempotencia forman parte del contrato y se probarán con contract tests en ambos repositorios.