@mostajs/wagon-engine 0.3.0 → 0.4.1

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,25 @@
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
  Format : Keep a Changelog ; versions : SemVer.
5
5
 
6
+ ## [0.4.1] — 2026-10-28
7
+
8
+ ### Corrigé
9
+ - **Le prix de l'inscription suit les moyens de paiement actifs** : la monnaie de chaque banque prépayée (Chargily :
10
+ DZD), puis l'euro si Stripe est actif. Une école payant en dinars voyait « 19,00 €/mois » sur « Créer un compte »
11
+ et « 8 000 DZD » sur les tarifs (Speaking, 28/10). Demande `@mostajs/wagon-ui` ≥ 0.10.1. Essai T-ENG-11.
12
+
13
+ ## [0.4.0] — 2026-10-27
14
+
15
+ ### Ajouté
16
+ - **`?suite=`** sur `/inscription` et `/connexion` : la page LOCALE où revenir une fois connecté. Une charge y envoie
17
+ un visiteur sans compte (Speaking : `/inscription?suite=/parcours`). Après l'inscription, s'il n'y a rien à montrer
18
+ (pas de clé d'API, pas de paiement), le visiteur y repart connecté (303) ; une clé d'API ne se montrant qu'une fois,
19
+ la page « inscription faite » reste alors, avec un bouton « Continuer » vers la suite ; un paiement à faire passe
20
+ avant. Après la connexion par mot de passe : 303 vers la suite au lieu de `/compte`. Seul un chemin du site est
21
+ suivi : `//hôte`, `/\hôte`, une adresse absolue, un chemin relatif ou un caractère hors liste sont ignorés
22
+ (redirection ouverte). La connexion par code ou lien de courriel n'en tient pas compte (à venir). Essai T-ENG-10 ;
23
+ sabotages consignés (suite ignorée, garde retirée → T-ENG-10).
24
+
6
25
  ## [0.3.0] — 2026-10-27
7
26
 
8
27
  ### 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
@@ -28,6 +28,7 @@ démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauv
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.
31
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 /…).
32
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).
33
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/wagon-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.10.0",
55
+ "@mostajs/wagon-ui": "^0.10.1",
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',
@@ -591,12 +602,23 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
591
602
  if (chemin === '/inscription') {
592
603
  const tous = await paliersEnBase();
593
604
  const palierDe = (slug) => tous.find((p) => p.slug === slug && p.active !== false) ?? tous.find((p) => p.slug === (config.planGratuit ?? 'gratuit'));
594
- 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
+ // 0.4.1 — le prix montré est celui des moyens ACTIFS : la monnaie de chaque banque prépayée, puis l'euro de Stripe
606
+ // (une école payant en dinars voyait l'euro de Stripe ici, et les dinars sur /tarifs).
607
+ const prixDe = (p) => {
608
+ if (!p) return null;
609
+ const pp = typeof p.prepaid === 'string' ? JSON.parse(p.prepaid || 'null') : p.prepaid;
610
+ const l = [...new Set(Object.values(banques))].filter((dv) => Number(pp?.[dv]?.month) > 0).map((dv) => ({ centimes: Number(pp[dv].month), devise: dv }));
611
+ if (stripe && Number(p.price) > 0) l.push({ centimes: Number(p.price), devise: 'EUR' });
612
+ return l.length ? l : null;
613
+ };
614
+ const suite = suiteSure(url.searchParams.get('suite'));
615
+ const action = avecSuite('/inscription', suite);
616
+ if (methode === 'GET') return envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier: palierDe(url.searchParams.get('plan')), prix: prixDe(palierDe(url.searchParams.get('plan'))), methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength, action }) }));
595
617
  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' });
596
618
  const f = await formulaire();
597
619
  const palier = palierDe(f.plan);
598
620
  const courriel = String(f.courriel ?? '').trim().toLowerCase();
599
- const refus = (m) => envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier, erreur: m, methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength }) }));
621
+ const refus = (m) => envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier, prix: prixDe(palier), erreur: m, methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength, action }) }));
600
622
  if (!COURRIEL.test(courriel) || !String(f.nom ?? '').trim()) return refus(t('inscription.courrielAide'));
601
623
  if (await d.findOne(S.User, { email: courriel })) return refus(t('inscription.deja'));
602
624
  if (methodeConnexion === 'motdepasse') {
@@ -610,19 +632,25 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
610
632
  let cle = null;
611
633
  try { cle = (await comptes.cles.creer(String(c.id), { label: t('espace.vosCles'), portees: {} })).full; } catch (e) { if (e.code !== 'PLAFOND_CLES') throw e; }
612
634
  const paiement = Number(palier?.price) > 0 ? { compteId: String(c.id), planSlug: palier.slug } : null;
613
- const contenu = ui.inscriptionFaite({ lang, cle, base: origine, paiement });
635
+ // 0.4 — ?suite= : rien à montrer (ni clé, ni paiement) → on y repart, connecté ; une clé se montre UNE fois, donc
636
+ // la page reste, avec « Continuer » vers la suite. Un paiement à faire passe avant la suite.
637
+ if (suite && !cle && !paiement && methodeConnexion === 'motdepasse') return ouvrirSession(utilisateur.id, suite);
638
+ const continuer = suite && !paiement ? `<p><a class="btn" href="${esc(suite)}">${esc((TEXTES_COMPTE[lang] ?? TEXTES_COMPTE.fr).continuer)}</a></p>` : '';
639
+ const contenu = ui.inscriptionFaite({ lang, cle, base: origine, paiement }) + continuer;
614
640
  if (methodeConnexion === 'code') return envoyer(200, page({ titre: t('inscription.faite'), contenu }));
615
641
  return envoyer(200, page({ titre: t('inscription.faite'), contenu }), { 'set-cookie': cookieStrict('wagon_session', await sessions.ouvrir(utilisateur.id), { maxAge: 30 * 86400, https }) });
616
642
  }
617
643
 
618
644
  // La connexion : mot de passe, ou code + lien par courriel. Un refus ne dit jamais lequel était faux.
619
645
  if (chemin === '/connexion') {
620
- 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>` : '') }));
646
+ const suite = suiteSure(url.searchParams.get('suite'));
647
+ const action = avecSuite('/connexion', suite);
648
+ 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>` : '') }));
621
649
  const f = await formulaire();
622
650
  if (methodeConnexion === 'motdepasse') {
623
651
  const r = await motsDePasse.login(String(f.courriel ?? ''), String(f.motdepasse ?? ''), { ip: ipClient(req) });
624
- if (r.ok) return ouvrirSession(r.userId);
625
- return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux') }) }));
652
+ if (r.ok) return ouvrirSession(r.userId, suite ?? '/compte');
653
+ return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux'), action }) }));
626
654
  }
627
655
  const courriel = String(f.courriel ?? '').trim().toLowerCase();
628
656
  const dfi = await codes.request(courriel, { ip: ipClient(req) });