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_idderivado del intent, no del request del conector. Idempotency-Keyes 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_INTENTSHOP_ALREADY_LINKEDSCOPES_NOT_ALLOWEDINSTALLATION_IDENTITY_INVALIDIDEMPOTENCY_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
2xxtras autenticar; - privacidad y
app/uninstalledson 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:
- workload identity/mTLS gestionado;
- JWT de servicio de vida corta con audience, issuer, jti y rotación;
- 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_versionobligatorio en eventos asíncronos.- Timeouts, reintentos e idempotencia forman parte del contrato y se probarán con contract tests en ambos repositorios.