@mostajs/wagon-engine 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,32 @@
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
  Format : Keep a Changelog ; versions : SemVer.
5
5
 
6
+ ## [0.4.0] — 2026-10-27
7
+
8
+ ### Ajouté
9
+ - **`?suite=`** sur `/inscription` et `/connexion` : la page LOCALE où revenir une fois connecté. Une charge y envoie
10
+ un visiteur sans compte (Speaking : `/inscription?suite=/parcours`). Après l'inscription, s'il n'y a rien à montrer
11
+ (pas de clé d'API, pas de paiement), le visiteur y repart connecté (303) ; une clé d'API ne se montrant qu'une fois,
12
+ la page « inscription faite » reste alors, avec un bouton « Continuer » vers la suite ; un paiement à faire passe
13
+ avant. Après la connexion par mot de passe : 303 vers la suite au lieu de `/compte`. Seul un chemin du site est
14
+ suivi : `//hôte`, `/\hôte`, une adresse absolue, un chemin relatif ou un caractère hors liste sont ignorés
15
+ (redirection ouverte). La connexion par code ou lien de courriel n'en tient pas compte (à venir). Essai T-ENG-10 ;
16
+ sabotages consignés (suite ignorée, garde retirée → T-ENG-10).
17
+
18
+ ## [0.3.0] — 2026-10-27
19
+
20
+ ### Ajouté
21
+ - Les **dispositions** de `@mostajs/wagon-ui` 0.10 : une page de la charge choisit son cadre par
22
+ `ctx.page({ disposition: 'application' | 'console' | 'nu' | <fournie>, application, console })` ; les pages du wagon
23
+ (tarifs, connexion, compte…) restent en `vitrine`. Le moteur transmet `config.jetons` (rayon, police, ombre, fond),
24
+ le logo de `config.marque` (`logo`, `logoClair`) et `charge.dispositions` (dispositions fournies). Essai T-ENG-8.
25
+ - `charge.accueil` peut être une **fonction** `async (ctx) => ({ titre, contenu, disposition, … })` : l'application
26
+ rend son propre accueil (la maquette de Speaking ouvre sur « Rejoindre mon groupe »). La fiche de données
27
+ `{ fr: { titre, lede, actions, preuves } }` marche toujours. Essai T-ENG-9.
28
+
29
+ ### Modifié
30
+ - Dépend de `@mostajs/wagon-ui` `^0.10.0`.
31
+
6
32
  ## [0.2.0] — 2026-10-26
7
33
 
8
34
  ### Ajouté
package/README.md CHANGED
@@ -60,6 +60,11 @@ Elle contrôle les comptes, le quota, le paiement (signatures Stripe réelles, b
60
60
  node examples/verifier.mjs # une greffe de vingt lignes démarre : paiement, paliers, texte légal, quota
61
61
  ```
62
62
 
63
+ ## Revenir où l'on était
64
+
65
+ Une page de la charge qui demande un compte envoie le visiteur vers `/inscription?suite=/ma-page` (ou
66
+ `/connexion?suite=/ma-page`) : une fois inscrit ou connecté, il y revient. Seul un chemin du site est suivi.
67
+
63
68
  ## Référence
64
69
 
65
70
  L'API et le contrat complet de la charge sont dans `llms.txt`.
package/llms.txt CHANGED
@@ -22,12 +22,14 @@ PAYPAL_WEBHOOK_ID · PAIEMENT_<STRIPE|CHARGILY|SATIM|PAYPAL>=off (coupe le moyen
22
22
  Moyen ACTIF = clés présentes ET commutateur ≠ off (ET, pour une banque prépayée, proposée par config.paiement.prepaye).
23
23
 
24
24
  ## CONTRAT DE LA CHARGE (charge/index.mjs)
25
- liens · ressources (quota { cle, limite, periode } | plafond { cle, limite, plafond: true }) · accueil · textes ·
25
+ liens · ressources (quota { cle, limite, periode } | plafond { cle, limite, plafond: true }) · accueil (fiche { fr: { titre, lede, actions, preuves } }, ou 0.3 : async (ctx) => ({ titre, contenu, disposition, application, console })) · dispositions (0.3 : { nom: (p) => corps }) · textes ·
26
26
  routes(req, res, ctx) (passent en premier ; true si servie) · conformite (essai de quota T-WAG-3) · amorcer() (base au
27
27
  démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauvegarde() · permissions (0.11 :
28
28
  { '/page': { microphone: 'self' }, '/dossier/*': … }, garde-http ≥ 0.2).
29
29
  ctx : chemin, methode, lang, t, ui, page({ titre, contenu }), envoyer, json, rediriger, formulaire, corpsJson({ strict }),
30
30
  compte, consommer(cle), refuser(v), palier({ compte? }), plafond(cle, n, { compte? }), courriel({ to, subject, text, html? }) → bool, admin.
31
+ 0.4 : /inscription?suite=/chemin et /connexion?suite=/chemin — retour à une page LOCALE après inscription (si ni clé ni paiement à montrer ; sinon bouton « Continuer ») ou connexion par mot de passe ; //hôte et adresses absolues ignorés.
32
+ 0.3 : ctx.page({ disposition: 'vitrine'|'application'|'console'|'nu'|<fournie>, application: { retour, titre, avatar, onglets, langues }, console: { menu, contexte, utilisateur } }) (wagon-ui 0.10) ; config.jetons { rayon, police, policeTitres, ombre, fond } ; config.marque.logo / logoClair (SVG en ligne ou chemin /…).
31
33
  0.2 : { compte } = identifiant d'un compte DÉSIGNÉ (requête sans session bornée par le palier d'un compte rattaché par la charge).
32
34
 
33
35
  ## PIÈGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/wagon-engine",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Le wagon d'un SaaS, une fois pour toutes : comptes, connexion, paliers, quotas, paiement (Stripe, Chargily, SATIM), espace client, administration, pages légales, fr/en/ar. Une greffe n'apporte que wagon.config.mjs, charge/, legal/ et son .env ; la suite de conformité publiée juge la greffe.",
5
5
  "keywords": [
6
6
  "mostajs",
@@ -52,7 +52,7 @@
52
52
  "@mostajs/orm": "^2.12.1",
53
53
  "@mostajs/payment": "^2.6.0",
54
54
  "@mostajs/subscriptions-plan": "^0.9.0",
55
- "@mostajs/wagon-ui": "^0.9.1",
55
+ "@mostajs/wagon-ui": "^0.10.0",
56
56
  "better-sqlite3": "^12.11.1",
57
57
  "stripe": "^17.7.0"
58
58
  },
package/src/wagon.mjs CHANGED
@@ -69,6 +69,17 @@ const TEXTES_PAIEMENT = {
69
69
  * 0.5 (audit du 09/10/2026) — LE COMPTE DU CLIENT : changer son mot de passe, le retrouver, partir avec ses données,
70
70
  * supprimer son compte (RGPD), se déconnecter par un POST. Textes propres au gabarit, en trois langues.
71
71
  */
72
+ /**
73
+ * 0.4 — `?suite=` : la page LOCALE où revenir après l'inscription ou la connexion (la charge y renvoie un visiteur
74
+ * sans compte, par ex. /parcours de Speaking). Seul un chemin du site est suivi — jamais `//hôte`, `/\\hôte`, ni une
75
+ * adresse absolue (redirection ouverte) ; sinon le comportement d'avant.
76
+ */
77
+ const suiteSure = (v) => {
78
+ const s = String(v ?? '');
79
+ return s.length <= 300 && /^\/(?![\/\\])[A-Za-z0-9\-._~\/?=&%+:@!$()*,;]*$/.test(s) ? s : null;
80
+ };
81
+ const avecSuite = (chemin, suite) => (suite ? `${chemin}?suite=${encodeURIComponent(suite)}` : chemin);
82
+
72
83
  const TEXTES_COMPTE = {
73
84
  fr: { gerer: 'Mon compte', motDePasse: 'Changer mon mot de passe', actuel: 'Mot de passe actuel', nouveau: 'Nouveau mot de passe', confirmer: 'Le même, encore',
74
85
  enregistrer: 'Enregistrer', mauvais: 'Mot de passe actuel incorrect.', differents: 'Les deux saisies diffèrent.', oublie: 'Mot de passe oublié ?',
@@ -79,7 +90,7 @@ const TEXTES_COMPTE = {
79
90
  exporter: 'Télécharger mes données', supprimer: 'Supprimer mon compte', supprimerAide: 'La suppression efface votre identité et vos données ; les factures sont conservées, anonymisées, le temps exigé par la loi. Pour confirmer, saisissez votre courriel.',
80
91
  confirmation: 'Votre courriel', supprimerBouton: 'Supprimer définitivement', confirmationFausse: 'Le courriel saisi ne correspond pas.',
81
92
  abonnementActif: 'Résiliez d’abord votre abonnement (« Gérer mon abonnement »).', deconnexion: 'Se déconnecter', deconnexionAide: 'Fermer votre session sur cet appareil ?',
82
- trop: 'Trop d’inscriptions depuis cette adresse : réessayez dans quelques minutes.' },
93
+ trop: 'Trop d’inscriptions depuis cette adresse : réessayez dans quelques minutes.' , continuer: 'Continuer' },
83
94
  en: { gerer: 'My account', motDePasse: 'Change my password', actuel: 'Current password', nouveau: 'New password', confirmer: 'The same, again',
84
95
  enregistrer: 'Save', mauvais: 'Current password is incorrect.', differents: 'The two entries differ.', oublie: 'Forgot your password?',
85
96
  oubliTitre: 'Recover access', oubliAide: 'Enter your email: if an account matches, you will receive a link to choose a new password.',
@@ -89,7 +100,7 @@ const TEXTES_COMPTE = {
89
100
  exporter: 'Download my data', supprimer: 'Delete my account', supprimerAide: 'Deletion erases your identity and your data; invoices are kept, anonymised, for the period required by law. To confirm, type your email.',
90
101
  confirmation: 'Your email', supprimerBouton: 'Delete for good', confirmationFausse: 'The email typed does not match.',
91
102
  abonnementActif: 'Cancel your subscription first (“Manage my subscription”).', deconnexion: 'Sign out', deconnexionAide: 'Close your session on this device?',
92
- trop: 'Too many sign-ups from this address: try again in a few minutes.' },
103
+ trop: 'Too many sign-ups from this address: try again in a few minutes.' , continuer: 'Continue' },
93
104
  ar: { gerer: 'حسابي', motDePasse: 'تغيير كلمة المرور', actuel: 'كلمة المرور الحالية', nouveau: 'كلمة المرور الجديدة', confirmer: 'نفسها مرة أخرى',
94
105
  enregistrer: 'حفظ', mauvais: 'كلمة المرور الحالية غير صحيحة.', differents: 'الإدخالان مختلفان.', oublie: 'نسيت كلمة المرور؟',
95
106
  oubliTitre: 'استعادة الوصول', oubliAide: 'أدخل بريدك الإلكتروني: إن كان له حساب، ستتلقى رابطًا لاختيار كلمة مرور جديدة.',
@@ -99,7 +110,7 @@ const TEXTES_COMPTE = {
99
110
  exporter: 'تنزيل بياناتي', supprimer: 'حذف حسابي', supprimerAide: 'يمحو الحذف هويتك وبياناتك؛ وتُحفظ الفواتير مجهولة الهوية المدة التي يفرضها القانون. للتأكيد، اكتب بريدك الإلكتروني.',
100
111
  confirmation: 'بريدك الإلكتروني', supprimerBouton: 'حذف نهائي', confirmationFausse: 'البريد المكتوب غير مطابق.',
101
112
  abonnementActif: 'ألغِ اشتراكك أولًا («إدارة اشتراكي»).', deconnexion: 'تسجيل الخروج', deconnexionAide: 'إغلاق جلستك على هذا الجهاز؟',
102
- trop: 'تسجيلات كثيرة من هذا العنوان: أعد المحاولة بعد دقائق.' },
113
+ trop: 'تسجيلات كثيرة من هذا العنوان: أعد المحاولة بعد دقائق.' , continuer: 'متابعة' },
103
114
  };
104
115
  const DOMAINES_BANQUES = {
105
116
  chargily: 'https://pay.chargily.net https://pay.chargily.com',
@@ -187,7 +198,10 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
187
198
  // 0.9 (wagon-ui 0.6) — la feuille de style de l'application, de confiance, posée sur toutes les pages.
188
199
  css: typeof config.css === 'string' ? config.css : '',
189
200
  // 0.5 (wagon-ui 0.5) — en-tête collant, bouton d'appel, langue en menu déroulant : déclarés par wagon.config.mjs.
190
- collant: config.collant === true, appel: config.appel ?? null, selecteurLangue: config.selecteurLangue ?? 'liens' });
201
+ collant: config.collant === true, appel: config.appel ?? null, selecteurLangue: config.selecteurLangue ?? 'liens',
202
+ // 0.3 (wagon-ui 0.10) — les jetons de forme, et les dispositions que la charge fournit (une page choisit son cadre
203
+ // par ctx.page({ disposition }) ; le logo vient de config.marque).
204
+ jetons: config.jetons ?? {}, dispositions: charge.dispositions ?? {} });
191
205
  const sessions = magasinSessions({ dialect: d, schema: S.Session, nom: 'wagon_session', jours: 30 });
