@mostajs/wagon-engine 0.1.0 → 0.2.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 +16 -0
- package/llms.txt +3 -2
- package/package.json +1 -1
- package/src/conformite/conformite.test.mjs +17 -11
- package/src/wagon.mjs +16 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
|
|
4
4
|
Format : Keep a Changelog ; versions : SemVer.
|
|
5
5
|
|
|
6
|
+
## [0.2.0] — 2026-10-26
|
|
7
|
+
|
|
8
|
+
### Ajouté
|
|
9
|
+
- `ctx.palier({ compte })` et `ctx.plafond(cle, actuel, { compte })` lisent le palier d'un compte **désigné**, et non plus seulement de celui de la session. Une requête sans session peut ainsi être bornée par le palier d'un compte : un élève qui rejoint le groupe de son école compte dans le plafond d'apprenants de l'école (Speeking). La charge ne désigne qu'un compte qu'elle a elle-même rattaché à la requête. Essai T-ENG-6.
|
|
10
|
+
- `ctx.courriel(message)` permet à la charge d'envoyer un courriel par le transport du wagon (SMTP du `.env`). La fonction rend `true` si le courriel est parti, `false` sans transport ou en cas d'échec ; elle ne lève jamais d'exception. Essai T-ENG-7.
|
|
11
|
+
|
|
12
|
+
## [0.1.1] — 2026-10-26
|
|
13
|
+
|
|
14
|
+
### Corrigé (suite de conformité)
|
|
15
|
+
Speeking, première greffe qui ne ressemble pas au gabarit, a fait apparaître trois hypothèses fausses :
|
|
16
|
+
- **T-WAG-2** attendait une clé d'API à l'inscription, et un palier gratuit nommé « Gratuit ». La clé n'est attendue que si le palier gratuit en permet une (`maxCles > 0`), et le nom vient de la configuration.
|
|
17
|
+
- **T-WAG-5** (API par clé) est **ignoré avec son motif** quand le palier gratuit ne donne pas de clé.
|
|
18
|
+
- **T-WAG-6** montait la limite du *premier* palier payant à 777, ce qui inversait l'échelle quand un palier plus haut avait une limite plus basse (« Classe » 40, « École » 300). Il prend maintenant le palier payant à la plus grande limite finie, et le porte au-dessus de toutes les autres.
|
|
19
|
+
|
|
20
|
+
Une seconde greffe d'essai, faite comme une école (`test-scripts/greffe-plafond`), rejoue la suite à chaque version.
|
|
21
|
+
|
|
6
22
|
## [0.1.0] — 2026-10-26
|
|
7
23
|
|
|
8
24
|
### Extrait
|
package/llms.txt
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
> PayPal), espace client, administration, pages légales, robots/sitemap, sauvegarde, fr/en/ar. Une GREFFE n'apporte que
|
|
5
5
|
> wagon.config.mjs, charge/, legal/ et son .env. N2 (pack). Extrait du gabarit mosta-wagon 0.11.0 le 26/10/2026.
|
|
6
6
|
|
|
7
|
-
- Version: 0.
|
|
7
|
+
- Version: 0.2.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
|
|
8
8
|
- Chemin: mostajs/mosta-platform-stack/mosta-wagon-engine · gabarit d'une greffe : Entreprise/mosta-wagon
|
|
9
9
|
|
|
10
10
|
## EXPORTS
|
|
@@ -27,7 +27,8 @@ routes(req, res, ctx) (passent en premier ; true si servie) · conformite (essai
|
|
|
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
|
-
compte, consommer(cle), refuser(v), palier(), plafond(cle, n), admin.
|
|
30
|
+
compte, consommer(cle), refuser(v), palier({ compte? }), plafond(cle, n, { compte? }), courriel({ to, subject, text, html? }) → bool, admin.
|
|
31
|
+
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).
|
|
31
32
|
|
|
32
33
|
## PIÈGES
|
|
33
34
|
- wagon-ui ≥ 0.9 exige config.marque.auteur.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/wagon-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -39,6 +39,13 @@ const ESSAI = charge.conformite?.route ? charge.conformite : null;
|
|
|
39
39
|
/** `conformite.tarifs: false` : la charge remplace /tarifs (ex. les prix de ses voyages) — les paliers n'y sont pas. */
|
|
40
40
|
const TARIFS = charge.conformite?.tarifs !== false;
|
|
41
41
|
const MAIL_PAYANT = 'payant@exemple.test';
|
|
42
|
+
/** 0.1.1 — une clé d'API naît-elle à l'inscription ? Seulement si le palier gratuit en permet une (maxCles > 0). */
|
|
43
|
+
const CLE_A_L_INSCRIPTION = Number(GRATUIT?.limits?.maxCles ?? 0) > 0;
|
|
44
|
+
/** 0.1.1 — T-WAG-6 change une limite SANS inverser l'échelle : le palier payant qui a la plus grande limite finie, porté
|
|
45
|
+
* au-dessus de toutes les autres (777 au moins). Le premier palier payant n'est pas toujours le plus haut. */
|
|
46
|
+
const finie = (p) => Number(p.limits?.[LIMITE]) >= 0;
|
|
47
|
+
const CIBLE = ACTIFS.filter((p) => Number(p.price) > 0 && finie(p)).sort((a, b) => Number(b.limits[LIMITE]) - Number(a.limits[LIMITE]))[0] ?? null;
|
|
48
|
+
const VALEUR_ADMIN = String(Math.max(777, ...ACTIFS.filter(finie).map((p) => Number(p.limits[LIMITE]) + 1)));
|
|
42
49
|
/** Un essai sans objet pour cette greffe : ignoré AVEC son motif (mjs-unit ≥ 0.8 : il entre au rapport et au compte rendu). */
|
|
43
50
|
const sansObjet = (motif) => (nom, fn) => test.skip(nom, fn, `sans objet : ${motif}`);
|
|
44
51
|
|
|
@@ -133,8 +140,8 @@ test('T-WAG-2 — inscription, connexion, espace, déconnexion', async () => {
|
|
|
133
140
|
const r = await inscrire(B, 'anis@exemple.test');
|
|
134
141
|
expect(r.status).toBe(200);
|
|
135
142
|
const m = r.texte.match(/sk_(?:test|live)_[0-9a-f]{64}/);
|
|
136
|
-
expect(m !== null).toBe(
|
|
137
|
-
cleApi = m[0];
|
|
143
|
+
expect(m !== null).toBe(CLE_A_L_INSCRIPTION); // pas de clé si le palier gratuit n'en donne pas
|
|
144
|
+
cleApi = m?.[0] ?? '';
|
|
138
145
|
expect(r.cookie !== '').toBe(true);
|
|
139
146
|
const deja = await inscrire(B, 'anis@exemple.test');
|
|
140
147
|
expect(/existe déjà/i.test(deja.texte)).toBe(true);
|
|
@@ -142,9 +149,8 @@ test('T-WAG-2 — inscription, connexion, espace, déconnexion', async () => {
|
|
|
142
149
|
const c = await connecter(B, 'anis@exemple.test');
|
|
143
150
|
expect(c !== '').toBe(true);
|
|
144
151
|
const e = (await req(B, '/compte', { cookie: c })).texte;
|
|
145
|
-
expect(e.includes(
|
|
146
|
-
expect(e.includes(cleApi.slice(0, 12))).toBe(true);
|
|
147
|
-
expect(e.includes(cleApi)).toBe(false);
|
|
152
|
+
expect(e.includes(GRATUIT.name)).toBe(true);
|
|
153
|
+
if (CLE_A_L_INSCRIPTION) { expect(e.includes(cleApi.slice(0, 12))).toBe(true); expect(e.includes(cleApi)).toBe(false); }
|
|
148
154
|
const d = await req(B, '/deconnexion', { method: 'POST', cookie: c, form: {} }); // 0.5 : un POST
|
|
149
155
|
expect(d.status).toBe(303);
|
|
150
156
|
expect((await req(B, '/compte', { cookie: c })).status).toBe(303);
|
|
@@ -238,7 +244,7 @@ test('T-WAG-2 — inscription, connexion, espace, déconnexion', async () => {
|
|
|
238
244
|
expect(appels.sessions.length).toBe(avant); // aucune session Stripe ouverte
|
|
239
245
|
});
|
|
240
246
|
|
|
241
|
-
test('T-WAG-5 — API par clé, révocation bornée'
|
|
247
|
+
(CLE_A_L_INSCRIPTION ? test : sansObjet('le palier gratuit ne donne pas de clé d’API (maxCles 0)'))(`T-WAG-5 — API par clé, révocation bornée${CLE_A_L_INSCRIPTION ? '' : ' (sans objet : pas de clé d’API au palier gratuit)'}`, async () => {
|
|
242
248
|
const moi = await req(B, '/api/v1/moi', { headers: { authorization: `Bearer ${cleApi}` } });
|
|
243
249
|
expect(moi.status).toBe(200);
|
|
244
250
|
expect(JSON.parse(moi.texte).compte.nom).toBe('Société anis@exemple.test');
|
|
@@ -251,7 +257,7 @@ test('T-WAG-5 — API par clé, révocation bornée', async () => {
|
|
|
251
257
|
expect((await req(B, '/api/v1/moi', { headers: { authorization: `Bearer ${cleApi}` } })).status).toBe(401);
|
|
252
258
|
});
|
|
253
259
|
|
|
254
|
-
(
|
|
260
|
+
(CIBLE && LIMITE ? test : sansObjet('pas de palier payant à limite finie, ou pas de limite'))(`T-WAG-6 — administration : accès, paliers, échelle, usage${CIBLE && LIMITE ? '' : ' (sans objet : pas de palier payant à limite finie, ou pas de limite)'}`, async () => {
|
|
255
261
|
const client = await connecter(B, 'anis@exemple.test');
|
|
256
262
|
expect((await req(B, '/admin/plans', { cookie: client })).status).toBe(404);
|
|
257
263
|
await inscrire(B, 'admin@exemple.test');
|
|
@@ -260,12 +266,12 @@ test('T-WAG-5 — API par clé, révocation bornée', async () => {
|
|
|
260
266
|
await A.w.confirmerAdmin('admin@exemple.test');
|
|
261
267
|
const page = await req(B, '/admin/plans', { cookie: adm });
|
|
262
268
|
expect(page.status).toBe(200);
|
|
263
|
-
expect(page.texte.includes(`name="${
|
|
269
|
+
expect(page.texte.includes(`name="${CIBLE.slug}.${LIMITE}"`)).toBe(true);
|
|
264
270
|
const valeurs = Object.fromEntries(config.paliers.flatMap((p) => Object.entries(p.limits).map(([k, v]) => [`${p.slug}.${k}`, String(v)])));
|
|
265
|
-
const ok = await req(B, '/admin/plans', { method: 'POST', cookie: adm, form: { ...valeurs, [`${
|
|
271
|
+
const ok = await req(B, '/admin/plans', { method: 'POST', cookie: adm, form: { ...valeurs, [`${CIBLE.slug}.${LIMITE}`]: VALEUR_ADMIN } });
|
|
266
272
|
expect(ok.status).toBe(200);
|
|
267
|
-
if (TARIFS) expect((await req(B, '/tarifs')).texte.includes(
|
|
268
|
-
else expect((await req(B, '/admin/plans', { cookie: adm })).texte.includes(
|
|
273
|
+
if (TARIFS) expect((await req(B, '/tarifs')).texte.includes(VALEUR_ADMIN)).toBe(true);
|
|
274
|
+
else expect((await req(B, '/admin/plans', { cookie: adm })).texte.includes(`value="${VALEUR_ADMIN}"`)).toBe(true); // la valeur tient, vue de l'administration
|
|
269
275
|
// L'échelle inversée : le gratuit donnerait PLUS que le payant.
|
|
270
276
|
const inverse = await req(B, '/admin/plans', { method: 'POST', cookie: adm, form: { ...valeurs, [`${GRATUIT.slug}.${LIMITE}`]: '2', [`${PAYANT.slug}.${LIMITE}`]: '1' } });
|
|
271
277
|
expect(/inversée|moins/i.test(inverse.texte)).toBe(true);
|
package/src/wagon.mjs
CHANGED
|
@@ -499,10 +499,13 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
499
499
|
const texte = v.plafond && v.limit ? v.reason : v.permanent ? t('tarifs.aucun') : v.reason;
|
|
500
500
|
return envoyer(code, page({ titre: String(code), contenu: `<h1>${code}</h1><p class="msg">${esc(texte)}</p><p><a class="bouton" href="/tarifs">${esc(t('nav.tarifs'))}</a></p>` }), en);
|
|
501
501
|
};
|
|
502
|
-
/** Le palier de la requête : celui du compte, sinon le palier anonyme.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
502
|
+
/** Le palier de la requête : celui du compte, sinon le palier anonyme. 0.2 (wagon-engine) — `{ compte }` désigne
|
|
503
|
+
* un AUTRE compte (son identifiant) : une requête sans session (l'élève qui rejoint son école) lit le palier de
|
|
504
|
+
* l'école. La charge n'en fait usage que pour un compte qu'elle a elle-même rattaché à la requête. */
|
|
505
|
+
const palier = async ({ compte: designe } = {}) => {
|
|
506
|
+
const cible = designe ? { id: String(designe) } : compte;
|
|
507
|
+
if (cible) {
|
|
508
|
+
const a = await facturation.activePlan(String(cible.id));
|
|
506
509
|
const l = a?.limits ?? {};
|
|
507
510
|
return { slug: a?.plan?.slug ?? null, nom: a?.plan?.name ?? null, limites: typeof l === 'string' ? JSON.parse(l) : l };
|
|
508
511
|
}
|
|
@@ -514,10 +517,10 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
514
517
|
* une unité de plus tient dans le palier. Refus toujours définitif (403) — un stock ne se recharge pas
|
|
515
518
|
* avec le temps ; il se libère (suppression, archivage) ou se relève (palier supérieur).
|
|
516
519
|
*/
|
|
517
|
-
const plafond = async (cle, actuel) => {
|
|
520
|
+
const plafond = async (cle, actuel, { compte: designe } = {}) => {
|
|
518
521
|
const r = ressources.find((x) => x.cle === cle && x.plafond);
|
|
519
522
|
if (!r) throw new Error(`ressource « ${cle} » : pas un plafond déclaré par la charge`);
|
|
520
|
-
const limite = Number((await palier()).limites?.[r.limite] ?? 0);
|
|
523
|
+
const limite = Number((await palier({ compte: designe })).limites?.[r.limite] ?? 0);
|
|
521
524
|
const n = Number(actuel) || 0;
|
|
522
525
|
if (limite === -1) return { allowed: true, plafond: true, resource: cle, current: n, limit: -1 };
|
|
523
526
|
if (!limite) return { allowed: false, permanent: true, plafond: true, resource: cle, current: n, limit: 0, reason: 'plan does not include this resource' };
|
|
@@ -540,7 +543,13 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
540
543
|
};
|
|
541
544
|
// 0.3.1 — `admin` : la session est celle d'un administrateur (config.admins + WAGON_ADMINS) — pour qu'une
|
|
542
545
|
// charge réserve ses outils (ex. l'outil de l'agence de travel) sans recalculer la liste.
|
|
543
|
-
|
|
546
|
+
/** 0.2 (wagon-engine) — un courriel de la charge, par le transport du wagon (SMTP du .env). Rend true s'il est parti,
|
|
547
|
+
* false s'il n'y a pas de transport ou s'il a échoué (journalisé) : jamais une exception dans la route. */
|
|
548
|
+
const courriel = async (m) => {
|
|
549
|
+
if (!transport) return false;
|
|
550
|
+
try { await transport.envoyer(m); return true; } catch (e) { journal.warn(`wagon : courriel de la charge non parti : ${e?.message ?? e}`); return false; }
|
|
551
|
+
};
|
|
552
|
+
const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, courriel, admin: estAdmin && !compteCle };
|
|
544
553
|
if (charge.routes && (await charge.routes(req, res, ctx))) return true;
|
|
545
554
|
if (chemin.startsWith('/api/')) return json(404, { ok: false, error: 'introuvable' });
|
|
546
555
|
|