@mostajs/wagon-engine 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @mostajs/wagon-engine — le démarrage d'une greffe : tout ce que faisaient les server.mjs copiés.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+ *
5
+ * Lit le .env de la greffe (@mostajs/config), monte le courriel, les moyens de paiement actifs et la base, puis
6
+ * écoute. Un moyen de paiement est ACTIF quand ses clés sont dans le .env (et, pour une banque prépayée, quand
7
+ * wagon.config.mjs la propose) ; `PAIEMENT_<MOYEN>=off` le coupe sans toucher à ses clés (comme RestoTrax).
8
+ * L'utilisateur choisit parmi les moyens actifs sur l'écran de paiement du wagon.
9
+ */
10
+ import { createServer } from 'node:http';
11
+ import { appendFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { loadEnv, getEnv } from '@mostajs/config';
14
+ import { creerWagon } from './wagon.mjs';
15
+
16
+ const coupe = (moyen) => getEnv(`PAIEMENT_${moyen.toUpperCase()}`, '') === 'off';
17
+
18
+ /** Les courriels : SMTP (avec DKIM si la clé est publiée), ou un fichier pour les essais. */
19
+ async function transport() {
20
+ const fichier = getEnv('WAGON_COURRIEL_FICHIER', '');
21
+ if (fichier) return { envoyer: async (m) => appendFileSync(fichier, `${JSON.stringify(m)}\n`, { mode: 0o600 }) };
22
+ const host = getEnv('WAGON_EMAIL_SERVER_HOST', '');
23
+ if (!host) return null;
24
+ const { createSmtpLiteDriver } = await import('@mostajs/mailer');
25
+ const pilote = createSmtpLiteDriver({ host, port: Number(getEnv('WAGON_EMAIL_SERVER_PORT', '587')), user: getEnv('WAGON_EMAIL_SERVER_USER', ''), pass: getEnv('WAGON_EMAIL_SERVER_PASSWORD', '') });
26
+ const de = getEnv('WAGON_EMAIL_FROM', getEnv('WAGON_EMAIL_SERVER_USER', ''));
27
+ return { envoyer: (m) => pilote.send({ from: de, ...m }) };
28
+ }
29
+
30
+ /**
31
+ * Les banques PRÉPAYÉES prêtes : leurs clés sont dans le .env (@mostajs/payment les y relit pour vérifier chaque
32
+ * webhook). Une banque sans clé n'est pas proposée : elle échouerait au moment de payer.
33
+ */
34
+ const PRETES = {
35
+ chargily: () => getEnv('CHARGILY_SECRET_KEY', '') || getEnv('CHARGILY_API_KEY', ''),
36
+ paypal: () => getEnv('PAYPAL_CLIENT_ID', '') && getEnv('PAYPAL_SECRET', '') && getEnv('PAYPAL_WEBHOOK_ID', ''),
37
+ // 0.10 du gabarit — le terminal aussi : SATIM l'exige à l'ouverture du paiement.
38
+ satim: () => getEnv('SATIM_MERCHANT_ID', '') && getEnv('SATIM_PASSWORD', '') && getEnv('SATIM_TERMINAL_ID', ''),
39
+ };
40
+
41
+ /**
42
+ * @param {{ config: object, charge: object, racine?: string, journal?: Pick<Console, 'log'|'warn'|'error'> }} o
43
+ * @returns {Promise<{ wagon: object, serveur: import('node:http').Server, moyens: string[], fermer(): Promise<void> }>}
44
+ */
45
+ export async function demarrer({ config, charge, racine = process.cwd(), journal = console }) {
46
+ loadEnv({ path: join(racine, '.env') });
47
+ const cle = coupe('stripe') ? '' : (getEnv('WAGON_STRIPE_RESTRICTED_KEY', '') || getEnv('WAGON_STRIPE_SECRET_KEY', ''));
48
+ const banques = Object.fromEntries(Object.entries(config.paiement?.prepaye ?? {}).filter(([b]) => PRETES[b]?.() && !coupe(b)));
49
+ const wagon = await creerWagon({
50
+ config, charge, racine, journal,
51
+ sgbd: { dialect: getEnv('WAGON_DB_DIALECT', 'sqlite'), uri: getEnv('WAGON_SGBD', join(racine, 'data', 'wagon.db')) },
52
+ baseUrl: getEnv('WAGON_BASE_URL', 'http://localhost:4800'),
53
+ stripe: cle ? new (await import('stripe')).default(cle) : null,
54
+ webhookSecret: getEnv('WAGON_STRIPE_WEBHOOK_SECRET', ''),
55
+ transport: await transport(),
56
+ paiement: { banques },
57
+ });
58
+ await wagon.catalogue();
59
+ const moyens = [...(cle ? ['stripe'] : []), ...Object.keys(banques)];
60
+ const serveur = createServer(wagon.handler);
61
+ const port = Number(getEnv('PORT', '4800'));
62
+ await new Promise((ok) => serveur.listen(port, getEnv('HOST', '127.0.0.1'), ok));
63
+ journal.log(`✅ ${config.marque.nom} → http://127.0.0.1:${serveur.address().port} · paiement : ${moyens.join(', ') || 'aucun'}`);
64
+ return {
65
+ wagon, serveur, moyens,
66
+ async fermer() { serveur.closeAllConnections?.(); await new Promise((ok) => serveur.close(() => ok())); await wagon.fermer(); },
67
+ };
68
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // @mostajs/wagon-engine — le wagon d'un SaaS, une fois pour toutes.
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ // La suite de conformité, qui juge une greffe, est publiée à part : '@mostajs/wagon-engine/conformite'.
4
+ export { creerWagon, schemasWagon } from './wagon.mjs';
5
+ export { demarrer } from './demarrer.mjs';
6
+ export { sauvegarder } from './sauvegarde.mjs';
7
+ export { suivrePalette } from './palette.mjs';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * mosta-wagon — LA MARQUE SE GÈRE DANS PALETTE STUDIO (palette.amia.fr), comme pour qatrax.dev et mdseal.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+ *
5
+ * Repris de qatrax-cra (src/vitrine/palette.mjs, 02/10/2026) pour que toute greffe en profite. Côté SERVEUR :
6
+ * le thème (`/theme.css?site=<projet>`, un `:root{…}` de jetons) est relu toutes les `ttlMs` et posé dans
7
+ * chaque page par @mostajs/wagon-ui (option `theme`) — aucun script à autoriser dans la CSP, et un changement
8
+ * fait dans Palette Studio se voit sans redéploiement.
9
+ *
10
+ * ⚠️ Palette Studio ne refuse pas un projet inconnu : il rend le thème d'un AUTRE (constaté sur mdseal). On
11
+ * vérifie donc d'abord que le projet existe ; sinon, la greffe garde les couleurs de wagon.config.mjs.
12
+ */
13
+ export function suivrePalette({ source, projet, ttlMs = 60000, journal = console } = {}) {
14
+ let css = '';
15
+ let lu = 0;
16
+ let enCours = null;
17
+ let averti = false;
18
+ async function rafraichir() {
19
+ lu = Date.now();
20
+ try {
21
+ const liste = await (await fetch(`${source}/api/projects`, { signal: AbortSignal.timeout(5000) })).json();
22
+ if (!Array.isArray(liste?.projects) || !liste.projects.includes(projet)) {
23
+ if (!averti) { journal.warn(`[palette] projet « ${projet} » absent de ${source} : couleurs de wagon.config.mjs gardées`); averti = true; }
24
+ css = '';
25
+ return;
26
+ }
27
+ averti = false;
28
+ const r = await fetch(`${source}/theme.css?site=${encodeURIComponent(projet)}`, { signal: AbortSignal.timeout(5000) });
29
+ const t = r.ok ? (await r.text()).trim() : '';
30
+ if (/^:root\s*\{[^{}<]*\}$/.test(t)) css = t; // sinon : on garde le dernier thème sain
31
+ } catch { /* source muette : on garde le dernier thème connu */ }
32
+ }
33
+ return {
34
+ rafraichir,
35
+ /** Le thème courant ; relance une lecture en arrière-plan quand il a vieilli. Ne bloque jamais une page. */
36
+ css() {
37
+ if (Date.now() - lu > ttlMs && !enCours) enCours = rafraichir().finally(() => { enCours = null; });
38
+ return css;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * mosta-wagon 0.6 — LA SAUVEGARDE HORS SERVEUR (audit du 09/10/2026 : rien ne sauvegardait data/).
3
+ *
4
+ * Reprise de mdseal et qatrax.dev (scripts/sauvegarde-distante.mjs) : tout le travail est fait par `@mostajs/backup`
5
+ * (archive, empreintes, scellement X25519, test de restauration, rétention) et `@mostajs/blob-store` (dépôt).
6
+ * Ce module n'ajoute que la GÉNÉRALISATION au wagon : plusieurs cibles (la base des comptes, la base de la charge),
7
+ * les fichiers d'une cible (les photos d'une agence), et la copie cohérente d'une base sqlite EN SERVICE.
8
+ *
9
+ * Règles reprises, et non négociables (DEVRULES-SAAS-APP §5.3 à §5.5) :
10
+ * - ce qui sort du serveur sort SCELLÉ : sans destinataire, refus AVANT toute copie ;
11
+ * - l'archive se restaure sur une base jetable AVANT le dépôt ; sinon, rien n'est déposé ;
12
+ * - la rétention ne purge qu'après un dépôt réussi.
13
+ * @author Dr Hamid MADANI <drmdh@msn.com>
14
+ */
15
+ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, existsSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, relative, sep } from 'node:path';
18
+ import { createBackup, storeBackup, testRestore, pruneBackups } from '@mostajs/backup';
19
+
20
+ /** Les fichiers d'un dossier, chemins relatifs (séparateur « / »). */
21
+ function fichiersDe(dossier) {
22
+ const sortie = [];
23
+ const parcourir = (d) => {
24
+ for (const nom of readdirSync(d)) {
25
+ const p = join(d, nom);
26
+ if (statSync(p).isDirectory()) parcourir(p);
27
+ else sortie.push({ path: relative(dossier, p).split(sep).join('/'), content: readFileSync(p) });
28
+ }
29
+ };
30
+ if (dossier && existsSync(dossier)) parcourir(dossier);
31
+ return sortie;
32
+ }
33
+
34
+ /**
35
+ * Une copie COHÉRENTE d'une base sqlite en service : `VACUUM INTO` prend un instantané transactionnel (lire le fichier
36
+ * pendant une écriture peut rendre une base tronquée que rien ne signale — constaté sur qatrax le 29/09/2026).
37
+ */
38
+ async function copieSqlite(uri, cible) {
39
+ const Database = (await import('better-sqlite3')).default;
40
+ const db = new Database(uri, { readonly: true, fileMustExist: true });
41
+ try { db.exec(`VACUUM INTO '${cible.replaceAll("'", "''")}'`); } finally { db.close(); }
42
+ return cible;
43
+ }
44
+
45
+ /**
46
+ * Sauvegarde chaque cible : copie cohérente (sqlite), archive scellée (+ fichiers), restauration éprouvée, dépôt, rétention.
47
+ * @param {object} o
48
+ * @param {{ site: string, source: { dialect: string, uri: string, schemas: object[] }, fichiers?: string }[]} o.cibles
49
+ * @param {object} o.driver un pilote @mostajs/blob-store (disque d'un autre serveur, S3…)
50
+ * @param {string} o.seau
51
+ * @param {Uint8Array[]} o.recipients clés PUBLIQUES X25519 : l'hébergeur conserve sans pouvoir ouvrir
52
+ * @param {number} [o.garder] combien d'archives garder par cible (défaut 30)
53
+ * @returns {Promise<{ site, total, fichiers, verifie, contenu, scellee, chemin }[]>}
54
+ */
55
+ export async function sauvegarder({ cibles, driver, seau, recipients, garder = 30, journal = console, createdBy = 'mosta-wagon/scripts/sauvegarde.mjs' }) {
56
+ if (!recipients?.length) throw new Error('aucun destinataire de scellement : une sauvegarde hors serveur ne part que scellée (rien n’a été copié)');
57
+ const rapports = [];
58
+ for (const c of cibles) {
59
+ const atelier = mkdtempSync(join(tmpdir(), 'wagon-sauv-'));
60
+ try {
61
+ const source = c.source.dialect === 'sqlite' ? { ...c.source, uri: await copieSqlite(c.source.uri, join(atelier, 'copie.db')) } : c.source;
62
+ const fichiers = fichiersDe(c.fichiers);
63
+ const archive = await createBackup({ source, siteId: c.site, createdBy, recipients, extras: fichiers.length ? { files: fichiers } : undefined });
64
+ const total = Object.values(archive.counts).reduce((s, n) => s + n, 0);
65
+ const rendu = await testRestore(archive.plainBuffer, { schemas: c.source.schemas });
66
+ if (!rendu.verifie) throw new Error(`${c.site} : l’archive ne se restaure pas — RIEN n’a été déposé (${(rendu.ecarts ?? []).join(' · ')})`);
67
+ const ref = await storeBackup(driver, seau, archive);
68
+ await pruneBackups(driver, seau, c.site, { keepLast: garder });
69
+ journal.log?.(`✓ ${c.site} : ${total} enregistrement(s), ${fichiers.length} fichier(s), restauration éprouvée${rendu.contenu === 'verifie' ? ' (contenu identique)' : ''}, déposée en ${ref.bucket}/${ref.path}${archive.sealed ? ', scellée' : ''}`);
70
+ rapports.push({ site: c.site, total, fichiers: fichiers.length, verifie: true, contenu: rendu.contenu, scellee: !!archive.sealed, chemin: ref.path });
71
+ } finally {
72
+ rmSync(atelier, { recursive: true, force: true });
73
+ }
74
+ }
75
+ return rapports;
76
+ }