192
206
  // 0.5 — les sessions expirées se purgent au démarrage puis toutes les six heures (auth-plug 0.3) ; les inscriptions
193
207
  // sont freinées par adresse (config.freinInscription, défaut : 10 puis 1 par minute).
@@ -555,6 +569,11 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
555
569
 
556
570
  // ── Les pages du wagon ──
557
571
  if (chemin === '/' && methode === 'GET') {
572
+ // 0.3 — l'accueil peut être une FONCTION de la charge : elle rend la page entière (titre, contenu, disposition).
573
+ if (typeof charge.accueil === 'function') {
574
+ const p = (await charge.accueil(ctx)) ?? {};
575
+ return envoyer(200, page({ actif: '/', ...p, titre: p.titre ?? config.marque.nom }));
576
+ }
558
577
  const a = charge.accueil?.[lang] ?? charge.accueil?.fr ?? { titre: config.marque.nom };
559
578
  return envoyer(200, page({ actif: '/', titre: config.marque.nom, contenu: ui.accueil({ lang, ...a }) }));
560
579
  }
@@ -583,12 +602,14 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
583
602
  if (chemin === '/inscription') {
584
603
  const tous = await paliersEnBase();
585
604
  const palierDe = (slug) => tous.find((p) => p.slug === slug && p.active !== false) ?? tous.find((p) => p.slug === (config.planGratuit ?? 'gratuit'));
586
- if (methode === 'GET') return envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier: palierDe(url.searchParams.get('plan')), methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength }) }));
605
+ const suite = suiteSure(url.searchParams.get('suite'));
606
+ const action = avecSuite('/inscription', suite);
607
+ if (methode === 'GET') return envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier: palierDe(url.searchParams.get('plan')), methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength, action }) }));
587
608
  if (!freinInscription.take(`ip:${ipClient(req)}`).ok) return envoyer(429, page({ titre: t('inscription.titre'), contenu: `<p class="msg">${esc((TEXTES_COMPTE[lang] ?? TEXTES_COMPTE.fr).trop)}</p>` }), { 'retry-after': '60' });
