Saltar al contenido principal

Contrato v1 de juegos externos

Esta página describe el contrato postMessage entre tu paquete de juego (cargado en un iframe aislado) y el portal del socio de IncentIA. El paquete informa de lo ocurrido; el servidor decide el resultado.

1. Ciclo de vida​

Member opens the game    →  POST /api/member/portal/external-games/{gameId}/sessions/start
Host renders the iframe → the bundle loads with the handshake query string
Bundle plays, then → postMessage({ type: "incentia.complete.v1", ... })
Host forwards the signal → POST /api/member/portal/external-games/sessions/{sessionId}/complete
Server resolves outcome → host posts incentia.outcome.v1 back to the iframe

La página anfitriona hace las dos llamadas HTTP. Tu paquete solo intercambia mensajes con su ventana padre.

2. Handshake​

La página anfitriona añade estos parámetros a la URL del paquete:

ParámetroValor
sessionIdext-<uuid> emitido por el servidor
playTokenToken de vida corta (5 minutos) que autoriza la llamada de finalización
hostEl window.location.origin de la página anfitriona
scoreSecretSolo en juegos con umbral de puntuación (puntuación mínima mayor que 0): el secreto de firma de la sesión (apartado 8). Si no hay umbral, no aparece.

Léelos al cargar. Devuelve el sessionId en el mensaje complete.

3. Mensajes del paquete al anfitrión​

Envía un mensaje a window.parent una sola vez, cuando termine la partida:

window.parent.postMessage({
type: 'incentia.complete.v1',
sessionId: '<the sessionId from the handshake>',
signal: { score: 100 }, // JSON validated against the game's signal schema
}, '*');
  • type: obligatorio, debe ser incentia.complete.v1.
  • sessionId: obligatorio, debe coincidir con el sessionId del handshake.
  • signal: un objeto o una cadena JSON. El servidor lo valida con el esquema de señal del juego. Máximo 16 KB ya serializado.

El anfitrión ignora los mensajes que incumplan alguna de estas condiciones.

4. Mensajes del anfitrión al paquete​

Cuando el servidor resuelve la partida, el anfitrión envía el resultado al iframe:

{
type: 'incentia.outcome.v1',
sessionId: 'ext-...',
outcome: 'win' | 'lose',
prize: { name?: string, type?: string, couponCode?: string },
pointsAwarded?: number,
}

Escucha los eventos message y muestra tu pantalla de premio o de consolación.

5. Aislamiento del iframe​

El anfitrión muestra tu paquete así:

<iframe sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
src="<bundle url>?sessionId=...&playToken=...&host=..."></iframe>

Solo se concede allow-scripts. No hay allow-same-origin, allow-top-navigation ni allow-forms. Tu paquete se ejecuta en un origen opaco: sin cookies y con localStorage bloqueado. La URL del paquete también se sirve con la cabecera Content-Security-Policy: sandbox allow-scripts.

6. Comprobación de origen​

Como el iframe tiene origen opaco, los mensajes que envía tienen event.origin === "null". Por eso el anfitrión comprueba la identidad de la ventana emisora (debe ser el iframe del juego) y no la cadena de origen. En tu paquete, al recibir el resultado, compara event.origin con el parámetro host.

El anfitrión solo carga paquetes por https://, desde su propio dominio o desde un dominio de paquetes autorizado.

7. Límites y caducidad del token​

  • Un socio puede iniciar 10 partidas por juego y hora. A partir de ahí, sessions/start devuelve 429.
  • Solo puede haber una sesión en curso por socio y juego (si no, 429).
  • El playToken dura 5 minutos. Pasado ese tiempo, la finalización se rechaza.
  • La finalización es idempotente por sessionId: una segunda llamada devuelve el resultado ya decidido sin volver a ejecutar el motor.

8. Umbral de puntuación y firma​

La mayoría de los juegos externos son de resultado por sorteo: la score es informativa y el servidor elige el premio de la tabla de probabilidades del juego. Deja Minimum score for prize (skill gate) en 0 (el valor por defecto) y envía un { score } simple. Es la única opción sensata para paquetes de terceros en los que no confías.

Un juego puede fijar una puntuación mínima N. Entonces una partida solo opta a premio si la puntuación enviada es >= N. La puntuación se calcula en el navegador del socio y se puede falsificar, así que el servidor exige que vaya firmada.

Modelo de confianza​

  • El servidor nunca envía una clave global al navegador. En sessions/start deriva un secreto por sesión y lo devuelve como scoreSecret (el anfitrión lo reenvía en la URL). Ese secreto solo sirve para firmar la puntuación de esa sesión.
  • Firmar limita el daño; no hace fiable la puntuación. El secreto está en el navegador del jugador, así que un jugador decidido puede firmar la puntuación que quiera en su propia sesión.
  • Usa una puntuación mínima solo con premios de poco valor o de participación y solo con paquetes que hayas escrito tú y en los que confíes. Un paquete que recibe el secreto puede firmar la puntuación máxima para todos los jugadores.

Una puntuación sin firma o mal firmada se trata como sin premio. Así se bloquean los paquetes ingenuos, la manipulación en la red y la reutilización entre sesiones, socios o juegos.

Calcular la firma​

scoreSig = base64url( HMAC_SHA256( key = UTF8(scoreSecret),
msg = UTF8( decimalString(score) ) ) )
  • Usa scoreSecret tal como lo recibes, como bytes UTF-8 de la clave. No lo decodifiques de base64.
  • score es el entero que pones en signal.score, como cadena decimal simple (String(score)).
  • base64url es base64 estándar con + → -, / → _ y sin los = finales.
async function signScore(scoreSecret, score) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(scoreSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(String(score)));
return btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

window.parent.postMessage({
type: 'incentia.complete.v1',
sessionId,
signal: { score: 4200, scoreSig: await signScore(scoreSecret, 4200) },
}, '*');

scoreSig es un campo reservado. El servidor lo quita antes de validar tu esquema de señal, así que un esquema con "additionalProperties": false sigue funcionando.

9. Paquete mínimo​

<!doctype html>
<html><body><button id="play">Play</button>
<script>
const params = new URLSearchParams(location.search);
const sessionId = params.get('sessionId');
document.getElementById('play').onclick = () => {
parent.postMessage(
{ type: 'incentia.complete.v1', sessionId, signal: { score: 100 } },
'*');
};
window.addEventListener('message', (e) => {
if (e.data?.type === 'incentia.outcome.v1') alert('Outcome: ' + e.data.outcome);
});
</script></body></html>

Relacionado​