@mostajs/wagon-engine 0.3.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 +12 -0
- package/README.md +5 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/wagon.mjs +28 -9
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
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
|
+
|
|
6
18
|
## [0.3.0] — 2026-10-27
|
|
7
19
|
|
|
8
20
|
### 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
|
+
"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",
|
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,14 @@ 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
|
-
|
|
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 }) }));
|
|
595
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' });
|
|
596
609
|
const f = await formulaire();
|
|
597
610
|
const palier = palierDe(f.plan);
|
|
598
611
|
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 }) }));
|
|
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 }) }));
|
|
600
613
|
if (!COURRIEL.test(courriel) || !String(f.nom ?? '').trim()) return refus(t('inscription.courrielAide'));
|
|
601
614
|
if (await d.findOne(S.User, { email: courriel })) return refus(t('inscription.deja'));
|
|
602
615
|
if (methodeConnexion === 'motdepasse') {
|
|
@@ -610,19 +623,25 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
610
623
|
let cle = null;
|
|
611
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; }
|
|
612
625
|
const paiement = Number(palier?.price) > 0 ? { compteId: String(c.id), planSlug: palier.slug } : null;
|
|
613
|
-
|
|
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;
|
|
614
631
|
if (methodeConnexion === 'code') return envoyer(200, page({ titre: t('inscription.faite'), contenu }));
|
|
615
632
|
return envoyer(200, page({ titre: t('inscription.faite'), contenu }), { 'set-cookie': cookieStrict('wagon_session', await sessions.ouvrir(utilisateur.id), { maxAge: 30 * 86400, https }) });
|
|
616
633
|
}
|
|
617
634
|
|
|
618
635
|
// La connexion : mot de passe, ou code + lien par courriel. Un refus ne dit jamais lequel était faux.
|
|
619
636
|
if (chemin === '/connexion') {
|
|
620
|
-
|
|
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>` : '') }));
|
|
621
640
|
const f = await formulaire();
|
|
622
641
|
if (methodeConnexion === 'motdepasse') {
|
|
623
642
|
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') }) }));
|
|
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 }) }));
|
|
626
645
|
}
|
|
627
646
|
const courriel = String(f.courriel ?? '').trim().toLowerCase();
|
|
628
647
|
const dfi = await codes.request(courriel, { ip: ipClient(req) });
|