588
609
  const f = await formulaire();
589
610
  const palier = palierDe(f.plan);
590
611
  const courriel = String(f.courriel ?? '').trim().toLowerCase();
591
- const refus = (m) => envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier, erreur: m, methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength }) }));
612
+ const refus = (m) => envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier, erreur: m, methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength, action }) }));
592
613
  if (!COURRIEL.test(courriel) || !String(f.nom ?? '').trim()) return refus(t('inscription.courrielAide'));
593
614
  if (await d.findOne(S.User, { email: courriel })) return refus(t('inscription.deja'));
594
615
  if (methodeConnexion === 'motdepasse') {
@@ -602,19 +623,25 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
602
623
  let cle = null;
603
624
  try { cle = (await comptes.cles.creer(String(c.id), { label: t('espace.vosCles'), portees: {} })).full; } catch (e) { if (e.code !== 'PLAFOND_CLES') throw e; }
604
625
  const paiement = Number(palier?.price) > 0 ? { compteId: String(c.id), planSlug: palier.slug } : null;
605
- const contenu = ui.inscriptionFaite({ lang, cle, base: origine, paiement });
626
+ // 0.4 — ?suite= : rien à montrer (ni clé, ni paiement) → on y repart, connecté ; une clé se montre UNE fois, donc
627
+ // la page reste, avec « Continuer » vers la suite. Un paiement à faire passe avant la suite.
628
+ if (suite && !cle && !paiement && methodeConnexion === 'motdepasse') return ouvrirSession(utilisateur.id, suite);
629
+ const continuer = suite && !paiement ? `<p><a class="btn" href="${esc(suite)}">${esc((TEXTES_COMPTE[lang] ?? TEXTES_COMPTE.fr).continuer)}</a></p>` : '';
630
+ const contenu = ui.inscriptionFaite({ lang, cle, base: origine, paiement }) + continuer;
606
631
  if (methodeConnexion === 'code') return envoyer(200, page({ titre: t('inscription.faite'), contenu }));
607
632
  return envoyer(200, page({ titre: t('inscription.faite'), contenu }), { 'set-cookie': cookieStrict('wagon_session', await sessions.ouvrir(utilisateur.id), { maxAge: 30 * 86400, https }) });
608
633
  }
