@mostajs/wagon-engine 0.7.0 → 0.9.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,54 @@
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
  Format : Keep a Changelog ; versions : SemVer.
5
5
 
6
+ ## [0.9.0] — 2026-10-29
7
+
8
+ ### Modifié — les clés du `.env` sans préfixe `WAGON_` (demande du fondateur)
9
+ Le préfixe `WAGON_` ne disait ni la fonction de la clé ni son sens métier, et se mêlait mal au profil `MOSTA_ENV`
10
+ (`PROD_SGBD_URI` n'était pas lu : la base des comptes retombait sur `data/wagon.db`). Les clés portent désormais le
11
+ nom de leur fonction ; le seul préfixe est celui du profil. **Les anciens noms ne sont plus lus** (pas de secours) :
12
+ le démarrage avertit pour chacun qui reste (`signalerClesRetirees`).
13
+
14
+ | retiré | remplacé par |
15
+ |---|---|
16
+ | `WAGON_SGBD`, `WAGON_DB_DIALECT` | `SGBD_URI`, `DB_DIALECT` |
17
+ | `WAGON_BASE_URL` | `BASE_URL` |
18
+ | `WAGON_ADMINS` | `ADMIN_EMAILS` |
19
+ | `WAGON_EMAIL_SERVER_HOST`/`_PORT`/`_USER`/`_PASSWORD`, `WAGON_EMAIL_FROM` | `EMAIL_SERVER_HOST`/`_PORT`/`_USER`/`_PASSWORD`, `EMAIL_FROM` |
20
+ | `WAGON_COURRIEL_FICHIER` | `EMAIL_FICHIER` |
21
+ | `WAGON_STRIPE_RESTRICTED_KEY`, `WAGON_STRIPE_SECRET_KEY`, `WAGON_STRIPE_WEBHOOK_SECRET` | `STRIPE_RESTRICTED_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` |
22
+ | `WAGON_GREFFE` (suite de conformité) | `GREFFE` |
23
+
24
+ Toutes ces clés sont lues par `getEnv` de `@mostajs/config` (la suite de conformité aussi, qui lisait `process.env`).
25
+ Ajouté : `adminsDeclares(config)` (la liste des administrateurs déclarés, pour les scripts d'administration des
26
+ greffes), `signalerClesRetirees(journal)`, `CLES_RETIREES`.
27
+
28
+ Essai T-ENG-17 (cascade `MOSTA_ENV=PROD` → `PROD_SGBD_URI` ; `WAGON_SGBD` ignoré et signalé) ; 53/53 ; sabotage vérifié
29
+ (relire `WAGON_SGBD` → T-ENG-17 tombe).
30
+
31
+ ## [0.8.0] — 2026-10-29
32
+
33
+ ### Ajouté — les membres d'un compte (demandé par CaisseTrax : écart E-08 de SPEC-ECRANS-CAISSETRAX)
34
+ - `ctx.utilisateur` (la personne de la session, sans empreinte) et `ctx.role` (son rôle dans le compte) ; `null`
35
+ sans session et pour une clé d'API.
36
+ - `ctx.membres` (session seulement) : `lister()` (courriel, nom, rôle, statut `actif` | `invite` | `suspendu`),
37
+ `inviter({ courriel, nom, role })` → membre du MÊME compte + lien d'activation (`config.invitationJours`, défaut 7),
38
+ `reinviter(id)`, `changerRole(id, role)`, `retirer(id)`. Le lien part par courriel s'il y a un transport et est
39
+ TOUJOURS rendu à la charge (un commerce n'a pas toujours de courriel). La charge décide qui peut s'en servir.
40
+ - `charge.vues.connexion({ lang, action, erreur, oubli })` et `charge.vues.reinitialiser({ lang, jeton, utilisateur, erreur,
41
+ expire, minimum })` : la greffe rend ces deux pages ENTIÈRES (l'écran de SA maquette), erreurs comprises ; le porteur
42
+ d'un jeton d'invitation est lu sans le consommer (« Bienvenue …, vous êtes invité comme … »).
43
+ - `config.apresConnexion` : la page d'arrivée après connexion ou activation (défaut `/compte`).
44
+
45
+ ### Corrigé
46
+ - Les jetons de réinitialisation vivaient en mémoire : un redémarrage perdait les liens en cours. Ils sont rangés en
47
+ base (table `wagon_jetons`, empreinte seule).
48
+ - `/connexion/reinitialiser` est servie dès que la connexion est par mot de passe, même sans courriel : le lien d'une
49
+ invitation remis de la main à la main doit fonctionner. La DEMANDE (`/connexion/oubli`) exige toujours un courriel.
50
+
51
+ Essais T-WAG-30 à 35 (test-scripts/unit/membres.test.mjs) ; 52/52 ; sabotage vérifié (jetons remis en mémoire →
52
+ T-WAG-32 tombe).
53
+
6
54
  ## [0.7.0] — 2026-10-29
7
55
 
8
56
  ### Ajouté
package/README.md CHANGED
@@ -34,17 +34,17 @@ await demarrer({ config, charge });
34
34
  | `.env` | rôle |
35
35
  |---|---|
36
36
  | `PORT`, `HOST` | écoute (4800, 127.0.0.1) |
37
- | `WAGON_BASE_URL` | l'adresse publique (liens des courriels, retours de paiement) |
38
- | `WAGON_DB_DIALECT`, `WAGON_SGBD` | la base : `sqlite` et un chemin, ou `postgres` et `postgresql://…` |
39
- | `WAGON_EMAIL_SERVER_HOST`, `…_PORT`, `…_USER`, `…_PASSWORD`, `WAGON_EMAIL_FROM` | le courriel (SMTP) |
40
- | `WAGON_STRIPE_SECRET_KEY`, `WAGON_STRIPE_WEBHOOK_SECRET` | Stripe |
37
+ | `BASE_URL` | l'adresse publique (liens des courriels, retours de paiement) |
38
+ | `DB_DIALECT`, `SGBD_URI` | la base : `sqlite` et un chemin, ou `postgres` et `postgresql://…` |
39
+ | `EMAIL_SERVER_HOST`, `…_PORT`, `…_USER`, `…_PASSWORD`, `EMAIL_FROM` | le courriel (SMTP) |
40
+ | `STRIPE_SECRET_KEY` (ou `STRIPE_RESTRICTED_KEY`), `STRIPE_WEBHOOK_SECRET` | Stripe |
41
41
  | `CHARGILY_SECRET_KEY` · `SATIM_MERCHANT_ID`, `SATIM_PASSWORD`, `SATIM_TERMINAL_ID` · `PAYPAL_…` | les banques prépayées proposées par `config.paiement.prepaye` |
42
42
  | `PAIEMENT_STRIPE=off`, `PAIEMENT_CHARGILY=off`, `PAIEMENT_SATIM=off`, `PAIEMENT_PAYPAL=off` | **coupe un moyen** sans toucher à ses clés |
43
- | `WAGON_ADMINS` | les courriels administrateurs (confirmés par un code reçu) |
43
+ | `ADMIN_EMAILS` | les courriels administrateurs (confirmés par un code reçu) |
44
44
  | `PAIEMENT_VENDEUR_DZD`, `PAIEMENT_VENDEUR_EUR`… | 0.6 — **le vendeur de chaque devise** (dénomination, forme, adresse, immatriculations) ; dès qu'une est déclarée, une devise sans vendeur n'est pas vendue, et `{{VENDEUR}}` des CGV les nomme |
45
45
  | `PAIEMENT_STRIPE_DEVISES` | 0.6 — la devise de Stripe (défaut `EUR`) |
46
46
 
47
- **Le `.env` d'abord.** Toutes les clés passent par `getEnv` de `@mostajs/config` : cascade `MOSTA_ENV` (`PROD_CLÉ` l'emporte sur `CLÉ`), valeur vide = absente, et `loadEnv` n'écrase jamais une variable déjà posée par l'orchestrateur. Si `wagon.config.mjs` lit des variables, il appelle `loadEnv` lui-même, en tête.
47
+ **Le `.env` d'abord.** Toutes les clés passent par `getEnv` de `@mostajs/config` : cascade `MOSTA_ENV` (`PROD_CLÉ` l'emporte sur `CLÉ`), valeur vide = absente, et `loadEnv` n'écrase jamais une variable déjà posée par l'orchestrateur. Une clé porte le nom de sa fonction ; le seul préfixe est celui du profil (`PROD_SGBD_URI`, `DEV_BASE_URL`). Depuis la 0.9, les anciens noms `WAGON_…` ne sont plus lus : le démarrage signale ceux qui restent. Si `wagon.config.mjs` lit des variables, il appelle `loadEnv` lui-même, en tête.
48
48
 
49
49
  **Un vendeur par devise (0.6).** Un SaaS qui vend en dinars par un auto-entrepreneur et en euros par une société pose `PAIEMENT_VENDEUR_DZD=…` et `PAIEMENT_VENDEUR_EUR=…`. Une devise déclarée vide (`PAIEMENT_VENDEUR_EUR=`, la société n'est pas encore immatriculée) n'est pas vendue : Stripe est écarté même avec sa clé. Sans aucune `PAIEMENT_VENDEUR_*`, rien ne change.
50
50
 
@@ -54,7 +54,7 @@ await demarrer({ config, charge });
54
54
 
55
55
  ## Juger une greffe
56
56
 
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` :
57
+ La suite de conformité est publiée avec le moteur. Elle juge la greffe du dossier courant, ou celle que désigne `GREFFE` :
58
58
 
59
59
  ```bash
60
60
  npx mjs-unit node_modules/@mostajs/wagon-engine/src/conformite/conformite.test.mjs
@@ -13,7 +13,7 @@ import { demarrer } from '../src/index.js';
13
13
  const racine = mkdtempSync(join(tmpdir(), 'greffe-'));
14
14
  mkdirSync(join(racine, 'legal'));
15
15
  writeFileSync(join(racine, 'legal', 'cgu.fr.md'), '# Conditions\n\n{{SERVICE}} est édité par Exemple SARL.\n');
16
- writeFileSync(join(racine, '.env'), `PORT=0\nWAGON_SGBD=${join(racine, 'wagon.db')}\nWAGON_BASE_URL=http://127.0.0.1\nCHARGILY_SECRET_KEY=test_sk_exemple\nPAIEMENT_STRIPE=off\n`);
16
+ writeFileSync(join(racine, '.env'), `PORT=0\nSGBD_URI=${join(racine, 'wagon.db')}\nBASE_URL=http://127.0.0.1\nCHARGILY_SECRET_KEY=test_sk_exemple\nPAIEMENT_STRIPE=off\n`);
17
17
 
18
18
  const config = {
19
19
  marque: { nom: 'Échos', symbole: 'É', auteur: 'Exemple SARL' }, langues: ['fr', 'en', 'ar'], connexion: 'motdepasse',
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
@@ -17,7 +17,7 @@ Une greffe se configure par SON `.env` (dossier courant de `demarrer`, ou `racin
17
17
  conteneur) prime sur le fichier.
18
18
  3. **Cascade** (première valeur NON VIDE) : `${MOSTA_ENV}_${CLÉ}` → `${CLÉ}` → défaut. Un seul .env, des profils
19
19
  `TEST_`/`DEV_`/`PROD_`. Toutes les clés ci-dessous, et celles de `@mostajs/payment`, passent par `getEnv`.
20
- 4. **Vide = absent** : `WAGON_STRIPE_SECRET_KEY=` ne configure rien. Exception voulue : `PAIEMENT_VENDEUR_<DEVISE>=`
20
+ 4. **Vide = absent** : `STRIPE_SECRET_KEY=` ne configure rien. Exception voulue : `PAIEMENT_VENDEUR_<DEVISE>=`
21
21
  vide DÉCLARE la devise sans la nommer (elle n'est pas vendue).
22
22
  5. Les secrets et les informations de l'entreprise sont posés par l'exploitant sur le serveur, jamais commités ;
23
23
  `.env.example` de la greffe liste chaque clé, sans valeur.
@@ -27,13 +27,13 @@ Une greffe se configure par SON `.env` (dossier courant de `demarrer`, ou `racin
27
27
  - creerWagon({ config, charge, sgbd: string|{dialect,uri}, baseUrl, stripe?, webhookSecret?, transport?, journal?,
28
28
  paiement?: { banques: { chargily:'DZD', … }, port?, deviseStripe? }, racine? }) → { moyens, handler, catalogue(), catalogueStripe(), fermer(), … }
29
29
  - schemasWagon() (sauvegarde) · sauvegarder({ cibles, driver, seau, recipients, garder }) · suivrePalette(…)
30
- - '@mostajs/wagon-engine/conformite' = src/conformite/conformite.test.mjs (suite mjs-unit, juge WAGON_GREFFE ou cwd)
30
+ - '@mostajs/wagon-engine/conformite' = src/conformite/conformite.test.mjs (suite mjs-unit, juge GREFFE ou cwd)
31
31
 
32
32
  ## .env lu par demarrer
33
- PORT, HOST · WAGON_BASE_URL · WAGON_DB_DIALECT (sqlite|postgres…), WAGON_SGBD · WAGON_EMAIL_SERVER_HOST/PORT/USER/PASSWORD,
34
- WAGON_EMAIL_FROM (ou WAGON_COURRIEL_FICHIER pour les essais) · WAGON_STRIPE_SECRET_KEY|RESTRICTED_KEY, WAGON_STRIPE_WEBHOOK_SECRET ·
33
+ PORT, HOST · BASE_URL · DB_DIALECT (sqlite|postgres…), SGBD_URI · EMAIL_SERVER_HOST/PORT/USER/PASSWORD,
34
+ EMAIL_FROM (ou EMAIL_FICHIER pour les essais) · STRIPE_SECRET_KEY|STRIPE_RESTRICTED_KEY, STRIPE_WEBHOOK_SECRET ·
35
35
  CHARGILY_SECRET_KEY|API_KEY · SATIM_MERCHANT_ID, SATIM_PASSWORD, SATIM_TERMINAL_ID · PAYPAL_CLIENT_ID, PAYPAL_SECRET,
36
- PAYPAL_WEBHOOK_ID · PAIEMENT_<STRIPE|CHARGILY|SATIM|PAYPAL>=off (coupe le moyen) · WAGON_ADMINS · WAGON_INDEXER.
36
+ PAYPAL_WEBHOOK_ID · PAIEMENT_<STRIPE|CHARGILY|SATIM|PAYPAL>=off (coupe le moyen) · ADMIN_EMAILS. Cascade MOSTA_ENV (PROD_SGBD_URI…). 0.9 : plus aucun nom WAGON_… n'est lu (signalé au démarrage).
37
37
  0.6 (@mostajs/payment ≥ 2.7) : PAIEMENT_VENDEUR_<DEVISE> (le vendeur de cette devise : dénomination, forme, adresse,
38
38
  immatriculations) · PAIEMENT_STRIPE_DEVISES (la devise de Stripe, défaut EUR).
39
39
  Moyen ACTIF = clés présentes ET commutateur ≠ off (ET, pour une banque prépayée, proposée par config.paiement.prepaye)
@@ -49,6 +49,7 @@ démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauv
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.
52
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.
53
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.
54
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 /…).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/wagon-engine",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -1,5 +1,5 @@
1
1
  // @mostajs/wagon-engine — LA SUITE DE CONFORMITÉ : elle dit si une greffe tient.
2
- // La greffe jugée est celle de WAGON_GREFFE, sinon le dossier courant : sa configuration (wagon.config.mjs), sa
2
+ // La greffe jugée est celle de GREFFE, sinon le dossier courant : sa configuration (wagon.config.mjs), sa
3
3
  // charge (charge/index.mjs) et ses textes légaux (legal/). Lancer depuis la greffe :
4
4
  // mjs-unit node_modules/@mostajs/wagon-engine/src/conformite/conformite.test.mjs
5
5
  // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
@@ -20,10 +20,11 @@ import { join, resolve } from 'node:path';
20
20
  import { pathToFileURL } from 'node:url';
21
21
  import Stripe from 'stripe';
22
22
  import { creerWagon } from '../wagon.mjs';
23
+ import { getEnv } from '@mostajs/config';
23
24
 
24
- const GREFFE = resolve(process.env.WAGON_GREFFE || process.cwd());
25
+ const GREFFE = resolve(getEnv('GREFFE', process.cwd()));
25
26
  if (!existsSync(join(GREFFE, 'wagon.config.mjs')) || !existsSync(join(GREFFE, 'charge', 'index.mjs'))) {
26
- throw new Error(`conformité : greffe introuvable dans ${GREFFE} (wagon.config.mjs et charge/index.mjs attendus ; WAGON_GREFFE la désigne)`);
27
+ throw new Error(`conformité : greffe introuvable dans ${GREFFE} (wagon.config.mjs et charge/index.mjs attendus ; GREFFE la désigne)`);
27
28
  }
28
29
  const config = (await import(pathToFileURL(join(GREFFE, 'wagon.config.mjs')).href)).default;
29
30
  const charge = (await import(pathToFileURL(join(GREFFE, 'charge', 'index.mjs')).href)).default;
@@ -374,7 +375,7 @@ test('T-WAG-27 — `config.css` : la feuille de l’application est posée sur l
374
375
  });
375
376
 
376
377
  // 0.5 (audit du 09/10/2026) — la base du wagon n'est plus figée en sqlite : `sgbd: { dialect, uri }` (le .env :
377
- // WAGON_DB_DIALECT, WAGON_SGBD), comme la base de la charge. La chaîne seule reste un chemin sqlite.
378
+ // DB_DIALECT, SGBD_URI), comme la base de la charge. La chaîne seule reste un chemin sqlite.
378
379
  test('T-WAG-19 — la base du wagon se choisit par { dialect, uri } ; la chaîne reste un chemin sqlite', async () => {
379
380
  const W2 = await creerWagon({ config: { ...config, dossierLegal: LEGAL_COMPLET }, charge, sgbd: { dialect: 'sqlite', uri: join(atelier, `objet${Math.random()}.db`) }, baseUrl: 'http://127.0.0.1', racine: GREFFE });
380
381
  await W2.catalogue();
package/src/demarrer.mjs CHANGED
@@ -13,18 +13,19 @@ import { join } from 'node:path';
13
13
  import { loadEnv, getEnv } from '@mostajs/config';
14
14
  import { devisesDuMoyen } from '@mostajs/payment/server';
15
15
  import { creerWagon } from './wagon.mjs';
16
+ import { signalerClesRetirees } from './env.mjs';
16
17
 
17
18
  const coupe = (moyen) => getEnv(`PAIEMENT_${moyen.toUpperCase()}`, '') === 'off';
18
19
 
19
20
  /** Les courriels : SMTP (avec DKIM si la clé est publiée), ou un fichier pour les essais. */
20
21
  async function transport() {
21
- const fichier = getEnv('WAGON_COURRIEL_FICHIER', '');
22
+ const fichier = getEnv('EMAIL_FICHIER', '');
22
23
  if (fichier) return { envoyer: async (m) => appendFileSync(fichier, `${JSON.stringify(m)}\n`, { mode: 0o600 }) };
23
- const host = getEnv('WAGON_EMAIL_SERVER_HOST', '');
24
+ const host = getEnv('EMAIL_SERVER_HOST', '');
24
25
  if (!host) return null;
25
26
  const { createSmtpLiteDriver } = await import('@mostajs/mailer');
26
- const pilote = createSmtpLiteDriver({ host, port: Number(getEnv('WAGON_EMAIL_SERVER_PORT', '587')), user: getEnv('WAGON_EMAIL_SERVER_USER', ''), pass: getEnv('WAGON_EMAIL_SERVER_PASSWORD', '') });
27
- const de = getEnv('WAGON_EMAIL_FROM', getEnv('WAGON_EMAIL_SERVER_USER', ''));
27
+ const pilote = createSmtpLiteDriver({ host, port: Number(getEnv('EMAIL_SERVER_PORT', '587')), user: getEnv('EMAIL_SERVER_USER', ''), pass: getEnv('EMAIL_SERVER_PASSWORD', '') });
28
+ const de = getEnv('EMAIL_FROM', getEnv('EMAIL_SERVER_USER', ''));
28
29
  return { envoyer: (m) => pilote.send({ from: de, ...m }) };
29
30
  }
30
31
 
@@ -45,14 +46,15 @@ const PRETES = {
45
46
  */
46
47
  export async function demarrer({ config, charge, racine = process.cwd(), journal = console }) {
47
48
  loadEnv({ path: join(racine, '.env') });
48
- const cle = coupe('stripe') ? '' : (getEnv('WAGON_STRIPE_RESTRICTED_KEY', '') || getEnv('WAGON_STRIPE_SECRET_KEY', ''));
49
+ signalerClesRetirees(journal);
50
+ const cle = coupe('stripe') ? '' : (getEnv('STRIPE_RESTRICTED_KEY', '') || getEnv('STRIPE_SECRET_KEY', ''));
49
51
  const banques = Object.fromEntries(Object.entries(config.paiement?.prepaye ?? {}).filter(([b]) => PRETES[b]?.() && !coupe(b)));
50
52
  const wagon = await creerWagon({
51
53
  config, charge, racine, journal,
52
- sgbd: { dialect: getEnv('WAGON_DB_DIALECT', 'sqlite'), uri: getEnv('WAGON_SGBD', join(racine, 'data', 'wagon.db')) },
53
- baseUrl: getEnv('WAGON_BASE_URL', 'http://localhost:4800'),
54
+ sgbd: { dialect: getEnv('DB_DIALECT', 'sqlite'), uri: getEnv('SGBD_URI', join(racine, 'data', 'wagon.db')) },
55
+ baseUrl: getEnv('BASE_URL', 'http://localhost:4800'),
54
56
  stripe: cle ? new (await import('stripe')).default(cle) : null,
55
- webhookSecret: getEnv('WAGON_STRIPE_WEBHOOK_SECRET', ''),
57
+ webhookSecret: getEnv('STRIPE_WEBHOOK_SECRET', ''),
56
58
  transport: await transport(),
57
59
  // 0.6 — la devise de Stripe (PAIEMENT_STRIPE_DEVISES, défaut EUR) : creerWagon écarte une devise sans vendeur.
58
60
  paiement: { banques, deviseStripe: devisesDuMoyen('stripe', ['EUR'])[0] },
package/src/env.mjs ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @mostajs/wagon-engine — les clés du .env, toujours lues par getEnv de @mostajs/config (cascade MOSTA_ENV :
3
+ * `PROD_CLÉ` l'emporte sur `CLÉ`, valeur vide = absente).
4
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
5
+ *
6
+ * 0.9 — les clés portent le nom de leur fonction (SGBD_URI, BASE_URL, ADMIN_EMAILS…) ; le seul préfixe est celui du
7
+ * profil MOSTA_ENV. Les anciens noms `WAGON_…` ne sont PLUS lus : s'il en reste dans le .env, le démarrage le dit.
8
+ */
9
+ import { getEnv } from '@mostajs/config';
10
+
11
+ /** Les noms retirés en 0.9, et leur remplaçant. */
12
+ export const CLES_RETIREES = {
13
+ WAGON_SGBD: 'SGBD_URI',
14
+ WAGON_DB_DIALECT: 'DB_DIALECT',
15
+ WAGON_BASE_URL: 'BASE_URL',
16
+ WAGON_ADMINS: 'ADMIN_EMAILS',
17
+ WAGON_EMAIL_SERVER_HOST: 'EMAIL_SERVER_HOST',
18
+ WAGON_EMAIL_SERVER_PORT: 'EMAIL_SERVER_PORT',
19
+ WAGON_EMAIL_SERVER_USER: 'EMAIL_SERVER_USER',
20
+ WAGON_EMAIL_SERVER_PASSWORD: 'EMAIL_SERVER_PASSWORD',
21
+ WAGON_EMAIL_FROM: 'EMAIL_FROM',
22
+ WAGON_COURRIEL_FICHIER: 'EMAIL_FICHIER',
23
+ WAGON_STRIPE_RESTRICTED_KEY: 'STRIPE_RESTRICTED_KEY',
24
+ WAGON_STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY',
25
+ WAGON_STRIPE_WEBHOOK_SECRET: 'STRIPE_WEBHOOK_SECRET',
26
+ };
27
+
28
+ /** Signale les anciens noms encore posés : ils sont ignorés. Rend la liste. */
29
+ export function signalerClesRetirees(journal = console) {
30
+ const restes = Object.keys(CLES_RETIREES).filter((k) => getEnv(k, undefined) !== undefined);
31
+ for (const k of restes) journal.warn(`wagon : ${k} n'est plus lue — renommez-la ${CLES_RETIREES[k]} dans le .env (même préfixe MOSTA_ENV)`);
32
+ return restes;
33
+ }
34
+
35
+ /** Les courriels administrateurs déclarés (config.admins + ADMIN_EMAILS), en minuscules. */
36
+ export function adminsDeclares(config = {}) {
37
+ return [...new Set([...(config.admins ?? []), ...String(getEnv('ADMIN_EMAILS', '') ?? '').split(',')]
38
+ .map((x) => x.trim().toLowerCase()).filter(Boolean))];
39
+ }
package/src/index.js CHANGED
@@ -5,3 +5,4 @@ export { creerWagon, schemasWagon } from './wagon.mjs';
5
5
  export { demarrer } from './demarrer.mjs';
6
6
  export { sauvegarder } from './sauvegarde.mjs';
7
7
  export { suivrePalette } from './palette.mjs';
8
+ export { adminsDeclares, signalerClesRetirees, CLES_RETIREES } from './env.mjs';
package/src/wagon.mjs CHANGED
@@ -19,8 +19,10 @@
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';
25
+ import { adminsDeclares } from './env.mjs';
24
26
  import { creerWagonUi, esc } from '@mostajs/wagon-ui';
25
27
  import { creerComptes, schemasComptes } from '@mostajs/comptes';
26
28
  import { createBilling, createStripeSale, createPrepaidSale, billingSchemas, checkLimits, assertMonotonicPlans } from '@mostajs/subscriptions-plan/server';
@@ -33,7 +35,6 @@ import { emailCodeEmailHtml, emailCodeEmailText, emailCodeForm, emailLinkConfirm
33
35
  import { magasinSessions } from '@mostajs/auth-plug';
34
36
  import { requeteLegitime, enTetesSecurite, cookieStrict } from '@mostajs/garde-http';
35
37
  import { mdToHtml } from '@mostajs/markdown-html';
36
- import { getEnv } from '@mostajs/config';
37
38
  import { suivrePalette } from './palette.mjs';
38
39
 
39
40
  const COURRIEL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -133,7 +134,7 @@ const CredentialSchema = {
133
134
  relations: {}, indexes: [{ fields: { user: 'asc' }, unique: true }],
134
135
  };
135
136
  /**
136
- * 0.4 (audit du 09/10/2026) — les administrateurs CONFIRMÉS. Un courriel déclaré (config.admins, WAGON_ADMINS) ne
137
+ * 0.4 (audit du 09/10/2026) — les administrateurs CONFIRMÉS. Un courriel déclaré (config.admins, ADMIN_EMAILS) ne
137
138
  * donne l'administration qu'une fois sa détention prouvée : connexion par code ou par lien reçu à ce courriel, ou
138
139
  * confirmation faite sur le serveur (scripts/admin.mjs). Sans cela, quiconque inscrivait ce courriel AVANT son
139
140
  * titulaire prenait l'administration.
@@ -143,12 +144,21 @@ const AdminSchema = {
143
144
  fields: { courriel: { type: 'string', required: true }, confirmeLe: { type: 'date' }, par: { type: 'string' } },
144
145
  relations: {}, indexes: [{ fields: { courriel: 'asc' }, unique: true }],
145
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
+ };
146
156
  export const schemasWagon = () => {
147
157
  const vus = new Set();
148
158
  // `Payment` (@mostajs/payment) : une ligne par session de banque, que le webhook retrouve pour clore la vente.
149
159
  // Déclarée même sans banque prépayée — incident RestoTrax du 19/08/2026 : « no such table: payments » APRÈS
150
160
  // que le client eut engagé son paiement.
151
- 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)));
152
162
  };
153
163
 
154
164
  const lireCorps = (req, max = 1_000_000) => new Promise((ok, ko) => {
@@ -185,7 +195,7 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
185
195
  const https = /^https:/.test(origine);
186
196
  const S = Object.fromEntries(schemasWagon().map((s) => [s.name, s]));
187
197
  // 0.5 (audit du 09/10/2026) — `sgbd` : un chemin sqlite (comme avant), ou { dialect, uri } d'après le .env
188
- // (WAGON_DB_DIALECT, WAGON_SGBD) : la base des comptes n'est plus figée en sqlite.
198
+ // (DB_DIALECT, SGBD_URI) : la base des comptes n'est plus figée en sqlite.
189
199
  const base = typeof sgbd === 'string' ? { dialect: 'sqlite', uri: sgbd } : { dialect: sgbd?.dialect ?? 'sqlite', uri: sgbd?.uri };
190
200
  const d = await openIsolatedDialect({ ...base, schemaStrategy: 'update' }, schemasWagon());
191
201
  // 0.4 — la charge ouvre SA base au démarrage, pas au premier client (DEVRULES-SAAS-APP §5.2).
@@ -218,13 +228,21 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
218
228
  const purgerSessions = () => sessions.purger?.().catch((e) => journal.warn(`wagon : purge des sessions : ${e?.message ?? e}`));
219
229
  purgerSessions(); setInterval(purgerSessions, 6 * 3600 * 1000).unref?.();
220
230
  const freinInscription = memoryLimiter({ limits: { byIp: config.freinInscription ?? { capacity: 10, refillPerSec: 1 / 60 } } });
221
- const motsDePasse = createPasswordAuth({
222
- credentials: {
223
- get: async (u) => { const r = await d.findOne(S.WagonCredential, { user: String(u) }); return r ? { hash: r.hash, updatedAt: r.updatedAt } : null; },
224
- 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 }); },
225
- },
226
- findUser: async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; },
227
- });
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 });
228
246
  const codes = createEmailCodeAuth({ findUser: async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; } });
229
247
  const vente = stripe ? createStripeSale({
230
248
  billing: facturation, stripe, webhookSecret, metadataKeys: META,
@@ -307,7 +325,7 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
307
325
  } catch (e) { journal.warn(`wagon : paiementRegle de la charge : ${e?.message ?? e}`); return { status: 500, paye: false, meta }; }
308
326
  return { status: 200, paye: r.status === 'paid', meta };
309
327
  }
310
- const admins = new Set([...(config.admins ?? []), ...String(getEnv('WAGON_ADMINS', '') ?? '').split(',')].map((x) => x.trim().toLowerCase()).filter(Boolean));
328
+ const admins = new Set(adminsDeclares(config));
311
329
  /** Un courriel est administrateur s'il est DÉCLARÉ et CONFIRMÉ. */
312
330
  const estAdministrateur = async (courriel) => {
313
331
  const c = String(courriel ?? '').toLowerCase();
@@ -494,6 +512,53 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
494
512
  });
495
513
  }
496
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
+
497
562
  async function handler(req, res) {
498
563
  const url = new URL(req.url, 'http://wagon.local');
499
564
  const chemin = url.pathname;
@@ -684,7 +749,7 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
684
749
  if (v.allowed) anonymes.set(k, (anonymes.get(k) ?? 0) + 1);
685
750
  return v;
686
751
  };
687
- // 0.3.1 — `admin` : la session est celle d'un administrateur (config.admins + WAGON_ADMINS) — pour qu'une
752
+ // 0.3.1 — `admin` : la session est celle d'un administrateur (config.admins + ADMIN_EMAILS) — pour qu'une
688
753
  // charge réserve ses outils (ex. l'outil de l'agence de travel) sans recalculer la liste.
689
754
  /** 0.2 (wagon-engine) — un courriel de la charge, par le transport du wagon (SMTP du .env). Rend true s'il est parti,
690
755
  * false s'il n'y a pas de transport ou s'il a échoué (journalisé) : jamais une exception dans la route. */
@@ -693,7 +758,10 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
693
758
  try { await transport.envoyer(m); return true; } catch (e) { journal.warn(`wagon : courriel de la charge non parti : ${e?.message ?? e}`); return false; }
694
759
  };
695
760
  const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, courriel, admin: estAdmin && !compteCle,
696
- paiement: paiementCharge }; // 0.7 — null sans banque prépayée active
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 };
697
765
  if (charge.routes && (await charge.routes(req, res, ctx))) return true;
698
766
  if (chemin.startsWith('/api/')) return json(404, { ok: false, error: 'introuvable' });
699
767
 
@@ -729,6 +797,10 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
729
797
  // 0.5 — « mot de passe oublié » n'existe que si un courriel peut partir : un accusé de réception d'un message que
730
798
  // personne n'enverra serait pire qu'une page absente (auth-plug T-KIT-5).
731
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';
732
804
  if (chemin === '/inscription') {
733
805
  const tous = await paliersEnBase();
734
806
  const palierDe = (slug) => tous.find((p) => p.slug === slug && p.active !== false) ?? tous.find((p) => p.slug === (config.planGratuit ?? 'gratuit'));
@@ -775,11 +847,18 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
775
847
  if (chemin === '/connexion') {
776
848
  const suite = suiteSure(url.searchParams.get('suite'));
777
849
  const action = avecSuite('/connexion', suite);
778
- 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
+ }
779
857
  const f = await formulaire();
780
858
  if (methodeConnexion === 'motdepasse') {
781
859
  const r = await motsDePasse.login(String(f.courriel ?? ''), String(f.motdepasse ?? ''), { ip: ipClient(req) });
782
- 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 }));
783
862
  return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux'), action }) }));
784
863
  }
785
864
  const courriel = String(f.courriel ?? '').trim().toLowerCase();
@@ -826,15 +905,36 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
826
905
  }
827
906
  return true;
828
907
  }
829
- if (oubliPossible && chemin === '/connexion/reinitialiser') {
908
+ if (reinitPossible && chemin === '/connexion/reinitialiser') {
830
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>`;
831
- 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
+ }
832
928
  const f = await formulaire();
833
- 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
+ }
834
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);
835
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>` }));
836
936
  await sessions.fermerToutes(String(r.userId));
837
- return ouvrirSession(r.userId);
937
+ return ouvrirSession(r.userId, config.apresConnexion ?? '/compte');
838
938
  }
839
939
 
840
940
  // L'espace du client.