@nosserb/prmt 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garde de l'API locale.
|
|
3
|
+
*
|
|
4
|
+
* Un serveur qui exécute des commandes ne doit répondre qu'à sa propre interface. Trois contrôles :
|
|
5
|
+
* - Host : seuls 127.0.0.1 et localhost sur le bon port (bloque le « DNS rebinding ») ;
|
|
6
|
+
* - Origin : si le navigateur l'envoie, ce doit être l'interface elle-même (bloque les autres sites) ;
|
|
7
|
+
* - jeton de session : exigé sur toute l'API, sauf la route qui le délivre à l'interface.
|
|
8
|
+
*/
|
|
9
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
10
|
+
const DEV_PORT = 5173;
|
|
11
|
+
export function allowedHosts(options) {
|
|
12
|
+
const ports = options.dev ? [options.port, DEV_PORT] : [options.port];
|
|
13
|
+
return ports.flatMap((port) => [`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
14
|
+
}
|
|
15
|
+
function sameToken(given, expected) {
|
|
16
|
+
const a = Buffer.from(given);
|
|
17
|
+
const b = Buffer.from(expected);
|
|
18
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
19
|
+
}
|
|
20
|
+
export function checkRequest(req, pathname, options) {
|
|
21
|
+
const hosts = allowedHosts(options);
|
|
22
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
23
|
+
if (!hosts.includes(host))
|
|
24
|
+
return { ok: false, status: 421, code: 'bad_host', message: 'Hôte non autorisé.' };
|
|
25
|
+
const origin = req.headers.origin;
|
|
26
|
+
if (origin !== undefined && !hosts.some((h) => origin.toLowerCase() === `http://${h}`))
|
|
27
|
+
return { ok: false, status: 403, code: 'bad_origin', message: 'Origine non autorisée.' };
|
|
28
|
+
if (!pathname.startsWith('/api/'))
|
|
29
|
+
return { ok: true };
|
|
30
|
+
const site = req.headers['sec-fetch-site'];
|
|
31
|
+
if (site === 'cross-site')
|
|
32
|
+
return { ok: false, status: 403, code: 'cross_site', message: 'Requête venant d’un autre site refusée.' };
|
|
33
|
+
if (pathname === '/api/session') {
|
|
34
|
+
if (req.method !== 'GET')
|
|
35
|
+
return { ok: false, status: 405, code: 'method_not_allowed', message: 'Méthode non autorisée.' };
|
|
36
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none')
|
|
37
|
+
return { ok: false, status: 403, code: 'cross_site', message: 'Requête venant d’un autre site refusée.' };
|
|
38
|
+
return { ok: true };
|
|
39
|
+
}
|
|
40
|
+
const token = req.headers['x-promethee-token'];
|
|
41
|
+
if (typeof token !== 'string' || !sameToken(token, options.token))
|
|
42
|
+
return { ok: false, status: 401, code: 'bad_token', message: 'Jeton de session absent ou invalide. Recharge la page.' };
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
export const PAIR_PATH = '/api/telephone/appairer';
|
|
46
|
+
export function checkRemoteRequest(req, pathname, options) {
|
|
47
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
48
|
+
if (!options.hosts.includes(host))
|
|
49
|
+
return { ok: false, status: 421, code: 'bad_host', message: 'Hôte non autorisé.' };
|
|
50
|
+
const origin = req.headers.origin;
|
|
51
|
+
if (origin !== undefined && origin.toLowerCase() !== `https://${host}`)
|
|
52
|
+
return { ok: false, status: 403, code: 'bad_origin', message: 'Origine non autorisée.' };
|
|
53
|
+
if (!pathname.startsWith('/api/'))
|
|
54
|
+
return { ok: true, device: null };
|
|
55
|
+
const site = req.headers['sec-fetch-site'];
|
|
56
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none')
|
|
57
|
+
return { ok: false, status: 403, code: 'cross_site', message: 'Requête venant d’un autre site refusée.' };
|
|
58
|
+
if (pathname === PAIR_PATH) {
|
|
59
|
+
if (req.method !== 'POST')
|
|
60
|
+
return { ok: false, status: 405, code: 'method_not_allowed', message: 'Méthode non autorisée.' };
|
|
61
|
+
return { ok: true, device: null };
|
|
62
|
+
}
|
|
63
|
+
const device = options.device(req.headers.cookie);
|
|
64
|
+
if (device === null)
|
|
65
|
+
return { ok: false, status: 401, code: 'not_paired', message: 'Ce téléphone n’est pas appairé. Sur le PC, ouvre Réglages, Téléphone, puis scanne le QR code ou tape le code affiché.' };
|
|
66
|
+
if (pathname === '/api/session') {
|
|
67
|
+
if (req.method !== 'GET')
|
|
68
|
+
return { ok: false, status: 405, code: 'method_not_allowed', message: 'Méthode non autorisée.' };
|
|
69
|
+
return { ok: true, device };
|
|
70
|
+
}
|
|
71
|
+
const token = req.headers['x-promethee-token'];
|
|
72
|
+
if (typeof token !== 'string' || !sameToken(token, options.token))
|
|
73
|
+
return { ok: false, status: 401, code: 'bad_token', message: 'Jeton de session absent ou invalide. Recharge la page.' };
|
|
74
|
+
return { ok: true, device };
|
|
75
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { AppError, badRequest, describeError } from '../errors.js';
|
|
2
|
+
const MAX_BODY = 256 * 1024;
|
|
3
|
+
async function readBody(req) {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let size = 0;
|
|
6
|
+
for await (const chunk of req) {
|
|
7
|
+
const buf = chunk;
|
|
8
|
+
size += buf.length;
|
|
9
|
+
if (size > MAX_BODY)
|
|
10
|
+
throw new AppError('too_large', 413, 'Requête trop volumineuse.');
|
|
11
|
+
chunks.push(buf);
|
|
12
|
+
}
|
|
13
|
+
if (size === 0)
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw badRequest('Le corps de la requête n’est pas du JSON valide.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function sendJson(res, data, status = 200) {
|
|
23
|
+
if (res.headersSent)
|
|
24
|
+
return;
|
|
25
|
+
const body = JSON.stringify(data);
|
|
26
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store' });
|
|
27
|
+
res.end(body);
|
|
28
|
+
}
|
|
29
|
+
export function sendError(res, err) {
|
|
30
|
+
const { code, message, status } = describeError(err);
|
|
31
|
+
if (res.headersSent) {
|
|
32
|
+
// Flux déjà commencé : l'erreur part comme dernier événement.
|
|
33
|
+
res.write(`${JSON.stringify({ type: 'error', code, message })}\n`);
|
|
34
|
+
res.end();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
sendJson(res, { error: { code, message } }, status === 499 ? 400 : status);
|
|
38
|
+
}
|
|
39
|
+
export class Router {
|
|
40
|
+
routes = [];
|
|
41
|
+
add(method, pattern, handler) {
|
|
42
|
+
this.routes.push({ method, parts: pattern.split('/').filter(Boolean), handler });
|
|
43
|
+
}
|
|
44
|
+
match(method, pathname) {
|
|
45
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
46
|
+
let pathMatched = false;
|
|
47
|
+
for (const route of this.routes) {
|
|
48
|
+
if (route.parts.length !== parts.length)
|
|
49
|
+
continue;
|
|
50
|
+
const params = {};
|
|
51
|
+
let ok = true;
|
|
52
|
+
for (let i = 0; i < parts.length; i++) {
|
|
53
|
+
const expected = route.parts[i] ?? '';
|
|
54
|
+
const actual = parts[i] ?? '';
|
|
55
|
+
if (expected.startsWith(':')) {
|
|
56
|
+
try {
|
|
57
|
+
params[expected.slice(1)] = decodeURIComponent(actual);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
ok = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (expected !== actual)
|
|
64
|
+
ok = false;
|
|
65
|
+
if (!ok)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (!ok)
|
|
69
|
+
continue;
|
|
70
|
+
pathMatched = true;
|
|
71
|
+
if (route.method === method)
|
|
72
|
+
return { handler: route.handler, params };
|
|
73
|
+
}
|
|
74
|
+
return pathMatched ? 'method' : null;
|
|
75
|
+
}
|
|
76
|
+
/** Traite une requête d'API. Renvoie false si aucune route ne correspond. */
|
|
77
|
+
async handle(req, res, url) {
|
|
78
|
+
const found = this.match(req.method ?? 'GET', url.pathname);
|
|
79
|
+
if (found === null)
|
|
80
|
+
return false;
|
|
81
|
+
if (found === 'method') {
|
|
82
|
+
sendJson(res, { error: { code: 'method_not_allowed', message: 'Méthode non autorisée pour cette adresse.' } }, 405);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const ctx = {
|
|
86
|
+
req,
|
|
87
|
+
res,
|
|
88
|
+
url,
|
|
89
|
+
params: found.params,
|
|
90
|
+
body: async (schema) => {
|
|
91
|
+
const parsed = schema.safeParse(await readBody(req));
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
const issue = parsed.error.issues[0];
|
|
94
|
+
throw badRequest(issue ? `${issue.path.join('.') || 'requête'} : ${issue.message}` : 'Requête invalide.', 'invalid_input');
|
|
95
|
+
}
|
|
96
|
+
return parsed.data;
|
|
97
|
+
},
|
|
98
|
+
json: (data, status) => sendJson(res, data, status),
|
|
99
|
+
text: (body, contentType, filename) => {
|
|
100
|
+
res.writeHead(200, {
|
|
101
|
+
'Content-Type': `${contentType}; charset=utf-8`,
|
|
102
|
+
'Cache-Control': 'no-store',
|
|
103
|
+
...(filename ? { 'Content-Disposition': `attachment; filename="${filename.replace(/[^\w.-]/g, '_')}"` } : {}),
|
|
104
|
+
});
|
|
105
|
+
res.end(body);
|
|
106
|
+
},
|
|
107
|
+
stream: () => {
|
|
108
|
+
let closed = false;
|
|
109
|
+
res.on('close', () => {
|
|
110
|
+
closed = true;
|
|
111
|
+
});
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
|
|
113
|
+
return {
|
|
114
|
+
get closed() {
|
|
115
|
+
return closed || res.writableEnded;
|
|
116
|
+
},
|
|
117
|
+
write: (event) => {
|
|
118
|
+
if (!closed && !res.writableEnded)
|
|
119
|
+
res.write(`${JSON.stringify(event)}\n`);
|
|
120
|
+
},
|
|
121
|
+
end: () => {
|
|
122
|
+
if (!res.writableEnded)
|
|
123
|
+
res.end();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
await found.handler(ctx);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
sendError(res, err);
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Envoie les événements d'un générateur. Si le client se déconnecte, le générateur est fermé (ce qui annule le travail en cours). */
|
|
138
|
+
export async function pipeEvents(ctx, events) {
|
|
139
|
+
const stream = ctx.stream();
|
|
140
|
+
try {
|
|
141
|
+
for await (const event of events) {
|
|
142
|
+
if (stream.closed)
|
|
143
|
+
break;
|
|
144
|
+
stream.write(event);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const { code, message } = describeError(err);
|
|
149
|
+
stream.write({ type: 'error', code, message });
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
stream.end();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Sert l'interface construite (dossier dist). Aucun fichier en dehors de ce dossier n'est accessible. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const TYPES = {
|
|
5
|
+
'.html': 'text/html; charset=utf-8',
|
|
6
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
7
|
+
'.css': 'text/css; charset=utf-8',
|
|
8
|
+
'.svg': 'image/svg+xml',
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.ico': 'image/x-icon',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.woff2': 'font/woff2',
|
|
13
|
+
'.woff': 'font/woff',
|
|
14
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
15
|
+
'.map': 'application/json; charset=utf-8',
|
|
16
|
+
};
|
|
17
|
+
export const SECURITY_HEADERS = {
|
|
18
|
+
'X-Content-Type-Options': 'nosniff',
|
|
19
|
+
'X-Frame-Options': 'DENY',
|
|
20
|
+
'Referrer-Policy': 'no-referrer',
|
|
21
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
22
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
23
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
24
|
+
};
|
|
25
|
+
const NOT_BUILT = `<!doctype html><html lang="fr"><meta charset="utf-8"><title>Prométhée</title>
|
|
26
|
+
<body><h1>L’interface n’est pas encore construite</h1>
|
|
27
|
+
<p>Le serveur local tourne, mais le dossier <code>dist</code> est absent. Lance <code>npm run build</code> puis recharge cette page,
|
|
28
|
+
ou utilise <code>npm run dev</code> pendant le développement.</p></body></html>`;
|
|
29
|
+
export function serveStatic(res, distDir, pathname, method) {
|
|
30
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
31
|
+
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
32
|
+
res.end('Méthode non autorisée.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let relative;
|
|
36
|
+
try {
|
|
37
|
+
relative = decodeURIComponent(pathname);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
relative = '/';
|
|
41
|
+
}
|
|
42
|
+
const root = path.resolve(distDir);
|
|
43
|
+
let file = path.resolve(root, `.${relative}`);
|
|
44
|
+
const inside = file === root || file.startsWith(root + path.sep);
|
|
45
|
+
if (!inside || relative.includes('\0'))
|
|
46
|
+
file = path.join(root, 'index.html');
|
|
47
|
+
let stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
48
|
+
if (!stat || stat.isDirectory()) {
|
|
49
|
+
// Fichier demandé avec une extension mais absent : vraie 404. Sinon, on sert l'application.
|
|
50
|
+
if (path.extname(relative) && relative !== '/index.html') {
|
|
51
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
52
|
+
res.end('Introuvable.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
file = path.join(root, 'index.html');
|
|
56
|
+
stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
57
|
+
}
|
|
58
|
+
if (!stat) {
|
|
59
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...SECURITY_HEADERS });
|
|
60
|
+
res.end(NOT_BUILT);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const ext = path.extname(file).toLowerCase();
|
|
64
|
+
const hashed = /[\\/]assets[\\/]/.test(file);
|
|
65
|
+
res.writeHead(200, {
|
|
66
|
+
'Content-Type': TYPES[ext] ?? 'application/octet-stream',
|
|
67
|
+
'Content-Length': stat.size,
|
|
68
|
+
'Cache-Control': hashed ? 'public, max-age=31536000, immutable' : 'no-cache',
|
|
69
|
+
...SECURITY_HEADERS,
|
|
70
|
+
});
|
|
71
|
+
if (method === 'HEAD') {
|
|
72
|
+
res.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
fs.createReadStream(file).pipe(res);
|
|
76
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Point d'entrée : npm start (ou « node server/index.ts »). */
|
|
2
|
+
import { createApp } from './app.js';
|
|
3
|
+
import { VERSION, loadConfig } from './config.js';
|
|
4
|
+
import { writeRendezvous } from './lien.js';
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
const app = await createApp({ config });
|
|
7
|
+
app.server.on('error', (err) => {
|
|
8
|
+
if (err.code === 'EADDRINUSE')
|
|
9
|
+
console.error(`Le port ${config.port} est déjà pris. Ferme l'autre instance de Prométhée, ou change de port avec la variable PROMETHEE_PORT.`);
|
|
10
|
+
else
|
|
11
|
+
console.error(`Le serveur n'a pas pu démarrer : ${err.message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
14
|
+
app.server.listen(config.port, config.host, () => {
|
|
15
|
+
const url = config.dev ? 'http://127.0.0.1:5173' : `http://${config.host}:${config.port}`;
|
|
16
|
+
console.log(`\n Prométhée ${VERSION}`);
|
|
17
|
+
console.log(` Console ${url}`);
|
|
18
|
+
console.log(` Écoute ${config.host}:${config.port} (cette machine)`);
|
|
19
|
+
console.log(` Données ${config.dataDir}`);
|
|
20
|
+
console.log(` Moteur ${app.engine.available ? 'PowerShell prêt' : 'PowerShell indisponible'}`);
|
|
21
|
+
// La TUI « prmt » lit ce fichier pour partager la liste blanche, le périmètre et le journal.
|
|
22
|
+
const lien = writeRendezvous(config);
|
|
23
|
+
console.log(` TUI prmt ${lien ? `reliée (${lien})` : 'non reliée : dossier Documents\\Promethee inaccessible'}`);
|
|
24
|
+
// L'accès téléphone rouvre s'il était ouvert à l'arrêt. Il se ferme dans les Réglages, partie Téléphone.
|
|
25
|
+
void app.phone.resume().then(() => {
|
|
26
|
+
const phone = app.phone.state();
|
|
27
|
+
const ligne = !phone.active ? 'fermé (Réglages, Téléphone, pour l’ouvrir)' : phone.urls.length > 0 ? `${phone.urls.join(', ')} (appareils appairés seulement)` : `ouvert, mais sans écoute : ${phone.error ?? '?'}`;
|
|
28
|
+
console.log(` Téléphone ${ligne}\n`);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
let closing = false;
|
|
32
|
+
const shutdown = () => {
|
|
33
|
+
if (closing)
|
|
34
|
+
return;
|
|
35
|
+
closing = true;
|
|
36
|
+
void app.close().then(() => process.exit(0));
|
|
37
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
38
|
+
};
|
|
39
|
+
process.on('SIGINT', shutdown);
|
|
40
|
+
process.on('SIGTERM', shutdown);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal des actions : des fichiers JSONL en ajout seul.
|
|
3
|
+
*
|
|
4
|
+
* La console écrit dans son propre fichier. Elle lit aussi, sans jamais y écrire, le journal que
|
|
5
|
+
* tient la TUI « prmt » : le tableau de bord montre ainsi l'activité des deux interfaces.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
const KINDS = new Set(['command', 'refusal', 'audit', 'ai', 'search', 'scope', 'settings', 'security', 'system']);
|
|
11
|
+
const OUTCOMES = new Set(['ok', 'error', 'refused', 'cancelled', 'info']);
|
|
12
|
+
/** Une ligne de journal lisible, ou null si elle est abîmée ou d'un format inconnu. */
|
|
13
|
+
export function parseEntry(line) {
|
|
14
|
+
if (!line.trim())
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const e = JSON.parse(line);
|
|
18
|
+
if (typeof e.id !== 'string' || typeof e.at !== 'string' || typeof e.title !== 'string')
|
|
19
|
+
return null;
|
|
20
|
+
if (!KINDS.has(String(e.kind)) || !OUTCOMES.has(String(e.outcome)) || Number.isNaN(Date.parse(e.at)))
|
|
21
|
+
return null;
|
|
22
|
+
return e;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const sizeOf = (file) => fs.statSync(file, { throwIfNoEntry: false })?.size ?? 0;
|
|
29
|
+
export class Journal {
|
|
30
|
+
entries = [];
|
|
31
|
+
file;
|
|
32
|
+
extra;
|
|
33
|
+
keep;
|
|
34
|
+
/** Ajouté aux données de chaque entrée : l'appareil quand la requête vient du téléphone. */
|
|
35
|
+
tag;
|
|
36
|
+
/** Taille connue de chaque fichier : s'il a grossi, un autre processus y a écrit. */
|
|
37
|
+
sizes = new Map();
|
|
38
|
+
/** file null : journal en mémoire seulement (tests). extra : journaux lus mais jamais écrits. */
|
|
39
|
+
constructor(file, options = {}) {
|
|
40
|
+
this.file = file;
|
|
41
|
+
this.extra = options.extra ?? [];
|
|
42
|
+
this.keep = options.keep ?? 5000;
|
|
43
|
+
this.tag = options.tag ?? (() => null);
|
|
44
|
+
if (file)
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
46
|
+
this.load();
|
|
47
|
+
}
|
|
48
|
+
sources() {
|
|
49
|
+
return [...(this.file ? [this.file] : []), ...this.extra];
|
|
50
|
+
}
|
|
51
|
+
load() {
|
|
52
|
+
const sources = this.sources();
|
|
53
|
+
if (sources.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
const all = [];
|
|
56
|
+
for (const source of sources) {
|
|
57
|
+
this.sizes.set(source, sizeOf(source));
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = fs.readFileSync(source, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const line of text.split('\n').slice(-this.keep)) {
|
|
66
|
+
const entry = parseEntry(line);
|
|
67
|
+
if (entry)
|
|
68
|
+
all.push(entry);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Le tri est stable : à date égale, l'ordre d'écriture est conservé.
|
|
72
|
+
all.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
73
|
+
this.entries = all.slice(-this.keep);
|
|
74
|
+
}
|
|
75
|
+
/** Relit les fichiers si l'un d'eux a changé depuis la dernière lecture. */
|
|
76
|
+
refresh() {
|
|
77
|
+
for (const source of this.sources()) {
|
|
78
|
+
if (sizeOf(source) !== (this.sizes.get(source) ?? 0)) {
|
|
79
|
+
this.load();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
add(entry) {
|
|
85
|
+
this.refresh();
|
|
86
|
+
const tag = this.tag();
|
|
87
|
+
const full = { id: randomBytes(8).toString('hex'), at: new Date().toISOString(), ...entry, ...(tag ? { data: { ...entry.data, ...tag } } : {}) };
|
|
88
|
+
this.entries.push(full);
|
|
89
|
+
if (this.entries.length > this.keep)
|
|
90
|
+
this.entries.splice(0, this.entries.length - this.keep);
|
|
91
|
+
if (this.file) {
|
|
92
|
+
try {
|
|
93
|
+
fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, 'utf8');
|
|
94
|
+
this.sizes.set(this.file, sizeOf(this.file));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* disque plein ou fichier verrouillé : l'entrée reste en mémoire */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return full;
|
|
101
|
+
}
|
|
102
|
+
/** Entrées de la plus récente à la plus ancienne. */
|
|
103
|
+
query(query = {}) {
|
|
104
|
+
this.refresh();
|
|
105
|
+
const limit = Math.min(Math.max(query.limit ?? 100, 1), 500);
|
|
106
|
+
const needle = query.q?.trim().toLowerCase();
|
|
107
|
+
const out = [];
|
|
108
|
+
let passed = query.before === undefined;
|
|
109
|
+
for (let i = this.entries.length - 1; i >= 0 && out.length < limit; i--) {
|
|
110
|
+
const e = this.entries[i];
|
|
111
|
+
if (!e)
|
|
112
|
+
continue;
|
|
113
|
+
if (!passed) {
|
|
114
|
+
if (e.id === query.before)
|
|
115
|
+
passed = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (query.kind && e.kind !== query.kind)
|
|
119
|
+
continue;
|
|
120
|
+
if (query.outcome && e.outcome !== query.outcome)
|
|
121
|
+
continue;
|
|
122
|
+
if (needle && !`${e.title}\n${e.detail ?? ''}`.toLowerCase().includes(needle))
|
|
123
|
+
continue;
|
|
124
|
+
out.push(e);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
all() {
|
|
129
|
+
this.refresh();
|
|
130
|
+
return this.entries;
|
|
131
|
+
}
|
|
132
|
+
get size() {
|
|
133
|
+
this.refresh();
|
|
134
|
+
return this.entries.length;
|
|
135
|
+
}
|
|
136
|
+
exportText() {
|
|
137
|
+
return this.all().map((e) => JSON.stringify(e)).join('\n');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lien avec la TUI « prmt ».
|
|
3
|
+
*
|
|
4
|
+
* La console écrit un fichier de rendez-vous dans le dossier de la TUI (Documents\Promethee). La TUI y lit
|
|
5
|
+
* où trouver Node, le pont de politique et les données communes : les deux interfaces partagent ainsi la
|
|
6
|
+
* même liste blanche, le même périmètre et un journal que chacune sait lire.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { VERSION } from './config.js';
|
|
11
|
+
import { writeJson } from './stores.js';
|
|
12
|
+
export const TUI_JOURNAL = 'journal.jsonl';
|
|
13
|
+
export const RENDEZVOUS = 'plateforme.json';
|
|
14
|
+
export function rendezvous(config, now = new Date()) {
|
|
15
|
+
const racine = path.resolve(import.meta.dirname, '..');
|
|
16
|
+
return { version: VERSION, node: process.execPath, pont: path.join(racine, 'server', 'cli', 'pont.ts'), racine, donnees: config.dataDir, port: config.port, ecritLe: now.toISOString() };
|
|
17
|
+
}
|
|
18
|
+
/** Écrit le fichier de rendez-vous. Renvoie son chemin, ou null si le dossier de la TUI n'est pas accessible. */
|
|
19
|
+
export function writeRendezvous(config) {
|
|
20
|
+
const file = path.join(config.tuiDir, RENDEZVOUS);
|
|
21
|
+
try {
|
|
22
|
+
const next = rendezvous(config);
|
|
23
|
+
const previous = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
|
|
24
|
+
// Rien à réécrire si seule la date changerait : le fichier ne bouge pas à chaque démarrage.
|
|
25
|
+
if (previous && Object.keys(next).every((k) => k === 'ecritLe' || previous[k] === next[k]))
|
|
26
|
+
return file;
|
|
27
|
+
writeJson(file, next);
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|