@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 +26 -0
- package/README.md +5 -0
- package/llms.txt +3 -1
- package/package.json +2 -2
- package/src/wagon.mjs +37 -10
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) });
|