@parallel-adventure/external-site-sdk
v0.6.0
Published
Client TypeScript pour la façade External Gateway Parallèle (sites externes, sans clé Supabase côté navigateur).
Maintainers
Readme
@parallel-adventure/external-site-sdk
Client TypeScript (ESM) pour intégrer un site externe à la façade HTTP apps/external-gateway. Aucune clé Supabase n’est exposée au navigateur : tout passe par {origin}/v1/....
Quand installer ce SDK ? Ce SDK est l'instrumentation Parallèle d'un site. Il s'installe après que le design, le contenu, et l'UX du site soient validés par le scénariste — pas dès la création du projet. La phase de design est faite par l'éditeur (Lovable, etc.) sans aucune dépendance Parallèle ; l'installation de ce package marque le passage en phase d'instrumentation, où l'on câble l'auth, les stimuli, et la réactivité runtime.
Prérequis
- Gateway Parallèle déployée (ex.
https://api.prll.fr) — voir le README du workspace gateway. - Un
site_idstable attribué au site (cohérent avec la route/v1/sites/{siteId}/...et, côté runtime, avecexternal_sitesdu build quand la section est renseignée).
Installation
Projet hors monorepo :
npm install @parallel-adventure/external-site-sdkDepuis le monorepo (workspaces) :
npm installPublication npm (après npm run build et npm run test dans ce package) :
cd packages/external-site-sdk
npm publish --access publicLicence :
UNLICENSED(choix produit Parallèle — pas de licence open source sur ce package ; usage selon accord avec l’éditeur).
Démarrage rapide
import {
ParalleleExternalSiteClient,
setStoredSiteSessionToken,
getStoredSiteSessionToken,
} from "@parallel-adventure/external-site-sdk";
const origin = "https://api.prll.fr";
const siteId = "ton-site-id";
const client = new ParalleleExternalSiteClient(origin, siteId);
// 1) Policy (méthodes disponibles pour ce site)
const policy = await client.getPolicy();
console.log(policy.access_methods);
// 2) Exemple : OTP email historique (V1)
const otp = await client.requestOtp("[email protected]");
console.log(otp.status);
const session = await client.verifyOtp("[email protected]", "123456");
if (session.site_session_token) {
setStoredSiteSessionToken(siteId, session.site_session_token);
}
// 3) État frontend-safe
const token = getStoredSiteSessionToken(siteId);
if (token) {
const state = await client.getState(token);
console.log(state.projection);
}
// 4) Stimulus (idempotency_key obligatoire)
if (token) {
const stim = await client.submitStimulus(token, {
type: "mon_stimulus_type",
payload: { origin: "lovable", answer: "..." },
idempotencyKey: crypto.randomUUID(),
});
console.log(stim.status);
}Reco auth — privilégie le code email.
requestOtp+verifyOtp(code 6 chiffres tapé dans l'onglet déjà ouvert) est la voie recommandée. Le modelink(lien magique) est cassé par les passerelles de sécurité email corporate (Mimecast Browser Isolation, Microsoft Safe Links, Proofpoint) : elles consomment l'OTP single-use au pré-fetch et/ou ouvrent le lien en navigateur isolé d'où la session ne revient jamais. VoirrequestOtp(…, { mode })et le catalogue d'accès.
Flux générique start-access / complete-access
Aligné sur le catalogue gateway :
await client.startAccess({
method: "email_otp",
payload: { email: "[email protected]", redirect_url: "https://..." },
});
const done = await client.completeAccess({
method: "email_otp",
payload: { email: "[email protected]", token: "123456", game_id: undefined },
});Les méthodes email_claim / phone_claim acceptent un game_id optionnel (hint). Une réponse métier peut arriver avec HTTP 403 (status: access_denied) : le client la retourne comme objet JSON, sans lever d’exception — voir ParalleleGatewayError pour les erreurs transport (error + message).
AUTH-TO-STIMULUS BRIDGE — règle non négociable
Une auth réussie ne suffit PAS à faire progresser le runtime. La gateway
crée bien le site_session_token, mais le runtime n8n n'est notifié que si le
site poste un stimulus external-site-login. Sans ce stimulus, le step
log-in côté Builder reste pending pour toujours — symptôme typique : « le
joueur s'est connecté mais aucun message WhatsApp / SMS / email n'arrive ».
UX rapide (depuis SDK 0.4.3)
Ne bloque pas l'écran de chargement sur le stimulus ni sur getState. Le goulet
d'étranglement habituel est await verifyOtp puis await submitStimulus
(qui attend n8n de bout en bout). Confirme la connexion dès que le token arrive :
import {
handleLoginSuccess,
ParalleleExternalSiteClient,
} from "@parallel-adventure/external-site-sdk";
const session = await client.verifyOtp(email, code, gameIdHint);
const { confirmed, runtimeNotification } = handleLoginSuccess(
client,
siteId,
session,
{ username: email },
);
if (confirmed) {
navigate("/dashboard"); // immédiat — le joueur est connecté
}
// Runtime en arrière-plan (ne pas await avant d'afficher le succès)
void runtimeNotification.then((result) => {
if (!result) {
toast.warn("Connexion OK — synchronisation aventure en cours…");
}
});handleLoginSuccess persiste le token et lance external-site-login sans
bloquer. Pour un contrôle manuel, notifyRuntimeLogin + createLoginIdempotencyKey
sont aussi exportés.
Pattern legacy (bloquant — à éviter pour l'UX) :
if (session.status === "site_session_created" && session.site_session_token) {
setStoredSiteSessionToken(siteId, session.site_session_token);
await client.submitStimulus(session.site_session_token, {
type: "external-site-login",
payload: { logged_in: true, username: <email_or_phone> },
idempotencyKey: crypto.randomUUID(),
});
}Pour la liste des fields que le site doit fournir vs ceux que la gateway
remplit toute seule (auto_filled === true depuis v0.3.2), utiliser
client.getInputSources(token) et filtrer sur auto_filled === false. Le
site ne doit jamais hardcoder un field couvert par auto_filled (cf.
Anti-patterns plus bas).
UX d'attente — paliers de patience (depuis SDK 0.6.0)
Règle : quand on peut, on valide tout de suite ; sinon, on fait patienter avec un écran immersif dont le message évolue. Choisis le pattern par action, selon que la réponse gateway conditionne ou non l'écran suivant :
| Cas | Pattern |
|---|---|
| Le résultat conditionne l'écran suivant (formulaire validé par LLM, quiz, choix branchant → result.validated / result.next_step) | runWithWaitPhases + écran d'attente immersif |
| Le résultat ne conditionne rien (page vue, signal décoratif, télémétrie de jeu) | submitStimulusInBackground / submitCustomStimulusInBackground — l'UI avance immédiatement |
| Login | handleLoginSuccess (cf. UX rapide ci-dessus) |
Attente bloquante assumée — la réponse prend couramment 3 à 15 s
(runtime n8n + validation LLM de bout en bout). runWithWaitPhases notifie
l'UI à chaque palier franchi pour faire évoluer le message d'attente :
import { runWithWaitPhases } from "@parallel-adventure/external-site-sdk";
// Messages dans le ton du site — exemple « administration » :
const MESSAGES: Record<string, string> = {
sending: "Transmission de votre dossier…",
processing: "Analyse par nos services en cours…",
slow: "Vérification approfondie — merci de patienter.",
very_slow: "C'est plus long que prévu. Ne quittez pas cette page.",
};
const result = await runWithWaitPhases(
() => client.submitCustomStimulus(token, input),
{ onPhase: (key) => setWaitMessage(MESSAGES[key] ?? MESSAGES.processing) },
);
if (result.result?.validated) {
showScreen(result.result.next_step);
} else {
showRetry(result.result?.feedback_message);
}L'échelle par défaut (DEFAULT_WAIT_PHASES) : sending (0 s) →
processing (1,5 s) → slow (6 s) → very_slow (15 s). Configurable via
phases (clés libres). Les minuteurs sont annulés dès que l'appel aboutit ;
une exception dans onPhase n'affecte jamais la soumission.
Envoi en arrière-plan — quand l'écran suivant ne dépend pas de la réponse, ne bloque pas :
import { submitStimulusInBackground } from "@parallel-adventure/external-site-sdk";
showNextScreen(); // immédiat
void submitStimulusInBackground(client, token, {
type: "external-site-action",
payload: { action: "poster-consulte" },
}); // clé d'idempotence auto-générée, échec loggé, jamais de rejet⚠️ Ne bascule jamais en arrière-plan une action dont le step Builder attend la validation pour transitionner l'écran du site : le joueur verrait un état faux. En cas de doute → attente bloquante avec paliers.
Méthodes par cas d'usage
| method | Forme complete-access | Cas typique |
|---|---|---|
| email_otp | { email, token, game_id? } | OTP email standard (Supabase Auth) |
| phone_otp | { phone, token, game_id? } | OTP SMS standard (Supabase Auth) |
| magic_link | { email, token, game_id? } | Lien magique email (variant email_otp) |
| login_password | { email, password, game_id? } | Email + mot de passe Supabase |
| email_claim | { email, game_id? } | Saisie email sans vérification (low) |
| phone_claim | { phone, game_id? } | Saisie téléphone sans vérification (low) |
| url_context | { game_id, player_id? } | Contexte URL signé / dérivé |
| signed_deep_link | { signed_token } ou { token } | JWT HS256 émis par le runtime |
| runtime_login_code | { challenge_id, code } ou { phone, code } | Challenge à usage unique (TTL 15 min par défaut), S2S ou lookup-by-phone |
| runtime_player_secret | { phone, code, variable_key? } | Stateless : compare au scalaire de runtime_variables.<variable_key> (default ticket_id). Pas de TTL, pas de single-use. Secret dérivé scénariste-side. |
| runtime_game_secret | { token, variable_key? } | Stateless, sans identité joueur : résout la PARTIE en matchant token contre runtime_variables.<variable_key> (default webmail_token). Pour hacker le compte d'un PNJ via l'URL nue — pas d'email/tel du joueur, pas de lien. Pas de TTL, rejouable. |
Choix de méthode d'auth selon contexte
Le catalogue ci-dessus est exhaustif, mais le choix se fait sur 4 axes : type d'audience, niveau d'assurance attendu, canal de distribution du contexte, et dépendances infra (Supabase Auth, déploiement Edge Function, etc.).
| Contexte du site | Méthode recommandée | Pourquoi |
|---|---|---|
| Audience corporate (emails @entreprise filtrés par Mimecast / Safe Links / Proofpoint) | email_otp mode code | Le code 6 chiffres tapé évite les passerelles qui cassent les liens. À éviter : magic_link et email_otp mode link. |
| Audience grand public (gmail / outlook personnels) | email_otp mode code ou link | Les deux marchent. code reste plus prévisible et n'oblige pas à un retour dans l'onglet original. |
| Inscription minimaliste, MVP, démo | email_claim ou phone_claim | Crée une session sur simple déclaration, sans OTP. Niveau d'assurance bas (low) — ne pas utiliser pour des sites où une fraude d'identité serait problématique. |
| Onboarding 1-clic via lien WhatsApp / SMS porté par le runtime | url_context (low-assurance) ou signed_deep_link (high-assurance, JWT HS256) | Le contexte est entièrement porté par l'URL ; le site n'a qu'à appeler completeAccess avec les params lus de l'URL. signed_deep_link valide la signature côté gateway. |
| Login secret pré-distribué par le scénariste (ticket dérivé téléphone, code unique par joueur) | runtime_player_secret | Stateless : la gateway compare au scalaire de runtime_variables.<variable_key> (défaut ticket_id). Pas de TTL, pas de single-use, le secret peut être réutilisé. |
| Hack du compte d'un PNJ (login d'un tiers ; URL nue ; retour multi-navigateur sans lien) | runtime_game_secret | Un token opaque per-partie (porté par l'email/identifiant saisi) résout la partie côté gateway, sans email/téléphone réel du joueur. Token dérivé scénariste-side (ex. generate-hashed-code sur game_id). |
| Challenge à usage unique généré par le runtime (TTL 15 min) | runtime_login_code | Le runtime émet le challenge (via n8n), le site le consomme. single-use strict. |
| Email/mot de passe Supabase Auth déjà en place | login_password | Compte préexistant Supabase. Pas de magic link. |
Règle pratique d'arbitrage : démarrer par le plus simple compatible avec le niveau de confiance requis. email_otp mode code est un défaut robuste pour 80 % des cas. Passer à runtime_player_secret ou url_context quand le scénariste a besoin d'un parcours sans saisie d'email.
Exemple runtime_player_secret (Concert Talk / Freeforia) :
const session = await client.completeAccess({
method: "runtime_player_secret",
payload: {
phone: "33612345678",
code: "1234567890123456", // ticket dérivé du téléphone, sans le `*` décoratif
},
});
if (session.status === "site_session_created" && session.site_session_token) {
setStoredSiteSessionToken(siteId, session.site_session_token);
// Poste ensuite le stimulus external-site-login pour faire avancer le runtime
await client.submitStimulus(session.site_session_token, {
type: "external-site-login",
payload: { logged_in: true, username: "33612345678" },
idempotencyKey: crypto.randomUUID(),
});
}Erreurs
ParalleleGatewayError: échec HTTP « enveloppe » (ex.unknown_method, JSON invalide, réseau). Propriétés :httpStatus,code,body.- Réponses métier auth :
pending_link,ambiguous_player_context,access_denied, etc. sont portées dans le JSON (status,resolution_result, …), pas nécessairement comme exception.
Injection de fetch (tests, SSR) :
const client = new ParalleleExternalSiteClient(origin, siteId, {
fetch: globalThis.fetch,
});Anti-patterns
- ❌ Ne pas poster
external-site-loginaprès une auth réussie. C'est le bug n°1 — auth OK côté gateway mais runtime jamais notifié. Cf. AUTH-TO-STIMULUS BRIDGE plus haut. - ❌ Hardcoder un field couvert par
auto_filled === true. Depuis v0.3.2, la gateway enrichit automatiquement le payload des stimulus globaux (external-site-login,external-form-submit,external-gps-check-in,external-option-selected) avec les fields canoniques (auth_method,resolution_result,user_agent,ip_hashed,session_proof,logged_in_at, …). Les poser côté site crée un risque de désaccord auth/runtime. Toujours appelergetInputSources(token)et filtrer surauto_filled === falseavant de construire le payload. - ❌ Confondre
submitStimulus(catalogue plateforme global) etsubmitCustomStimulus(custom_stimulus_typesdu build). Les premiers ont un schéma fixe partagé entre toutes les aventures ; les seconds sont définis par le scénariste dans le Builder pour CE site. Vérifier lekindde chaque entrée renvoyée pargetInputSources("global"ou"custom"). - ❌ Hardcoder le
site_iddans le code du site au lieu de le passer en variable d'env (VITE_PARALLELE_SITE_ID,NEXT_PUBLIC_PARALLELE_SITE_ID, …). Bloque le déploiement multi-environnement. - ❌ Oublier le
gameIdhint dansverifyOtpquand le site connaît l'aventure ciblée (lue depuis?game_id=de l'URL d'invitation). Crée desambiguous_player_contextévitables. - ❌ Polling de
getStatepour la réactivité UI sans lirestate.projection.vars. Depuis v0.3.4, lesruntime_variablesexposées par le scénariste arrivent dansstate.projection.vars— c'est le canal de réactivité (déverrouillage de bouton, bandeau d'alerte, etc.). Comparaison stricte (=== "true") pour les flags STRING. - ❌ Utiliser
mode: "link"pour une audience corporate. Mimecast / Microsoft Safe Links / Proofpoint pré-fetchent le lien et consomment l'OTP single-use. Privilégiermode: "code"(cf. Reco auth en démarrage rapide).
Documentation canonique (monorepo)
| Sujet | Document |
|-------|----------|
| Contrat HTTP V1 | docs/architecture/external-sites-api-contracts.md |
| Catalogue méthodes / assurance | docs/architecture/external-sites-access-catalog.md |
| Guidelines intégration | docs/architecture/external-sites-integration-guidelines.md |
| Playbook intégrateur | docs/architecture/external-sites-integrator-playbook.md |
| Prompt Lovable | docs/implementation/external-sites-lovable-prompt.md |
| Maintenance interne (évolution, semver, publication) | docs/implementation/external-site-sdk-maintenance.md |
Compatibilité
- Module : ESM uniquement (
"type": "module"). - TypeScript :
module/moduleResolutionNodeNext— imports sources avec suffixe.js(résolution vers.tsau build).
Scripts
npm run build
npm run test