609
634
 
610
635
  // La connexion : mot de passe, ou code + lien par courriel. Un refus ne dit jamais lequel était faux.
611
636
  if (chemin === '/connexion') {
612
- if (methode === 'GET') return envoyer(200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, methode: methodeConnexion }) + (oubliPossible ? `<p class="hint"><a href="/connexion/oubli">${esc(TC.oublie)}</a></p>` : '') }));
637
+ const suite = suiteSure(url.searchParams.get('suite'));
638
+ const action = avecSuite('/connexion', suite);
639
+ if (methode === 'GET') return envoyer(200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, methode: methodeConnexion, action }) + (oubliPossible ? `<p class="hint"><a href="/connexion/oubli">${esc(TC.oublie)}</a></p>` : '') }));
613
640
  const f = await formulaire();
614
641
  if (methodeConnexion === 'motdepasse') {
615
642
  const r = await motsDePasse.login(String(f.courriel ?? ''), String(f.motdepasse ?? ''), { ip: ipClient(req) });
616
- if (r.ok) return ouvrirSession(r.userId);
617
- return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux') }) }));
643
+ if (r.ok) return ouvrirSession(r.userId, suite ?? '/compte');
644
+ return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux'), action }) }));
618
645
  }
619
646
  const courriel = String(f.courriel ?? '').trim().toLowerCase();
620
647
  const dfi = await codes.request(courriel, { ip: ipClient(req) });