@mostajs/wagon-engine 0.6.1 → 0.8.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 +35 -0
- package/README.md +2 -0
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/src/wagon.mjs +178 -16
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,41 @@
|
|
|
3
3
|
**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
|
|
4
4
|
Format : Keep a Changelog ; versions : SemVer.
|
|
5
5
|
|
|
6
|
+
## [0.8.0] — 2026-10-29
|
|
7
|
+
|
|
8
|
+
### Ajouté — les membres d'un compte (demandé par CaisseTrax : écart E-08 de SPEC-ECRANS-CAISSETRAX)
|
|
9
|
+
- `ctx.utilisateur` (la personne de la session, sans empreinte) et `ctx.role` (son rôle dans le compte) ; `null`
|
|
10
|
+
sans session et pour une clé d'API.
|
|
11
|
+
- `ctx.membres` (session seulement) : `lister()` (courriel, nom, rôle, statut `actif` | `invite` | `suspendu`),
|
|
12
|
+
`inviter({ courriel, nom, role })` → membre du MÊME compte + lien d'activation (`config.invitationJours`, défaut 7),
|
|
13
|
+
`reinviter(id)`, `changerRole(id, role)`, `retirer(id)`. Le lien part par courriel s'il y a un transport et est
|
|
14
|
+
TOUJOURS rendu à la charge (un commerce n'a pas toujours de courriel). La charge décide qui peut s'en servir.
|
|
15
|
+
- `charge.vues.connexion({ lang, action, erreur, oubli })` et `charge.vues.reinitialiser({ lang, jeton, utilisateur, erreur,
|
|
16
|
+
expire, minimum })` : la greffe rend ces deux pages ENTIÈRES (l'écran de SA maquette), erreurs comprises ; le porteur
|
|
17
|
+
d'un jeton d'invitation est lu sans le consommer (« Bienvenue …, vous êtes invité comme … »).
|
|
18
|
+
- `config.apresConnexion` : la page d'arrivée après connexion ou activation (défaut `/compte`).
|
|
19
|
+
|
|
20
|
+
### Corrigé
|
|
21
|
+
- Les jetons de réinitialisation vivaient en mémoire : un redémarrage perdait les liens en cours. Ils sont rangés en
|
|
22
|
+
base (table `wagon_jetons`, empreinte seule).
|
|
23
|
+
- `/connexion/reinitialiser` est servie dès que la connexion est par mot de passe, même sans courriel : le lien d'une
|
|
24
|
+
invitation remis de la main à la main doit fonctionner. La DEMANDE (`/connexion/oubli`) exige toujours un courriel.
|
|
25
|
+
|
|
26
|
+
Essais T-WAG-30 à 35 (test-scripts/unit/membres.test.mjs) ; 52/52 ; sabotage vérifié (jetons remis en mémoire →
|
|
27
|
+
T-WAG-32 tombe).
|
|
28
|
+
|
|
29
|
+
## [0.7.0] — 2026-10-29
|
|
30
|
+
|
|
31
|
+
### Ajouté
|
|
32
|
+
- **Le paiement ponctuel de la charge** : `ctx.paiement.ouvrir({ moyen, montant, description, reference, metadata,
|
|
33
|
+
merci, annule })` ouvre une session chez une banque prépayée active (celle qui a un vendeur, 0.6) pour un achat à
|
|
34
|
+
l'unité de la charge (une place de cours de Speaking) ; `ctx.paiement.moyens` dit les moyens et leur devise ;
|
|
35
|
+
`ctx.paiement` est `null` sans banque. La banque confirme à `/api/paiement/charge/<moyen>` (webhook authentifié par
|
|
36
|
+
sa signature, hors CSRF et hors session) ou renvoie l'acheteur à `/api/paiement/charge-retour/<moyen>` (banque
|
|
37
|
+
réinterrogée, comme SATIM) ; le wagon remet le constat à `charge.paiementRegle({ reference, statut, commande, montant,
|
|
38
|
+
devise, moyen, metadata })`. Les chemins de retour sont locaux. La ligne `Payment` reste dans la base du wagon.
|
|
39
|
+
Essai T-ENG-16.
|
|
40
|
+
|
|
6
41
|
## [0.6.1] — 2026-10-28
|
|
7
42
|
|
|
8
43
|
### Corrigé
|
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ await demarrer({ config, charge });
|
|
|
50
50
|
|
|
51
51
|
**Le paiement est au choix de l'utilisateur**, parmi les moyens actifs. Un moyen est actif quand ses clés sont dans le `.env` et que son commutateur n'est pas sur `off`. Une banque prépayée doit en plus être proposée par la configuration.
|
|
52
52
|
|
|
53
|
+
**Un achat à l'unité de la charge (0.7).** `ctx.paiement.ouvrir({ moyen, montant, description, reference, metadata, merci, annule })` ouvre un paiement ponctuel (montant en unités mineures) chez une banque prépayée active ; `ctx.paiement.moyens` les liste, `ctx.paiement` est `null` sans banque. La banque confirme à `/api/paiement/charge/<moyen>` (signature) ou y renvoie l'acheteur (`/api/paiement/charge-retour/<moyen>`) ; le wagon appelle `charge.paiementRegle({ reference, statut, commande, montant, devise, moyen, metadata })`, que la charge écrit idempotent.
|
|
54
|
+
|
|
53
55
|
## Juger une greffe
|
|
54
56
|
|
|
55
57
|
La suite de conformité est publiée avec le moteur. Elle juge la greffe du dossier courant, ou celle que désigne `WAGON_GREFFE` :
|
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.8.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
|
## ⚙️ D'EMBLÉE : LE .env ET LA CASCADE @mostajs/config
|
|
@@ -45,10 +45,12 @@ encore immatriculée → `PAIEMENT_VENDEUR_DZD=…`, `PAIEMENT_VENDEUR_EUR=` (vi
|
|
|
45
45
|
## CONTRAT DE LA CHARGE (charge/index.mjs)
|
|
46
46
|
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 ·
|
|
47
47
|
routes(req, res, ctx) (passent en premier ; true si servie) · conformite (essai de quota T-WAG-3) · amorcer() (base au
|
|
48
|
-
démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauvegarde() · permissions (0.11 :
|
|
48
|
+
démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauvegarde() · paiementRegle(p) (0.7) · permissions (0.11 :
|
|
49
49
|
{ '/page': { microphone: 'self' }, '/dossier/*': … }, garde-http ≥ 0.2).
|
|
50
50
|
ctx : chemin, methode, lang, t, ui, page({ titre, contenu }), envoyer, json, rediriger, formulaire, corpsJson({ strict }),
|
|
51
51
|
compte, consommer(cle), refuser(v), palier({ compte? }), plafond(cle, n, { compte? }), courriel({ to, subject, text, html? }) → bool, admin.
|
|
52
|
+
0.8 : ctx.utilisateur, ctx.role ; ctx.membres = { lister(), inviter({ courriel, nom, role }) → { utilisateurId, courriel, role, lien, expiresAt, courrielParti }, reinviter(id), changerRole(id, role), retirer(id) } (session seulement ; la charge garde l'accès) ; config.apresConnexion, config.invitationJours ; jetons en base (wagon_jetons) ; /connexion/reinitialiser servie sans courriel ; charge.vues.connexion(…) / charge.vues.reinitialiser({ jeton, utilisateur, erreur, expire }) → page entière.
|
|
53
|
+
0.7 : ctx.paiement (null sans banque prépayée active) = { moyens: [{ moyen, devise }], ouvrir({ moyen, montant (unités MINEURES), description, reference, metadata, merci: '/chemin', annule: '/chemin' }) → { ok, url, commande } | { ok: false, raison } } — paiement PONCTUEL de la charge (une place de cours…). La banque confirme à /api/paiement/charge/<moyen> (webhook, signature) ou renvoie l'acheteur à /api/paiement/charge-retour/<moyen> (SATIM) ; le wagon appelle alors charge.paiementRegle({ reference, statut: paid|failed|refunded|…, commande, montant (mineures), devise, moyen, metadata }), à écrire IDEMPOTENT (une banque rejoue). merci/annule : chemins locaux seulement.
|
|
52
54
|
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.
|
|
53
55
|
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 /…).
|
|
54
56
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/wagon-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
* faux ; l'administration est invisible (404) à qui n'est pas administrateur.
|
|
20
20
|
*/
|
|
21
21
|
import { readFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
22
23
|
import { join } from 'node:path';
|
|
23
24
|
import { openIsolatedDialect } from '@mostajs/data-plug';
|
|
24
25
|
import { creerWagonUi, esc } from '@mostajs/wagon-ui';
|
|
25
26
|
import { creerComptes, schemasComptes } from '@mostajs/comptes';
|
|
26
27
|
import { createBilling, createStripeSale, createPrepaidSale, billingSchemas, checkLimits, assertMonotonicPlans } from '@mostajs/subscriptions-plan/server';
|
|
27
28
|
import { PaymentSchema } from '@mostajs/payment';
|
|
28
|
-
import
|
|
29
|
+
import * as paymentServer from '@mostajs/payment/server';
|
|
30
|
+
const { venteAutorisee, vendeursFromEnv } = paymentServer;
|
|
29
31
|
import { formatNumber, resolveFormatLocale } from '@mostajs/i18n';
|
|
30
32
|
import { createPasswordAuth, createEmailCodeAuth, SessionSchema, ipClient, memoryLimiter } from '@mostajs/auth-lite';
|
|
31
33
|
import { emailCodeEmailHtml, emailCodeEmailText, emailCodeForm, emailLinkConfirmPage, AUTH_CSS } from '@mostajs/auth-ui';
|
|
@@ -142,12 +144,21 @@ const AdminSchema = {
|
|
|
142
144
|
fields: { courriel: { type: 'string', required: true }, confirmeLe: { type: 'date' }, par: { type: 'string' } },
|
|
143
145
|
relations: {}, indexes: [{ fields: { courriel: 'asc' }, unique: true }],
|
|
144
146
|
};
|
|
147
|
+
/**
|
|
148
|
+
* 0.8 — les JETONS à usage unique (réinitialisation, invitation), RANGÉS EN BASE : jusqu'à la 0.7 ils vivaient en
|
|
149
|
+
* mémoire, et un redémarrage perdait le lien qu'un invité n'avait pas encore ouvert. Seule l'empreinte est rangée.
|
|
150
|
+
*/
|
|
151
|
+
const JetonSchema = {
|
|
152
|
+
name: 'WagonJeton', collection: 'wagon_jetons', timestamps: false,
|
|
153
|
+
fields: { cle: { type: 'string', required: true }, user: { type: 'string', required: true }, expiresAt: { type: 'number', required: true } },
|
|
154
|
+
relations: {}, indexes: [{ fields: { cle: 'asc' }, unique: true }],
|
|
155
|
+
};
|
|
145
156
|
export const schemasWagon = () => {
|
|
146
157
|
const vus = new Set();
|
|
147
158
|
// `Payment` (@mostajs/payment) : une ligne par session de banque, que le webhook retrouve pour clore la vente.
|
|
148
159
|
// Déclarée même sans banque prépayée — incident RestoTrax du 19/08/2026 : « no such table: payments » APRÈS
|
|
149
160
|
// que le client eut engagé son paiement.
|
|
150
|
-
return [...schemasComptes(), ...billingSchemas(), PaymentSchema, SessionSchema, CredentialSchema, AdminSchema].filter((s) => (vus.has(s.name) ? false : vus.add(s.name)));
|
|
161
|
+
return [...schemasComptes(), ...billingSchemas(), PaymentSchema, SessionSchema, CredentialSchema, AdminSchema, JetonSchema].filter((s) => (vus.has(s.name) ? false : vus.add(s.name)));
|
|
151
162
|
};
|
|
152
163
|
|
|
153
164
|
const lireCorps = (req, max = 1_000_000) => new Promise((ok, ko) => {
|
|
@@ -217,13 +228,21 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
217
228
|
const purgerSessions = () => sessions.purger?.().catch((e) => journal.warn(`wagon : purge des sessions : ${e?.message ?? e}`));
|
|
218
229
|
purgerSessions(); setInterval(purgerSessions, 6 * 3600 * 1000).unref?.();
|
|
219
230
|
const freinInscription = memoryLimiter({ limits: { byIp: config.freinInscription ?? { capacity: 10, refillPerSec: 1 / 60 } } });
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
const identifiants = {
|
|
232
|
+
get: async (u) => { const r = await d.findOne(S.WagonCredential, { user: String(u) }); return r ? { hash: r.hash, updatedAt: r.updatedAt } : null; },
|
|
233
|
+
set: async (u, rec) => { const r = await d.findOne(S.WagonCredential, { user: String(u) }); if (r) await d.update(S.WagonCredential, String(r.id), rec); else await d.create(S.WagonCredential, { user: String(u), ...rec }); },
|
|
234
|
+
};
|
|
235
|
+
const trouverUtilisateur = async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; };
|
|
236
|
+
// 0.8 — les jetons en base (voir JetonSchema), partagés par la réinitialisation et l'invitation.
|
|
237
|
+
const jetons = {
|
|
238
|
+
get: async (cle) => { const r = await d.findOne(S.WagonJeton, { cle }); return r ? { userId: String(r.user), expiresAt: Number(r.expiresAt) } : null; },
|
|
239
|
+
put: async (cle, e) => { await d.create(S.WagonJeton, { cle, user: String(e.userId), expiresAt: Number(e.expiresAt) }); },
|
|
240
|
+
delete: async (cle) => { for (const r of await d.find(S.WagonJeton, { cle })) await d.delete(S.WagonJeton, String(r.id)); },
|
|
241
|
+
};
|
|
242
|
+
const motsDePasse = createPasswordAuth({ credentials: identifiants, findUser: trouverUtilisateur, resetTokens: jetons });
|
|
243
|
+
// L'invitation : même magasin, durée plus longue (un invité n'ouvre pas son courriel dans la demi-heure).
|
|
244
|
+
const invitations = createPasswordAuth({ credentials: identifiants, findUser: trouverUtilisateur, resetTokens: jetons, limiter: false,
|
|
245
|
+
resetTtlMs: (config.invitationJours ?? 7) * 86400000 });
|
|
227
246
|
const codes = createEmailCodeAuth({ findUser: async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; } });
|
|
228
247
|
const vente = stripe ? createStripeSale({
|
|
229
248
|
billing: facturation, stripe, webhookSecret, metadataKeys: META,
|
|
@@ -259,6 +278,53 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
259
278
|
return faits;
|
|
260
279
|
}
|
|
261
280
|
if (prepaye && Object.keys(banques).some((b) => SANS_WEBHOOK.includes(b))) setInterval(reprendrePaiements, 10 * 60 * 1000).unref?.();
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 0.7 — le PAIEMENT PONCTUEL de la charge (une place de cours, un achat à l'unité), par les banques prépayées
|
|
284
|
+
* actives (celles qui ont un vendeur) : `ctx.paiement.ouvrir(…)` ouvre la session chez la banque ; la banque confirme
|
|
285
|
+
* à /api/paiement/charge/<moyen> (webhook) ou y renvoie l'acheteur (/api/paiement/charge-retour/<moyen>, SATIM). Le
|
|
286
|
+
* paiement se constate par @mostajs/payment (signature vérifiée, ou banque réinterrogée : rien de l'URL n'est cru),
|
|
287
|
+
* puis le wagon appelle `charge.paiementRegle({ reference, statut, commande, montant, devise, moyen, metadata })`.
|
|
288
|
+
* Le wagon ne garde que la ligne Payment ; ce que le paiement règle (une réservation…) est l'affaire de la charge,
|
|
289
|
+
* qui doit être idempotente (une banque rejoue ses webhooks).
|
|
290
|
+
*/
|
|
291
|
+
const portPaiement = async () => paiement?.port?.createPaymentCheckout ? paiement.port : paymentServer;
|
|
292
|
+
const CHEMIN_LOCAL = /^\/(?!\/)[^\s]*$/;
|
|
293
|
+
const paiementCharge = Object.keys(banques).length ? Object.freeze({
|
|
294
|
+
moyens: Object.entries(banques).map(([moyen, devise]) => ({ moyen, devise: String(devise).toUpperCase() })),
|
|
295
|
+
/** `montant` en unités MINEURES ; `merci` et `annule` : des chemins de la greffe. Rend { ok, url, commande } ou { ok: false, raison }. */
|
|
296
|
+
async ouvrir({ moyen, montant, description = '', reference, metadata = {}, merci = '/', annule = '/' }) {
|
|
297
|
+
const devise = banques[moyen];
|
|
298
|
+
if (!devise) return { ok: false, raison: `moyen non proposé : ${moyen}` };
|
|
299
|
+
const m = Math.round(Number(montant));
|
|
300
|
+
if (!(m > 0)) return { ok: false, raison: 'montant invalide' };
|
|
301
|
+
if (!reference || !CHEMIN_LOCAL.test(merci) || !CHEMIN_LOCAL.test(annule)) return { ok: false, raison: 'référence ou chemins de retour invalides' };
|
|
302
|
+
const commande = `ch_${String(reference).replace(/[^\w-]/g, '').slice(0, 40)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
303
|
+
try {
|
|
304
|
+
const r = await (await portPaiement()).createPaymentCheckout({
|
|
305
|
+
dialect: d, providerName: moyen, orderId: commande, amount: m / 100, currency: String(devise).toUpperCase(), description,
|
|
306
|
+
successUrl: `${origine}${merci}`, cancelUrl: `${origine}${annule}`,
|
|
307
|
+
webhookUrl: `${origine}/api/paiement/charge/${moyen}`, returnUrl: `${origine}/api/paiement/charge-retour/${moyen}`,
|
|
308
|
+
metadata: { ...metadata, wagon_charge: String(reference), wagon_merci: merci, wagon_annule: annule },
|
|
309
|
+
});
|
|
310
|
+
if (!r?.checkout?.url) return { ok: false, raison: 'la banque n’a pas rendu d’adresse de paiement' };
|
|
311
|
+
return { ok: true, url: r.checkout.url, commande };
|
|
312
|
+
} catch (e) { return { ok: false, raison: String(e?.message ?? e) }; }
|
|
313
|
+
},
|
|
314
|
+
}) : null;
|
|
315
|
+
/** Constate un paiement de la charge et le lui remet : { status, paye, meta }. */
|
|
316
|
+
async function reglerCharge(moyen, corpsBrut, headers) {
|
|
317
|
+
if (!banques[moyen]) return { status: 404, paye: false, meta: {} };
|
|
318
|
+
const r = await (await portPaiement()).settlePaymentFromWebhook({ dialect: d, providerName: moyen, body: corpsBrut, headers });
|
|
319
|
+
if (!r?.ok) return { status: 400, paye: false, meta: {} };
|
|
320
|
+
const pay = r.payment;
|
|
321
|
+
const meta = typeof pay?.metadata === 'string' ? JSON.parse(pay.metadata || '{}') : (pay?.metadata ?? {});
|
|
322
|
+
if (!pay || !r.status || !meta.wagon_charge) return { status: 200, paye: false, meta };
|
|
323
|
+
try {
|
|
324
|
+
await charge.paiementRegle?.({ reference: meta.wagon_charge, statut: r.status, commande: String(r.orderId ?? pay.orderId), montant: Math.round(Number(pay.amount) * 100), devise: pay.currency, moyen, metadata: meta });
|
|
325
|
+
} catch (e) { journal.warn(`wagon : paiementRegle de la charge : ${e?.message ?? e}`); return { status: 500, paye: false, meta }; }
|
|
326
|
+
return { status: 200, paye: r.status === 'paid', meta };
|
|
327
|
+
}
|
|
262
328
|
const admins = new Set([...(config.admins ?? []), ...String(getEnv('WAGON_ADMINS', '') ?? '').split(',')].map((x) => x.trim().toLowerCase()).filter(Boolean));
|
|
263
329
|
/** Un courriel est administrateur s'il est DÉCLARÉ et CONFIRMÉ. */
|
|
264
330
|
const estAdministrateur = async (courriel) => {
|
|
@@ -446,6 +512,53 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
446
512
|
});
|
|
447
513
|
}
|
|
448
514
|
|
|
515
|
+
/**
|
|
516
|
+
* 0.8 — les MEMBRES du compte d'une session : la charge décide QUI peut s'en servir (le wagon ne connaît pas ses
|
|
517
|
+
* rôles). Inviter crée un membre du MÊME compte, au mot de passe impossible, et rend un lien d'activation (sept
|
|
518
|
+
* jours, `config.invitationJours`) : envoyé par courriel s'il y a un transport, toujours rendu à la charge, pour
|
|
519
|
+
* qu'elle le montre (un commerce n'a pas toujours de courriel).
|
|
520
|
+
*/
|
|
521
|
+
const membresDe = (compteSession) => {
|
|
522
|
+
const cid = String(compteSession.id);
|
|
523
|
+
const lienDe = (token) => `${origine}/connexion/reinitialiser?t=${encodeURIComponent(token)}`;
|
|
524
|
+
const envoyerInvitation = async (courriel, nom, lien) => {
|
|
525
|
+
if (!transport) return false;
|
|
526
|
+
try {
|
|
527
|
+
await transport.envoyer({ to: courriel, subject: `${config.marque.nom} — invitation`,
|
|
528
|
+
text: [config.marque.nom, '', `${nom ? `${nom}, v` : 'V'}ous êtes invité(e). Choisissez votre mot de passe :`, lien, '', `Ce lien vaut ${config.invitationJours ?? 7} jours.`, ''].join('\n') });
|
|
529
|
+
return true;
|
|
530
|
+
} catch (e) { journal.warn(`wagon : invitation non partie : ${e?.message ?? e}`); return false; }
|
|
531
|
+
};
|
|
532
|
+
return {
|
|
533
|
+
async lister() {
|
|
534
|
+
const out = [];
|
|
535
|
+
for (const m of await comptes.membres.lister(cid)) {
|
|
536
|
+
const u = await d.findById(S.User, m.utilisateurId).catch(() => null);
|
|
537
|
+
out.push({ ...m, nom: u?.firstName ?? null, statut: u?.status !== 'active' ? 'suspendu' : (await motsDePasse.hasPassword(m.utilisateurId)) ? 'actif' : 'invite' });
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
},
|
|
541
|
+
async inviter({ courriel, nom = null, role = 'membre' } = {}) {
|
|
542
|
+
const r = await comptes.membres.ajouter(cid, courriel, String(role || 'membre'));
|
|
543
|
+
const n = String(nom ?? '').trim();
|
|
544
|
+
if (n) await d.update(S.User, r.utilisateurId, { firstName: n });
|
|
545
|
+
const { token, expiresAt } = await invitations.issueToken(r.utilisateurId);
|
|
546
|
+
const lien = lienDe(token);
|
|
547
|
+
return { ...r, nom: n || null, lien, expiresAt: new Date(expiresAt).toISOString(), courrielParti: await envoyerInvitation(r.courriel, n, lien) };
|
|
548
|
+
},
|
|
549
|
+
async reinviter(utilisateurId) {
|
|
550
|
+
const m = (await comptes.membres.lister(cid)).find((x) => x.utilisateurId === String(utilisateurId));
|
|
551
|
+
if (!m) throw Object.assign(new Error('Cet utilisateur n’est pas membre de ce compte.'), { code: 'NON_MEMBRE' });
|
|
552
|
+
const u = await d.findById(S.User, m.utilisateurId).catch(() => null);
|
|
553
|
+
const { token, expiresAt } = await invitations.issueToken(m.utilisateurId);
|
|
554
|
+
const lien = lienDe(token);
|
|
555
|
+
return { ...m, lien, expiresAt: new Date(expiresAt).toISOString(), courrielParti: await envoyerInvitation(m.courriel, u?.firstName, lien) };
|
|
556
|
+
},
|
|
557
|
+
changerRole: (utilisateurId, role) => comptes.membres.changerRole(cid, String(utilisateurId), String(role)),
|
|
558
|
+
retirer: (utilisateurId) => comptes.membres.retirer(cid, String(utilisateurId)),
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
|
|
449
562
|
async function handler(req, res) {
|
|
450
563
|
const url = new URL(req.url, 'http://wagon.local');
|
|
451
564
|
const chemin = url.pathname;
|
|
@@ -556,6 +669,19 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
556
669
|
return rediriger(r.paid ? '/paiement/merci' : '/tarifs');
|
|
557
670
|
}
|
|
558
671
|
|
|
672
|
+
// 0.7 — les paiements ponctuels de la charge : webhook (signature) et retour de l'acheteur (banque réinterrogée).
|
|
673
|
+
if (chemin.startsWith('/api/paiement/charge/') && methode === 'POST') {
|
|
674
|
+
if (!paiementCharge) return json(503, { ok: false, error: 'paiement non configuré' });
|
|
675
|
+
const r = await reglerCharge(decodeURIComponent(chemin.slice('/api/paiement/charge/'.length)), (await corps()).toString('utf8'), req.headers);
|
|
676
|
+
return json(r.status, { ok: r.status === 200 });
|
|
677
|
+
}
|
|
678
|
+
if (chemin.startsWith('/api/paiement/charge-retour/') && (methode === 'GET' || methode === 'POST')) {
|
|
679
|
+
if (!paiementCharge) return rediriger('/');
|
|
680
|
+
const r = await reglerCharge(decodeURIComponent(chemin.slice('/api/paiement/charge-retour/'.length)), methode === 'GET' ? url.searchParams.toString() : (await corps()).toString('utf8'), req.headers);
|
|
681
|
+
const vers = r.paye ? r.meta.wagon_merci : r.meta.wagon_annule;
|
|
682
|
+
return rediriger(typeof vers === 'string' && CHEMIN_LOCAL.test(vers) ? vers : '/');
|
|
683
|
+
}
|
|
684
|
+
|
|
559
685
|
// La porte de service : une clé d'API ouvre /api/v1/* ; sans clé valable, 401 — jamais une page.
|
|
560
686
|
let compteCle = null;
|
|
561
687
|
if (chemin.startsWith('/api/v1/')) {
|
|
@@ -631,7 +757,11 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
631
757
|
if (!transport) return false;
|
|
632
758
|
try { await transport.envoyer(m); return true; } catch (e) { journal.warn(`wagon : courriel de la charge non parti : ${e?.message ?? e}`); return false; }
|
|
633
759
|
};
|
|
634
|
-
const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, courriel, admin: estAdmin && !compteCle
|
|
760
|
+
const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, courriel, admin: estAdmin && !compteCle,
|
|
761
|
+
paiement: paiementCharge, // 0.7 — null sans banque prépayée active
|
|
762
|
+
// 0.8 — la PERSONNE de la session (sans empreinte) et son rôle dans le compte ; les membres du compte.
|
|
763
|
+
utilisateur: compteCle ? null : (qui?.utilisateur ?? null), role: compteCle ? null : (qui?.role ?? null),
|
|
764
|
+
membres: qui && !compteCle ? membresDe(qui.compte) : null };
|
|
635
765
|
if (charge.routes && (await charge.routes(req, res, ctx))) return true;
|
|
636
766
|
if (chemin.startsWith('/api/')) return json(404, { ok: false, error: 'introuvable' });
|
|
637
767
|
|
|
@@ -667,6 +797,10 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
667
797
|
// 0.5 — « mot de passe oublié » n'existe que si un courriel peut partir : un accusé de réception d'un message que
|
|
668
798
|
// personne n'enverra serait pire qu'une page absente (auth-plug T-KIT-5).
|
|
669
799
|
const oubliPossible = methodeConnexion === 'motdepasse' && !!transport;
|
|
800
|
+
// 0.8 — la page qui consomme un jeton est servie dès que l'on se connecte par mot de passe : le lien d'une
|
|
801
|
+
// INVITATION remis de la main à la main (sans courriel) doit fonctionner. Seule la DEMANDE (/connexion/oubli)
|
|
802
|
+
// exige un courriel possible.
|
|
803
|
+
const reinitPossible = methodeConnexion === 'motdepasse';
|
|
670
804
|
if (chemin === '/inscription') {
|
|
671
805
|
const tous = await paliersEnBase();
|
|
672
806
|
const palierDe = (slug) => tous.find((p) => p.slug === slug && p.active !== false) ?? tous.find((p) => p.slug === (config.planGratuit ?? 'gratuit'));
|
|
@@ -713,11 +847,18 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
713
847
|
if (chemin === '/connexion') {
|
|
714
848
|
const suite = suiteSure(url.searchParams.get('suite'));
|
|
715
849
|
const action = avecSuite('/connexion', suite);
|
|
716
|
-
|
|
850
|
+
// 0.8 — `charge.vues.connexion({ lang, action, erreur, oubli })` rend la page ENTIÈRE à la place de wagon-ui
|
|
851
|
+
// (champs « courriel » et « motdepasse », POST vers `action`) : l'écran de la maquette de la greffe.
|
|
852
|
+
const vueConnexion = methodeConnexion === 'motdepasse' && typeof charge.vues?.connexion === 'function' ? charge.vues.connexion : null;
|
|
853
|
+
if (methode === 'GET') {
|
|
854
|
+
if (vueConnexion) return envoyer(200, await vueConnexion({ lang, action, erreur: null, oubli: oubliPossible ? '/connexion/oubli' : null }));
|
|
855
|
+
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>` : '') }));
|
|
856
|
+
}
|
|
717
857
|
const f = await formulaire();
|
|
718
858
|
if (methodeConnexion === 'motdepasse') {
|
|
719
859
|
const r = await motsDePasse.login(String(f.courriel ?? ''), String(f.motdepasse ?? ''), { ip: ipClient(req) });
|
|
720
|
-
if (r.ok) return ouvrirSession(r.userId, suite ?? '/compte');
|
|
860
|
+
if (r.ok) return ouvrirSession(r.userId, suite ?? config.apresConnexion ?? '/compte');
|
|
861
|
+
if (vueConnexion) return envoyer(r.reason === 'throttled' ? 429 : 200, await vueConnexion({ lang, action, erreur: t('espace.identifiantsFaux'), oubli: oubliPossible ? '/connexion/oubli' : null }));
|
|
721
862
|
return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux'), action }) }));
|
|
722
863
|
}
|
|
723
864
|
const courriel = String(f.courriel ?? '').trim().toLowerCase();
|
|
@@ -764,15 +905,36 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
|
|
|
764
905
|
}
|
|
765
906
|
return true;
|
|
766
907
|
}
|
|
767
|
-
if (
|
|
908
|
+
if (reinitPossible && chemin === '/connexion/reinitialiser') {
|
|
768
909
|
const formulaireReinit = (tk, erreur = '') => `<h1>${esc(TC.reinit)}</h1>${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}<form method="post" action="/connexion/reinitialiser"><input type="hidden" name="t" value="${esc(tk)}"><p><label>${esc(TC.nouveau)} <input type="password" name="nouveau" required autocomplete="new-password"></label></p><p><label>${esc(TC.confirmer)} <input type="password" name="confirmer" required autocomplete="new-password"></label></p><button class="bouton">${esc(TC.enregistrer)}</button></form>`;
|
|
769
|
-
|
|
910
|
+
// 0.8 — `charge.vues.reinitialiser({ lang, jeton, utilisateur, erreur, expire })` rend la page ENTIÈRE (champs
|
|
911
|
+
// « t », « nouveau », « confirmer ») : une invitation s'y présente (« Bienvenue …, vous êtes invité comme … »).
|
|
912
|
+
// `utilisateur` (id, courriel, prenom) est lu sur le jeton SANS le consommer ; null si le lien ne vaut plus.
|
|
913
|
+
const vueReinit = typeof charge.vues?.reinitialiser === 'function' ? charge.vues.reinitialiser : null;
|
|
914
|
+
const porteurDe = async (tk) => {
|
|
915
|
+
const e = tk ? await jetons.get(createHash('sha256').update(String(tk)).digest('base64url')) : null;
|
|
916
|
+
if (!e || e.expiresAt < Date.now()) return null;
|
|
917
|
+
const u = await d.findById(S.User, e.userId).catch(() => null);
|
|
918
|
+
return u ? { id: String(u.id), courriel: u.email, prenom: u.firstName ?? null } : null;
|
|
919
|
+
};
|
|
920
|
+
const vueR = async (code, tk, erreur) => {
|
|
921
|
+
const u = await porteurDe(tk);
|
|
922
|
+
return envoyer(code, await vueReinit({ lang, jeton: tk, utilisateur: u, erreur, expire: !u, minimum: motsDePasse.policy.minLength }));
|
|
923
|
+
};
|
|
924
|
+
if (methode === 'GET') {
|
|
925
|
+
if (vueReinit) return vueR(200, url.searchParams.get('t') ?? '', null);
|
|
926
|
+
return envoyer(200, page({ titre: TC.reinit, contenu: formulaireReinit(url.searchParams.get('t') ?? '') }));
|
|
927
|
+
}
|
|
770
928
|
const f = await formulaire();
|
|
771
|
-
if (String(f.nouveau ?? '') !== String(f.confirmer ?? ''))
|
|
929
|
+
if (String(f.nouveau ?? '') !== String(f.confirmer ?? '')) {
|
|
930
|
+
if (vueReinit) return vueR(400, String(f.t ?? ''), TC.differents);
|
|
931
|
+
return envoyer(400, page({ titre: TC.reinit, contenu: formulaireReinit(String(f.t ?? ''), TC.differents) }));
|
|
932
|
+
}
|
|
772
933
|
const r = await motsDePasse.consumeReset(String(f.t ?? ''), String(f.nouveau ?? ''));
|
|
934
|
+
if (!r.ok && vueReinit) return vueR(400, String(f.t ?? ''), r.reason === 'policy' ? t('espace.faible', { motifs: (r.issues ?? []).join(', ') }) : TC.lienMort);
|
|
773
935
|
if (!r.ok) return envoyer(400, page({ titre: TC.reinit, contenu: r.reason === 'policy' ? formulaireReinit(String(f.t ?? ''), t('espace.faible', { motifs: (r.issues ?? []).join(', ') })) : `<h1>${esc(TC.reinit)}</h1><p class="msg">${esc(TC.lienMort)}</p><p><a href="/connexion/oubli">${esc(TC.oublie)}</a></p>` }));
|
|
774
936
|
await sessions.fermerToutes(String(r.userId));
|
|
775
|
-
return ouvrirSession(r.userId);
|
|
937
|
+
return ouvrirSession(r.userId, config.apresConnexion ?? '/compte');
|
|
776
938
|
}
|
|
777
939
|
|
|
778
940
|
// L'espace du client.
|