Externe Rätsel mit einer PazzlPage-Sitzung verbinden
Bei einem externen Rätsel kann PazzlPage einen einzelnen Spielverlauf sicher an deine Anwendung übergeben. Aktiviere dafür im Editor die Sitzungsübergabe und hinterlege eine HTTPS-Adresse. Das Shared Secret wird von PazzlPage erzeugt und bleibt ausschließlich auf Servern. Teile es niemals mit dem Browser.
Zugriff und Berechtigungen
Jeder Nutzer mit Bearbeitungszugriff auf das Rätsel kann die Sitzungsübergabe konfigurieren und die externen Status- und Event-Callbacks nutzen. Dafür ist keine Admin- oder Beta-Tester-Rolle erforderlich. Die allgemeine PazzlPage-Plattform-API und MCP bleiben Admins und Beta-Testern vorbehalten. Jeder Callback wird mit dem Shared Secret und einer Run-HMAC-Signatur geprüft.
Signierten Link prüfen
Beim Öffnen erhält deine externe Anwendung die Parameter pazzl_session, pazzl_expires und pazzl_signature. Die UUID in pazzl_session bezeichnet einen externen Durchlauf. pazzl_expires ist ein Unix-Zeitstempel, der 30 Minuten nach dem Erzeugen des Links liegt. PazzlPage erhält dadurch keinen externen Spielstand und überträgt keinen pazzl_state.
Die konfigurierte externe URL darf kein Fragment ab # enthalten. Vorhandene Query-Parameter bleiben erhalten; PazzlPage hängt die drei Sitzungsparameter mit dem passenden ?- oder &-Trennzeichen an.
Prüfe die Signatur auf deinem Server, bevor du den Durchlauf annimmst. Berechne HMAC-SHA256 über die UTF-8-Bytes von:
ext-link:v1, gefolgt von einem Zeilenumbruch, gefolgt von der vollständigen URL ohne pazzl_signature.
Die URL setzt sich aus deiner fest konfigurierten öffentlichen Origin, dem unveränderten rohen Request-Pfad und dem unveränderten rohen Querystring zusammen. Entferne ausschließlich das am Ende angehängte Paar pazzl_signature. Dekodiere, sortiere oder kodiere Query-Parameter nicht neu. Verwende weder den Host-Header noch weitergeleitete Host-Header als Origin. Vergleiche den erwarteten hexadezimalen HMAC mit der gelieferten Signatur zeitkonstant und lehne doppelte oder fehlerhaft formatierte Sitzungsparameter ab.
Die Signatur prüft Integrität und Herkunft. Sie verschlüsselt weder URL noch UUID. Die UUID kann während der gültigen Linklaufzeit weitergegeben werden; behandle sie wie einen Zugangstoken und protokolliere sie nicht unnötig. Das Shared Secret bleibt ausschließlich serverseitig.
Externe Sitzung speichern
Speichere die UUID beim ersten gültigen Link serverseitig in der Session deiner Anwendung. Setze ein Session-Cookie mit Secure, HttpOnly und SameSite=Lax. Erneuere die Session-ID, wenn du eine neue PazzlPage-UUID annimmst, und setze den externen Spielstand für diesen neuen Durchlauf zurück.
Beim Neuladen ohne PazzlPage-Parameter setzt du die gespeicherte UUID fort. Ist der Link abgelaufen, darf er nur dieselbe UUID fortsetzen, die bereits in dieser externen Session gespeichert ist, und nur wenn die Signatur weiterhin gültig ist. Eine andere oder noch nicht gespeicherte UUID braucht einen gültigen, nicht abgelaufenen Link. So erzeugt jeder neue Durchlauf eine neue externe Session und einen frischen Spielstand.
Status und Events zurückmelden
Die Statusmeldungen erfolgen serverseitig ohne OAuth und ohne PazzlPage-Konto deiner Spielenden:
- POST https://api.pazzl.page/v1/external-puzzle-runs/state mit run_uuid, state und signature. state ist started oder completed. Die Signatur ist HMAC-SHA256 über ext-state:v1, Zeilenumbruch, run_uuid, Zeilenumbruch und state.
- POST https://api.pazzl.page/v1/external-puzzle-runs/events mit run_uuid, event_id und signature. Die Signatur ist HMAC-SHA256 über ext-event:v1, Zeilenumbruch, run_uuid, Zeilenumbruch und event_id.
Verwende das unveränderte Shared Secret als HMAC-Schlüssel und sende die Signatur hexadezimal. Die Anfrage braucht keinen zusätzlichen Ablaufzeitstempel. Sende started erst, wenn das externe Spiel tatsächlich beginnt, nicht schon beim Öffnen des Links. Ist die Startmeldung in PazzlPage deaktiviert, zählt bereits der Linkklick als Start; sende in diesem Modus kein started. Bei aktivierter Startmeldung darf completed erst nach started gesendet werden. Events meldest du erst, wenn das jeweilige Ereignis in deinem Spiel wirklich eingetreten ist.
Ein erfolgreicher Aufruf antwortet mit 204. 401 bedeutet, dass die Signatur ungültig ist. 409 weist auf einen Zustandskonflikt oder ein bereits angenommenes Event hin. Unbekannte Durchläufe oder nicht konfigurierte Event-IDs werden nicht als Erfolg behandelt. Verarbeite Konflikte bewusst und wiederhole sie nicht blind.
Im Editor kann der Ersteller bis zu zwölf Event-IDs anlegen. Sie enthalten ausschließlich Kleinbuchstaben a–z und Unterstriche, zum Beispiel match_won. Der Ersteller ordnet diese IDs in PazzlPage Freischaltungen von Rätselschritten oder Unterrätseln zu. Deine Anwendung sendet nur die ID; Anzeigenamen und Freischaltziele bleiben in PazzlPage konfiguriert. Jedes Event kann für den passenden Fortschritt einmal angenommen werden. Eine bereits freigeschaltete Aufgabe bleibt verfügbar, auch wenn der auslösende Schritt abgeschlossen wurde. Nach Abschluss des externen Durchlaufs kann ein Event keine weitere Aktion auslösen.
Ablauf und Fehlerbehandlung
Verwende ausschließlich die UUID aus dem signierten Link und speichere sie in deiner externen Server-Session. Eine URL allein ist keine Sitzung. Der geheime Schlüssel gehört in eine serverseitige Umgebungsvariable oder einen Secret Store, nie in HTML, JavaScript, Quellkarten oder Browseranfragen.
Die PHP- und Node.js-Beispiele unten zeigen die rohe Linkprüfung, sichere Session-Cookies, den Wechsel der Session-ID bei einer neuen UUID sowie serverseitige Status- und Eventmeldungen. Das Beispiel für Node.js verwendet Redis als persistente Session-Ablage. Der Express-Standardspeicher MemoryStore ist nur für Entwicklung gedacht und nicht für den Produktivbetrieb geeignet.
PHP- und Node.js-Beispiele
Die Beispiele sind Ausgangspunkte. Ersetze https://game.example durch deine kanonische öffentliche HTTPS-Origin und hinterlege das vom Editor angezeigte Shared Secret sicher auf deinem Server. Das Node.js-Beispiel setzt Node.js 18 oder neuer sowie express-session, connect-redis und redis voraus. Siehe auch die Express-Session-Dokumentation und connect-redis.
Serverbeispiele
Wähle eine Sprache. Der Code läuft auf deinem Server; das Shared Secret darf nie an den Browser gesendet werden.
PHP
<?php
declare(strict_types=1);
// Set this to the exact public origin, without a path or trailing slash.
$publicOrigin = 'https://game.example';
$sharedSecret = getenv('PAZZL_SHARED_SECRET') ?: throw new RuntimeException('Missing server secret');
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
session_start();
try {
$run = verifyPazzlLink($_SERVER['REQUEST_URI'] ?? '/', $publicOrigin, $sharedSecret);
} catch (RuntimeException) {
http_response_code(401);
exit('Invalid PazzlPage link');
}
if ($run !== null) {
$isNewRun = ($_SESSION['pazzl_run_uuid'] ?? null) !== $run['uuid'];
// An expired link is reusable only inside the already-bound external session.
if ($run['expires'] < time() && $isNewRun) {
http_response_code(410);
exit('This PazzlPage link has expired');
}
if ($isNewRun) {
session_regenerate_id(true);
unset($_SESSION['game_progress'], $_SESSION['pazzl_started']);
$_SESSION['pazzl_run_uuid'] = $run['uuid'];
}
}
// A reload without PazzlPage query parameters continues this stored run.
$runUuid = $_SESSION['pazzl_run_uuid'] ?? null;
function verifyPazzlLink(string $requestUri, string $origin, string $secret): ?array
{
$question = strpos($requestUri, '?');
if ($question === false) {
return null;
}
$path = substr($requestUri, 0, $question);
$rawQuery = substr($requestUri, $question + 1);
if (! preg_match('/(?:^|&)pazzl_(?:session|expires|signature)=/', $rawQuery)) {
return null;
}
if (substr_count($rawQuery, 'pazzl_signature=') !== 1
|| ! preg_match('/(?:^|&)pazzl_signature=([a-f0-9]{64})\z/', $rawQuery, $signatureMatch, PREG_OFFSET_CAPTURE)) {
throw new RuntimeException('Malformed signature');
}
// The signature is appended last. Remove only that raw pair; keep every other byte as received.
$signature = $signatureMatch[1][0];
$unsignedQuery = substr($rawQuery, 0, $signatureMatch[0][1]);
$unsignedUrl = $origin.$path.($unsignedQuery === '' ? '' : '?'.$unsignedQuery);
$expected = hash_hmac('sha256', "ext-link:v1\n".$unsignedUrl, $secret);
if (! hash_equals($expected, $signature)) {
throw new RuntimeException('Invalid signature');
}
$fields = [];
foreach (explode('&', $unsignedQuery) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$key = urldecode($key);
if (in_array($key, ['pazzl_session', 'pazzl_expires'], true)) {
if (isset($fields[$key])) {
throw new RuntimeException('Duplicate PazzlPage parameter');
}
$fields[$key] = $value;
}
}
if (count($fields) !== 2
|| ! preg_match('/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/D', $fields['pazzl_session'])
|| ! preg_match('/^[0-9]{1,12}$/D', $fields['pazzl_expires'])) {
throw new RuntimeException('Malformed PazzlPage parameters');
}
return ['uuid' => $fields['pazzl_session'], 'expires' => (int) $fields['pazzl_expires']];
}
function postPazzl(string $path, array $payload): void
{
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($payload, JSON_THROW_ON_ERROR),
'timeout' => 5,
'ignore_errors' => true,
'follow_location' => 0,
],
]);
file_get_contents('https://api.pazzl.page/v1'.$path, false, $context);
preg_match('/\s([0-9]{3})\s/', $http_response_header[0] ?? '', $statusMatch);
$status = (int) ($statusMatch[1] ?? 0);
if ($status !== 204) {
throw new RuntimeException('PazzlPage returned HTTP '.$status);
}
}
function reportPazzlState(string $runUuid, string $state, string $secret): void
{
$signature = hash_hmac('sha256', "ext-state:v1\n".$runUuid."\n".$state, $secret);
postPazzl('/external-puzzle-runs/state', [
'run_uuid' => $runUuid,
'state' => $state,
'signature' => $signature,
]);
}
function reportPazzlEvent(string $runUuid, string $eventId, string $secret): void
{
if (! preg_match('/^[a-z_]{1,64}$/D', $eventId)) {
throw new InvalidArgumentException('Invalid event ID');
}
$signature = hash_hmac('sha256', "ext-event:v1\n".$runUuid."\n".$eventId, $secret);
postPazzl('/external-puzzle-runs/events', [
'run_uuid' => $runUuid,
'event_id' => $eventId,
'signature' => $signature,
]);
}
// Call only when gameplay actually begins, finishes, or a configured event occurs:
// reportPazzlState($runUuid, 'started', $sharedSecret);
// reportPazzlState($runUuid, 'completed', $sharedSecret);
// reportPazzlEvent($runUuid, 'match_won', $sharedSecret);
Node.js + Express
import express from 'express';
import session from 'express-session';
import { createClient } from 'redis';
import { RedisStore } from 'connect-redis';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const publicOrigin = 'https://game.example'; // Exact public origin, no path or trailing slash.
const sharedSecret = process.env.PAZZL_SHARED_SECRET;
const sessionSecret = process.env.SESSION_SECRET;
if (!sharedSecret || !sessionSecret) throw new Error('Missing server secret');
// Trust only the reverse proxy in front of this app so Secure cookies work behind TLS termination.
app.set('trust proxy', 1);
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(session({
store: new RedisStore({ client: redis, prefix: 'game:sessions:' }),
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax' },
}));
function safeEqualHex(expectedHex, suppliedHex) {
if (!/^[a-f0-9]{64}$/.test(suppliedHex)) return false;
const expected = Buffer.from(expectedHex, 'hex');
const supplied = Buffer.from(suppliedHex, 'hex');
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
}
function verifyPazzlLink(requestUri, origin, secret) {
const question = requestUri.indexOf('?');
if (question < 0) return null;
const path = requestUri.slice(0, question);
const rawQuery = requestUri.slice(question + 1);
if (!/(?:^|&)pazzl_(?:session|expires|signature)=/.test(rawQuery)) return null;
const signatureMatches = [...rawQuery.matchAll(/(?:^|&)pazzl_signature=([a-f0-9]{64})/g)];
const signatureMatch = /(?:^|&)pazzl_signature=([a-f0-9]{64})$/.exec(rawQuery);
if (signatureMatches.length !== 1 || !signatureMatch) throw new Error('Malformed signature');
// The signature is appended last. Remove only that raw pair; keep every other byte as received.
const signature = signatureMatch[1];
const unsignedQuery = rawQuery.slice(0, signatureMatch.index);
const unsignedUrl = origin + path + (unsignedQuery === '' ? '' : '?' + unsignedQuery);
const expected = createHmac('sha256', secret).update('ext-link:v1\n' + unsignedUrl, 'utf8').digest('hex');
if (!safeEqualHex(expected, signature)) throw new Error('Invalid signature');
// Decode fields only after verifying the untouched raw URL, and reject duplicate values.
const params = new URLSearchParams(unsignedQuery);
const uuids = params.getAll('pazzl_session');
const expiries = params.getAll('pazzl_expires');
if (uuids.length !== 1 || expiries.length !== 1
|| !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(uuids[0])
|| !/^[0-9]{1,12}$/.test(expiries[0])) {
throw new Error('Malformed PazzlPage parameters');
}
return { uuid: uuids[0], expires: Number(expiries[0]) };
}
app.get('/game', async (req, res, next) => {
try {
// req.originalUrl is the raw path plus query string; never rebuild it with URLSearchParams.
const run = verifyPazzlLink(req.originalUrl, publicOrigin, sharedSecret);
if (run) {
const isNewRun = req.session.pazzlRunUuid !== run.uuid;
if (run.expires < Math.floor(Date.now() / 1000) && isNewRun) {
return res.sendStatus(410);
}
if (isNewRun) {
await new Promise((resolve, reject) => req.session.regenerate(error => error ? reject(error) : resolve()));
req.session.pazzlRunUuid = run.uuid;
req.session.gameProgress = {};
req.session.pazzlStarted = false;
}
}
// A reload without PazzlPage parameters keeps using this server-side session value.
if (!req.session.pazzlRunUuid) return res.status(400).send('Open this game from its PazzlPage link.');
res.render('game');
} catch (error) {
next(error);
}
});
async function postPazzl(path, payload) {
const response = await fetch('https://api.pazzl.page/v1' + path, {
method: 'POST',
redirect: 'error',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
if (response.status !== 204) throw new Error('PazzlPage returned HTTP ' + response.status);
}
async function reportPazzlState(runUuid, state) {
if (!['started', 'completed'].includes(state)) throw new Error('Invalid state');
const signature = createHmac('sha256', sharedSecret)
.update('ext-state:v1\n' + runUuid + '\n' + state, 'utf8')
.digest('hex');
await postPazzl('/external-puzzle-runs/state', { run_uuid: runUuid, state, signature });
}
async function reportPazzlEvent(runUuid, eventId) {
if (!/^[a-z_]{1,64}$/.test(eventId)) throw new Error('Invalid event ID');
const signature = createHmac('sha256', sharedSecret)
.update('ext-event:v1\n' + runUuid + '\n' + eventId, 'utf8')
.digest('hex');
await postPazzl('/external-puzzle-runs/events', { run_uuid: runUuid, event_id: eventId, signature });
}
// Call reportPazzlState(runUuid, 'started') only when gameplay actually begins.
// Call reportPazzlState(runUuid, 'completed') after that run has started.
// Call reportPazzlEvent(runUuid, 'match_won') when that configured event occurs.
