@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 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.6.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
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.6.1",
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 { venteAutorisee, vendeursFromEnv } from '@mostajs/payment/server';
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 motsDePasse = createPasswordAuth({
221
- credentials: {
222
- get: async (u) => { const r = await d.findOne(S.WagonCredential, { user: String(u) }); return r ? { hash: r.hash, updatedAt: r.updatedAt } : null; },
223
- 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 }); },
224
- },
225
- findUser: async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; },
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
- 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>` : '') }));
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 (oubliPossible && chemin === '/connexion/reinitialiser') {
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
- if (methode === 'GET') return envoyer(200, page({ titre: TC.reinit, contenu: formulaireReinit(url.searchParams.get('t') ?? '') }));
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 ?? '')) return envoyer(400, page({ titre: TC.reinit, contenu: formulaireReinit(String(f.t ?? ''), TC.differents) }));
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.