@imenam/mcp-organizer 0.0.0-stage → 1.0.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.
Files changed (155) hide show
  1. package/README.md +529 -2
  2. package/dist/shared/logic/first-account.d.ts +13 -0
  3. package/dist/shared/logic/first-account.js +15 -0
  4. package/dist/shared/logic/labels.d.ts +5 -0
  5. package/dist/shared/logic/labels.js +17 -0
  6. package/dist/shared/logic/positions.d.ts +9 -0
  7. package/dist/shared/logic/positions.js +19 -0
  8. package/dist/shared/logic/spaces.d.ts +13 -0
  9. package/dist/shared/logic/spaces.js +48 -0
  10. package/dist/shared/logic/test-summary.d.ts +28 -0
  11. package/dist/shared/logic/test-summary.js +35 -0
  12. package/dist/shared/schemas/agent-api.schema.d.ts +268 -0
  13. package/dist/shared/schemas/agent-api.schema.js +94 -0
  14. package/dist/shared/schemas/agent-tools.schema.d.ts +724 -0
  15. package/dist/shared/schemas/agent-tools.schema.js +165 -0
  16. package/dist/shared/schemas/api.schema.d.ts +249 -0
  17. package/dist/shared/schemas/api.schema.js +116 -0
  18. package/dist/shared/schemas/entities.schema.d.ts +472 -0
  19. package/dist/shared/schemas/entities.schema.js +262 -0
  20. package/dist/shared/schemas/enums.schema.d.ts +135 -0
  21. package/dist/shared/schemas/enums.schema.js +58 -0
  22. package/dist/shared/schemas/gui-api.schema.d.ts +53 -0
  23. package/dist/shared/schemas/gui-api.schema.js +20 -0
  24. package/dist/shared/schemas/ideas-api.schema.d.ts +81 -0
  25. package/dist/shared/schemas/ideas-api.schema.js +40 -0
  26. package/dist/shared/schemas/notifications-api.schema.d.ts +87 -0
  27. package/dist/shared/schemas/notifications-api.schema.js +58 -0
  28. package/dist/shared/schemas/tasks-api.schema.d.ts +675 -0
  29. package/dist/shared/schemas/tasks-api.schema.js +225 -0
  30. package/dist/shared/schemas/tokens-api.schema.d.ts +106 -0
  31. package/dist/shared/schemas/tokens-api.schema.js +42 -0
  32. package/dist/shared/schemas/transcription-api.schema.d.ts +15 -0
  33. package/dist/shared/schemas/transcription-api.schema.js +12 -0
  34. package/dist/src/agent-api/checklist.d.ts +3 -0
  35. package/dist/src/agent-api/checklist.js +127 -0
  36. package/dist/src/agent-api/ideas.d.ts +3 -0
  37. package/dist/src/agent-api/ideas.js +93 -0
  38. package/dist/src/agent-api/requests.d.ts +3 -0
  39. package/dist/src/agent-api/requests.js +60 -0
  40. package/dist/src/agent-api/router.d.ts +3 -0
  41. package/dist/src/agent-api/router.js +40 -0
  42. package/dist/src/agent-api/tasks.d.ts +2 -0
  43. package/dist/src/agent-api/tasks.js +290 -0
  44. package/dist/src/agent-api/types.d.ts +41 -0
  45. package/dist/src/agent-api/types.js +14 -0
  46. package/dist/src/cli/backup.d.ts +12 -0
  47. package/dist/src/cli/backup.js +126 -0
  48. package/dist/src/cli/prompt.d.ts +7 -0
  49. package/dist/src/cli/prompt.js +40 -0
  50. package/dist/src/cli/set-user.d.ts +2 -0
  51. package/dist/src/cli/set-user.js +114 -0
  52. package/dist/src/cli/setup-mcp.d.ts +10 -0
  53. package/dist/src/cli/setup-mcp.js +110 -0
  54. package/dist/src/cli/setup.d.ts +39 -0
  55. package/dist/src/cli/setup.js +626 -0
  56. package/dist/src/core/accounts.d.ts +129 -0
  57. package/dist/src/core/accounts.js +284 -0
  58. package/dist/src/core/agent-tokens.d.ts +31 -0
  59. package/dist/src/core/agent-tokens.js +120 -0
  60. package/dist/src/core/agent.d.ts +71 -0
  61. package/dist/src/core/agent.js +358 -0
  62. package/dist/src/core/attachments.d.ts +21 -0
  63. package/dist/src/core/attachments.js +44 -0
  64. package/dist/src/core/data-dir.d.ts +5 -0
  65. package/dist/src/core/data-dir.js +11 -0
  66. package/dist/src/core/db.d.ts +13 -0
  67. package/dist/src/core/db.js +38 -0
  68. package/dist/src/core/env-file.d.ts +11 -0
  69. package/dist/src/core/env-file.js +36 -0
  70. package/dist/src/core/errors.d.ts +24 -0
  71. package/dist/src/core/errors.js +42 -0
  72. package/dist/src/core/history.d.ts +34 -0
  73. package/dist/src/core/history.js +279 -0
  74. package/dist/src/core/ideas.d.ts +16 -0
  75. package/dist/src/core/ideas.js +100 -0
  76. package/dist/src/core/ids.d.ts +2 -0
  77. package/dist/src/core/ids.js +5 -0
  78. package/dist/src/core/log-dir.d.ts +2 -0
  79. package/dist/src/core/log-dir.js +9 -0
  80. package/dist/src/core/logger.d.ts +14 -0
  81. package/dist/src/core/logger.js +37 -0
  82. package/dist/src/core/mail.d.ts +43 -0
  83. package/dist/src/core/mail.js +44 -0
  84. package/dist/src/core/mcp-entry.d.ts +20 -0
  85. package/dist/src/core/mcp-entry.js +15 -0
  86. package/dist/src/core/migrations.d.ts +18 -0
  87. package/dist/src/core/migrations.js +404 -0
  88. package/dist/src/core/native-sqlite.d.ts +2 -0
  89. package/dist/src/core/native-sqlite.js +23 -0
  90. package/dist/src/core/notifications.d.ts +46 -0
  91. package/dist/src/core/notifications.js +173 -0
  92. package/dist/src/core/project-root.d.ts +2 -0
  93. package/dist/src/core/project-root.js +13 -0
  94. package/dist/src/core/requests.d.ts +49 -0
  95. package/dist/src/core/requests.js +239 -0
  96. package/dist/src/core/snapshots.d.ts +32 -0
  97. package/dist/src/core/snapshots.js +35 -0
  98. package/dist/src/core/spaces.d.ts +66 -0
  99. package/dist/src/core/spaces.js +252 -0
  100. package/dist/src/core/store.d.ts +565 -0
  101. package/dist/src/core/store.js +2093 -0
  102. package/dist/src/core/task-history.d.ts +7 -0
  103. package/dist/src/core/task-history.js +41 -0
  104. package/dist/src/core/tasks.d.ts +58 -0
  105. package/dist/src/core/tasks.js +389 -0
  106. package/dist/src/core/test-tasks.d.ts +19 -0
  107. package/dist/src/core/test-tasks.js +135 -0
  108. package/dist/src/core/transcription.d.ts +45 -0
  109. package/dist/src/core/transcription.js +92 -0
  110. package/dist/src/deploy/env-render.d.ts +26 -0
  111. package/dist/src/deploy/env-render.js +98 -0
  112. package/dist/src/deploy/templates.d.ts +38 -0
  113. package/dist/src/deploy/templates.js +130 -0
  114. package/dist/src/gui-api.d.ts +37 -0
  115. package/dist/src/gui-api.js +383 -0
  116. package/dist/src/index.d.ts +2 -0
  117. package/dist/src/index.js +100 -0
  118. package/dist/src/mcp/client.d.ts +23 -0
  119. package/dist/src/mcp/client.js +81 -0
  120. package/dist/src/mcp/start.d.ts +14 -0
  121. package/dist/src/mcp/start.js +104 -0
  122. package/dist/src/mcp/tools.d.ts +46 -0
  123. package/dist/src/mcp/tools.js +348 -0
  124. package/dist/src/server/app.d.ts +25 -0
  125. package/dist/src/server/app.js +290 -0
  126. package/dist/src/server/config.d.ts +23 -0
  127. package/dist/src/server/config.js +75 -0
  128. package/dist/src/server/limits.d.ts +2 -0
  129. package/dist/src/server/limits.js +4 -0
  130. package/dist/src/server/login-limiter.d.ts +36 -0
  131. package/dist/src/server/login-limiter.js +81 -0
  132. package/dist/src/server/start.d.ts +5 -0
  133. package/dist/src/server/start.js +88 -0
  134. package/dist/src/version.d.ts +1 -0
  135. package/dist/src/version.js +16 -0
  136. package/gui/dist/assets/index-DwkXx4Q2.js +70 -0
  137. package/gui/dist/assets/index-lnvnkDdg.css +1 -0
  138. package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  139. package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  140. package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  141. package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  142. package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  143. package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  144. package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  145. package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  146. package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  147. package/gui/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  148. package/gui/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  149. package/gui/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  150. package/gui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  151. package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  152. package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  153. package/gui/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  154. package/gui/dist/index.html +13 -0
  155. package/package.json +66 -4
@@ -0,0 +1,290 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { getConnInfo } from "@hono/node-server/conninfo";
5
+ import { serveStatic } from "@hono/node-server/serve-static";
6
+ import { Hono } from "hono";
7
+ import { bodyLimit } from "hono/body-limit";
8
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
9
+ import { SESSION_COOKIE_NAME, UPSTREAM_ERROR_HEADER } from "../../shared/schemas/gui-api.schema.js";
10
+ import { MAX_ATTACHMENT_BYTES } from "../../shared/schemas/tasks-api.schema.js";
11
+ import { MAX_TRANSCRIPTION_BYTES } from "../../shared/schemas/transcription-api.schema.js";
12
+ import { errorKindOf, errorStatusOf, handleApiRequest } from "../gui-api.js";
13
+ import { handleAgentRequest } from "../agent-api/router.js";
14
+ import { LoginLimiter } from "./login-limiter.js";
15
+ import { MAX_BODY_BYTES } from "./limits.js";
16
+ /**
17
+ * Politique de contenu de la GUI : scripts, styles et polices du site
18
+ * (polices intégrées en `data:` par Vite), enregistrements audio en `blob:`,
19
+ * appels réseau vers le site seul, aucun cadre englobant.
20
+ */
21
+ export const CONTENT_SECURITY_POLICY = [
22
+ "default-src 'self'",
23
+ "script-src 'self'",
24
+ "style-src 'self'",
25
+ "img-src 'self' data: blob:",
26
+ "font-src 'self' data:",
27
+ "media-src 'self' blob:",
28
+ "connect-src 'self'",
29
+ "object-src 'none'",
30
+ "base-uri 'self'",
31
+ "form-action 'self'",
32
+ "frame-ancestors 'none'",
33
+ ].join("; ");
34
+ const SECURITY_HEADERS = {
35
+ "Content-Security-Policy": CONTENT_SECURITY_POLICY,
36
+ "X-Content-Type-Options": "nosniff",
37
+ "X-Frame-Options": "DENY",
38
+ "Referrer-Policy": "same-origin",
39
+ "Cross-Origin-Opener-Policy": "same-origin",
40
+ "Cross-Origin-Resource-Policy": "same-origin",
41
+ "Permissions-Policy": "microphone=(self), camera=(), geolocation=()",
42
+ };
43
+ /** Méthodes qui modifient : elles exigent une requête de la GUI elle-même. */
44
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
45
+ /** Code HTTP de chaque nature d'erreur de l'API. */
46
+ const HTTP_STATUS_BY_ERROR_KIND = {
47
+ invalid: 400,
48
+ unauthorized: 401,
49
+ forbidden: 403,
50
+ not_found: 404,
51
+ conflict: 409,
52
+ too_large: 413,
53
+ internal: 500,
54
+ upstream: 502,
55
+ unavailable: 503,
56
+ };
57
+ /** Préfixe des routes de l'API des agents. */
58
+ const AGENT_API_PREFIX = "/api/agent";
59
+ /** Route de l'API des agents : authentifiée par `Authorization: Bearer`, sans cookie. */
60
+ function isAgentApiPath(path) {
61
+ return path === AGENT_API_PREFIX || path.startsWith(`${AGENT_API_PREFIX}/`);
62
+ }
63
+ /** Jeton de l'en-tête `Authorization: Bearer <jeton>`, null sans en-tête Bearer. */
64
+ function bearerToken(c) {
65
+ const match = /^Bearer\s+(.+)$/i.exec(c.req.header("authorization") ?? "");
66
+ return match ? match[1].trim() : null;
67
+ }
68
+ /** Taille lisible d'un nombre d'octets, en Mo. */
69
+ function megabytes(bytes) {
70
+ return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} Mo`;
71
+ }
72
+ /** Limite de taille d'un fichier envoyé : un enregistrement à transcrire, ou une pièce jointe. */
73
+ function uploadLimit(c) {
74
+ if (c.req.path === "/api/transcribe") {
75
+ return { maxBytes: MAX_TRANSCRIPTION_BYTES, tooLarge: (size) => `Enregistrement trop volumineux (${megabytes(size)}) : la dictée accepte ${megabytes(MAX_TRANSCRIPTION_BYTES)} au plus.` };
76
+ }
77
+ return { maxBytes: MAX_ATTACHMENT_BYTES, tooLarge: (size) => `Fichier trop volumineux (${megabytes(size)}) : une pièce jointe ne dépasse pas ${megabytes(MAX_ATTACHMENT_BYTES)}.` };
78
+ }
79
+ /**
80
+ * Formulaire multipart d'envoi de fichier (champ `file`) : pièce jointe ou
81
+ * enregistrement à transcrire, transmis à l'API en JSON : nom, type MIME
82
+ * (sans ses paramètres) et contenu en base64. Un fichier sans type déclaré par
83
+ * le navigateur est un flux binaire (`application/octet-stream`).
84
+ */
85
+ async function readUploadBody(c) {
86
+ let form;
87
+ try {
88
+ form = await c.req.formData();
89
+ }
90
+ catch (err) {
91
+ return { response: c.json({ error: `The multipart form cannot be read: ${err?.message ?? err}` }, 400) };
92
+ }
93
+ const file = form.get("file");
94
+ if (!(file instanceof File))
95
+ return { response: c.json({ error: "Le formulaire ne contient pas de fichier (champ « file »)." }, 400) };
96
+ const limit = uploadLimit(c);
97
+ if (file.size > limit.maxBytes)
98
+ return { response: c.json({ error: limit.tooLarge(file.size) }, 413) };
99
+ const content = Buffer.from(await file.arrayBuffer());
100
+ const mime = file.type.split(";")[0].trim() || "application/octet-stream";
101
+ return { body: { filename: file.name || "fichier", mime, contentBase64: content.toString("base64") } };
102
+ }
103
+ /** Corps d'une requête de modification : JSON, ou formulaire multipart d'un fichier ; `undefined` sans corps. */
104
+ async function readRequestBody(c) {
105
+ if (c.req.method === "GET" || c.req.method === "HEAD")
106
+ return { body: undefined };
107
+ if ((c.req.header("content-type") ?? "").toLowerCase().startsWith("multipart/form-data"))
108
+ return readUploadBody(c);
109
+ const text = await c.req.text();
110
+ if (text.trim() === "")
111
+ return { body: undefined };
112
+ try {
113
+ return { body: JSON.parse(text) };
114
+ }
115
+ catch (err) {
116
+ return { response: c.json({ error: `The request body is not valid JSON: ${err?.message ?? err}` }, 400) };
117
+ }
118
+ }
119
+ /** En-tête `Content-Disposition` d'un téléchargement, avec le nom du fichier en UTF-8 (RFC 6266). */
120
+ function contentDisposition(filename) {
121
+ const ascii = filename.replace(/[^\x20-\x7e]|["\\]/g, "_");
122
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
123
+ }
124
+ function fileResponse(c, file) {
125
+ return c.body(Buffer.from(file.contentBase64, "base64"), 200, {
126
+ "Content-Type": file.mime,
127
+ "Content-Disposition": contentDisposition(file.filename),
128
+ "Cache-Control": "private, no-store",
129
+ });
130
+ }
131
+ /** `<base href>` de la SPA : le chemin de base terminé par une barre oblique. */
132
+ function baseHref(basePath) {
133
+ return basePath === "/" ? "/" : `${basePath}/`;
134
+ }
135
+ /** Adresse e-mail d'une tentative de connexion, normalisée, ou null si le corps n'en porte pas. */
136
+ function loginEmail(body) {
137
+ const email = body?.email;
138
+ return typeof email === "string" && email.trim() !== "" ? email.trim().toLowerCase() : null;
139
+ }
140
+ export function createSiteApp(deps) {
141
+ const { config, api, logger, staticRoot } = deps;
142
+ const loginLimiter = deps.loginLimiter ?? new LoginLimiter();
143
+ const cookieOptions = { path: config.basePath, secure: config.secureCookie };
144
+ const app = new Hono();
145
+ /**
146
+ * Adresse du client : la dernière entrée de `X-Forwarded-For`, écrite par le
147
+ * reverse proxy, quand `ORGANIZER_TRUST_PROXY=1` ; sinon l'adresse de la connexion.
148
+ */
149
+ function clientIp(c) {
150
+ if (config.trustProxy) {
151
+ const forwarded = c.req.header("x-forwarded-for");
152
+ const last = forwarded?.split(",").map((part) => part.trim()).filter(Boolean).pop();
153
+ if (last)
154
+ return last;
155
+ }
156
+ const address = getConnInfo(c).remote.address;
157
+ if (!address)
158
+ throw new Error("The client address of the connection is unavailable.");
159
+ return address;
160
+ }
161
+ /** Journalise une erreur interne avec une référence courte, seule transmise au client avec un message générique. */
162
+ function logInternalError(context, err) {
163
+ const reference = randomBytes(4).toString("hex");
164
+ logger.error(`${context} failed (reference ${reference}): ${err?.stack ?? err}`);
165
+ return reference;
166
+ }
167
+ const securityHeaders = async (c, next) => {
168
+ await next();
169
+ for (const [name, value] of Object.entries(SECURITY_HEADERS))
170
+ c.res.headers.set(name, value);
171
+ };
172
+ /**
173
+ * Une modification vient de la GUI : `Sec-Fetch-Site: same-origin`, ou
174
+ * `Origin` égal à l'origine publique. L'API des agents en est exemptée : elle
175
+ * n'accepte aucun cookie, seulement un jeton porté par l'en-tête `Authorization`.
176
+ */
177
+ const sameOriginGuard = async (c, next) => {
178
+ if (!MUTATING_METHODS.has(c.req.method) || isAgentApiPath(c.req.path))
179
+ return next();
180
+ if (c.req.header("sec-fetch-site") === "same-origin" || c.req.header("origin") === config.publicOrigin)
181
+ return next();
182
+ logger.warn(`Cross-origin ${c.req.method} ${c.req.path} refused (Origin: ${c.req.header("origin") ?? "none"}, Sec-Fetch-Site: ${c.req.header("sec-fetch-site") ?? "none"})`);
183
+ return c.json({ error: `Requête refusée : une modification doit venir de l'application elle-même (${config.publicUrl}).` }, 403);
184
+ };
185
+ app.use("*", securityHeaders);
186
+ app.use("/api/*", sameOriginGuard);
187
+ app.use("/api/*", bodyLimit({
188
+ maxSize: MAX_BODY_BYTES,
189
+ onError: (c) => c.json({ error: `Requête trop volumineuse : ${megabytes(MAX_BODY_BYTES)} au plus.` }, 413),
190
+ }));
191
+ // API des agents : jeton de l'en-tête Authorization, corps JSON ; les cookies sont ignorés.
192
+ const agentApi = async (c) => {
193
+ let body = undefined;
194
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
195
+ const text = await c.req.text();
196
+ if (text.trim() !== "") {
197
+ try {
198
+ body = JSON.parse(text);
199
+ }
200
+ catch (err) {
201
+ return c.json({ error: `The request body is not valid JSON: ${err?.message ?? err}` }, 400);
202
+ }
203
+ }
204
+ }
205
+ try {
206
+ const response = handleAgentRequest({ store: api.store, now: api.now }, { method: c.req.method, path: c.req.path.slice(AGENT_API_PREFIX.length) || "/", query: c.req.query(), body, bearer: bearerToken(c) });
207
+ return c.json(response);
208
+ }
209
+ catch (err) {
210
+ const kind = errorKindOf(err);
211
+ if (kind === "internal") {
212
+ const reference = logInternalError(`Agent request ${c.req.method} ${c.req.path}`, err);
213
+ return c.json({ error: `Internal error of the organizer site (reference ${reference}): retry later; if it persists, give this reference to the administrator of the site, who finds the details in its log.` }, 500);
214
+ }
215
+ if (kind === "unauthorized")
216
+ c.header("WWW-Authenticate", 'Bearer realm="mcp-organizer"');
217
+ return c.json({ error: err?.message ?? String(err) }, HTTP_STATUS_BY_ERROR_KIND[kind]);
218
+ }
219
+ };
220
+ app.all(AGENT_API_PREFIX, agentApi);
221
+ app.all(`${AGENT_API_PREFIX}/*`, agentApi);
222
+ // Toute route `/api/*` est traitée par le routeur de l'API, avec le jeton du
223
+ // cookie de session ; sa réponse dit s'il faut poser, prolonger ou effacer le cookie.
224
+ app.all("/api/*", async (c) => {
225
+ const read = await readRequestBody(c);
226
+ if ("response" in read)
227
+ return read.response;
228
+ const request = {
229
+ method: c.req.method,
230
+ path: c.req.path.slice("/api".length) || "/",
231
+ query: c.req.query(),
232
+ body: read.body,
233
+ sessionToken: getCookie(c, SESSION_COOKIE_NAME) ?? null,
234
+ };
235
+ // Une tentative de connexion est comptée avant la vérification du mot de passe, et décomptée si elle réussit.
236
+ let loginAttempt = null;
237
+ if (request.method === "POST" && request.path === "/auth/login") {
238
+ const ip = clientIp(c);
239
+ const email = loginEmail(request.body);
240
+ loginAttempt = loginLimiter.begin(ip, email);
241
+ if (!loginAttempt.admitted) {
242
+ const minutes = Math.ceil(loginAttempt.retryAfterMs / 60_000);
243
+ logger.warn(`Login attempts limited for ${ip}${email ? ` / ${email}` : ""}`);
244
+ c.header("Retry-After", String(Math.ceil(loginAttempt.retryAfterMs / 1000)));
245
+ return c.json({ error: `Trop de tentatives de connexion. Réessayez dans ${minutes} minute${minutes > 1 ? "s" : ""}.` }, 429);
246
+ }
247
+ }
248
+ let response;
249
+ try {
250
+ response = await handleApiRequest(api, request);
251
+ }
252
+ catch (err) {
253
+ const kind = errorKindOf(err);
254
+ if (kind === "unauthorized")
255
+ deleteCookie(c, SESSION_COOKIE_NAME, cookieOptions);
256
+ if (kind === "internal") {
257
+ const reference = logInternalError(`GUI request ${request.method} ${c.req.path}`, err);
258
+ return c.json({ error: `Erreur interne du site (référence ${reference}). Réessayez ; si l'erreur persiste, transmettez cette référence à l'administrateur du site, qui en trouve le détail dans son journal.` }, 500);
259
+ }
260
+ // Un refus d'un service externe (Groq) garde son code HTTP ; l'en-tête le distingue d'un refus de session.
261
+ if (kind === "upstream")
262
+ c.header(UPSTREAM_ERROR_HEADER, "upstream");
263
+ return c.json({ error: err?.message ?? String(err) }, errorStatusOf(err) ?? HTTP_STATUS_BY_ERROR_KIND[kind]);
264
+ }
265
+ if (loginAttempt?.admitted)
266
+ loginAttempt.succeeded();
267
+ const { body, session, file } = response;
268
+ if (session === null) {
269
+ deleteCookie(c, SESSION_COOKIE_NAME, cookieOptions);
270
+ }
271
+ else if (session !== undefined) {
272
+ setCookie(c, SESSION_COOKIE_NAME, session.token, { ...cookieOptions, httpOnly: true, sameSite: "Lax", expires: new Date(session.expiresAt) });
273
+ }
274
+ if (file)
275
+ return fileResponse(c, file);
276
+ return c.json(body ?? null);
277
+ });
278
+ app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), staticRoot) }));
279
+ // Toute autre route renvoie la page de la SPA, avec la balise <base> du chemin de base.
280
+ app.get("/*", (c) => {
281
+ const indexPath = path.join(staticRoot, "index.html");
282
+ if (!fs.existsSync(indexPath)) {
283
+ return c.text(`GUI build not found in ${staticRoot}. Run "npm run build:gui" in the mcp-organizer package.`, 500);
284
+ }
285
+ const html = fs.readFileSync(indexPath, "utf8").replace("<head>", `<head>\n <base href="${baseHref(config.basePath)}" />`);
286
+ c.header("Cache-Control", "no-cache");
287
+ return c.html(html);
288
+ });
289
+ return app;
290
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Configuration du mode site (`--server`), lue dans l'environnement après le
3
+ * chargement du `.env`. Une variable vide est traitée comme absente ; une
4
+ * valeur absente ou invalide est une `ConfigError` qui dit quoi corriger.
5
+ */
6
+ /** Adresse d'écoute sans `HOST` : la boucle locale, derrière le reverse proxy. */
7
+ export declare const DEFAULT_HOST = "127.0.0.1";
8
+ export interface SiteConfig {
9
+ host: string;
10
+ port: number;
11
+ /** `ORGANIZER_PUBLIC_URL` sans barre oblique finale : base des liens envoyés par e-mail. */
12
+ publicUrl: string;
13
+ /** Origine de l'URL publique (`https://organizer.example.com`), seule admise dans `Origin`. */
14
+ publicOrigin: string;
15
+ /** Chemin de base vu par le navigateur, `/` ou `/organizer` : `<base href>` et `Path` du cookie. */
16
+ basePath: string;
17
+ /** Cookie de session `Secure` : l'URL publique est en https. */
18
+ secureCookie: boolean;
19
+ /** `X-Forwarded-For` donne l'adresse du client (`ORGANIZER_TRUST_PROXY=1`). */
20
+ trustProxy: boolean;
21
+ }
22
+ /** Configuration du site d'après `env`. */
23
+ export declare function loadSiteConfig(env?: NodeJS.ProcessEnv): SiteConfig;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Configuration du mode site (`--server`), lue dans l'environnement après le
3
+ * chargement du `.env`. Une variable vide est traitée comme absente ; une
4
+ * valeur absente ou invalide est une `ConfigError` qui dit quoi corriger.
5
+ */
6
+ import { ConfigError } from "../core/errors.js";
7
+ /** Adresse d'écoute sans `HOST` : la boucle locale, derrière le reverse proxy. */
8
+ export const DEFAULT_HOST = "127.0.0.1";
9
+ function read(env, name) {
10
+ const value = env[name]?.trim();
11
+ return value ? value : undefined;
12
+ }
13
+ function parsePort(raw) {
14
+ if (raw === undefined)
15
+ throw new ConfigError("PORT n’est pas défini : indiquez le port d’écoute du site (par exemple PORT=4800 dans le fichier .env).");
16
+ const port = Number(raw);
17
+ if (!/^\d+$/.test(raw) || !Number.isInteger(port) || port < 1 || port > 65535) {
18
+ throw new ConfigError(`PORT invalide (${raw}) : entier attendu entre 1 et 65535.`);
19
+ }
20
+ return port;
21
+ }
22
+ function parsePublicUrl(raw) {
23
+ if (raw === undefined) {
24
+ throw new ConfigError("ORGANIZER_PUBLIC_URL n’est pas défini : indiquez l’URL que les utilisateurs ouvrent dans leur navigateur (par exemple ORGANIZER_PUBLIC_URL=https://organizer.example.com dans le fichier .env). Le site y compare l’origine de chaque modification.");
25
+ }
26
+ let url;
27
+ try {
28
+ url = new URL(raw);
29
+ }
30
+ catch {
31
+ throw new ConfigError(`ORGANIZER_PUBLIC_URL n’est pas une URL absolue (${raw}) : attendu par exemple https://organizer.example.com.`);
32
+ }
33
+ if (url.protocol !== "http:" && url.protocol !== "https:")
34
+ throw new ConfigError(`ORGANIZER_PUBLIC_URL doit être en http ou https (${raw}).`);
35
+ if (url.search || url.hash || url.username || url.password) {
36
+ throw new ConfigError(`ORGANIZER_PUBLIC_URL ne doit contenir ni identifiants, ni paramètres, ni fragment (${raw}).`);
37
+ }
38
+ return url;
39
+ }
40
+ /** `/organizer/` → `/organizer` ; `/` reste `/`. */
41
+ function normalizePath(value) {
42
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
43
+ const trimmed = withSlash.replace(/\/+$/, "");
44
+ return trimmed === "" ? "/" : trimmed;
45
+ }
46
+ function parseBasePath(raw, publicUrl) {
47
+ const basePath = raw === undefined ? "/" : normalizePath(raw);
48
+ if (!/^\/[A-Za-z0-9._~\-/]*$/.test(basePath))
49
+ throw new ConfigError(`ORGANIZER_BASE_PATH invalide (${raw}) : chemin attendu, par exemple /organizer.`);
50
+ const publicPath = normalizePath(publicUrl.pathname);
51
+ if (publicPath !== basePath) {
52
+ throw new ConfigError(`ORGANIZER_PUBLIC_URL (${publicUrl.href}) et ORGANIZER_BASE_PATH (${raw ?? "non défini, donc /"}) ne concordent pas : le chemin de l’URL publique est ${publicPath}. Définissez ORGANIZER_BASE_PATH=${publicPath}, ou corrigez ORGANIZER_PUBLIC_URL.`);
53
+ }
54
+ return basePath;
55
+ }
56
+ function parseTrustProxy(raw) {
57
+ if (raw === undefined)
58
+ return false;
59
+ if (raw === "1")
60
+ return true;
61
+ throw new ConfigError(`ORGANIZER_TRUST_PROXY invalide (${raw}) : 1 derrière un reverse proxy qui écrit X-Forwarded-For, vide sinon.`);
62
+ }
63
+ /** Configuration du site d'après `env`. */
64
+ export function loadSiteConfig(env = process.env) {
65
+ const publicUrl = parsePublicUrl(read(env, "ORGANIZER_PUBLIC_URL"));
66
+ return {
67
+ host: read(env, "HOST") ?? DEFAULT_HOST,
68
+ port: parsePort(read(env, "PORT")),
69
+ publicUrl: publicUrl.href.replace(/\/+$/, ""),
70
+ publicOrigin: publicUrl.origin,
71
+ basePath: parseBasePath(read(env, "ORGANIZER_BASE_PATH"), publicUrl),
72
+ secureCookie: publicUrl.protocol === "https:",
73
+ trustProxy: parseTrustProxy(read(env, "ORGANIZER_TRUST_PROXY")),
74
+ };
75
+ }
@@ -0,0 +1,2 @@
1
+ /** Limite de taille des corps : le plus gros fichier accepté (enregistrement à transcrire) et l'enveloppe de son formulaire multipart. */
2
+ export declare const MAX_BODY_BYTES: number;
@@ -0,0 +1,4 @@
1
+ import { MAX_ATTACHMENT_BYTES } from "../../shared/schemas/tasks-api.schema.js";
2
+ import { MAX_TRANSCRIPTION_BYTES } from "../../shared/schemas/transcription-api.schema.js";
3
+ /** Limite de taille des corps : le plus gros fichier accepté (enregistrement à transcrire) et l'enveloppe de son formulaire multipart. */
4
+ export const MAX_BODY_BYTES = Math.max(MAX_ATTACHMENT_BYTES, MAX_TRANSCRIPTION_BYTES) + 64 * 1024;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Limitation des tentatives de connexion, par adresse IP et par couple
3
+ * (adresse IP, adresse e-mail), sur une fenêtre glissante. Une tentative est
4
+ * comptée dès son arrivée, avant toute vérification du mot de passe, et
5
+ * décomptée si la connexion réussit : des tentatives simultanées ne dépassent
6
+ * donc jamais la limite. Une clé qui a atteint sa limite est refusée jusqu'à
7
+ * ce que sa plus ancienne tentative sorte de la fenêtre.
8
+ */
9
+ export interface LoginLimits {
10
+ /** Durée de la fenêtre glissante. */
11
+ windowMs: number;
12
+ /** Tentatives admises par adresse IP dans la fenêtre. */
13
+ maxPerIp: number;
14
+ /** Tentatives admises par couple (adresse IP, adresse e-mail) dans la fenêtre. */
15
+ maxPerIpAndEmail: number;
16
+ }
17
+ export declare const DEFAULT_LOGIN_LIMITS: LoginLimits;
18
+ /** Issue de `begin` : tentative admise et comptée, ou refusée avec le délai d'attente. */
19
+ export type LoginAttempt = {
20
+ admitted: true;
21
+ succeeded: () => void;
22
+ } | {
23
+ admitted: false;
24
+ retryAfterMs: number;
25
+ };
26
+ export declare class LoginLimiter {
27
+ private readonly now;
28
+ private readonly byIp;
29
+ private readonly byIpAndEmail;
30
+ constructor(limits?: LoginLimits, now?: () => number);
31
+ /**
32
+ * Admet et compte une tentative de `ip` sur `email`, ou la refuse quand une
33
+ * de ses clés a atteint sa limite. `succeeded` retire la tentative admise.
34
+ */
35
+ begin(ip: string, email: string | null): LoginAttempt;
36
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Limitation des tentatives de connexion, par adresse IP et par couple
3
+ * (adresse IP, adresse e-mail), sur une fenêtre glissante. Une tentative est
4
+ * comptée dès son arrivée, avant toute vérification du mot de passe, et
5
+ * décomptée si la connexion réussit : des tentatives simultanées ne dépassent
6
+ * donc jamais la limite. Une clé qui a atteint sa limite est refusée jusqu'à
7
+ * ce que sa plus ancienne tentative sorte de la fenêtre.
8
+ */
9
+ export const DEFAULT_LOGIN_LIMITS = { windowMs: 15 * 60 * 1000, maxPerIp: 20, maxPerIpAndEmail: 5 };
10
+ /** Nombre de clés suivies au-delà duquel les clés sans tentative récente sont purgées. */
11
+ const PRUNE_THRESHOLD = 10_000;
12
+ class SlidingWindow {
13
+ windowMs;
14
+ max;
15
+ attempts = new Map();
16
+ constructor(windowMs, max) {
17
+ this.windowMs = windowMs;
18
+ this.max = max;
19
+ }
20
+ recent(key, now) {
21
+ const kept = (this.attempts.get(key) ?? []).filter((entry) => entry.at > now - this.windowMs);
22
+ if (kept.length === 0)
23
+ this.attempts.delete(key);
24
+ else
25
+ this.attempts.set(key, kept);
26
+ return kept;
27
+ }
28
+ /** Millisecondes avant la prochaine tentative admise, 0 si elle l'est déjà. */
29
+ retryAfter(key, now) {
30
+ const recent = this.recent(key, now);
31
+ if (recent.length < this.max)
32
+ return 0;
33
+ return recent[recent.length - this.max].at + this.windowMs - now;
34
+ }
35
+ record(key, now) {
36
+ if (this.attempts.size > PRUNE_THRESHOLD)
37
+ for (const candidate of [...this.attempts.keys()])
38
+ this.recent(candidate, now);
39
+ const entry = { at: now };
40
+ this.attempts.set(key, [...this.recent(key, now), entry]);
41
+ return entry;
42
+ }
43
+ remove(key, entry) {
44
+ const kept = (this.attempts.get(key) ?? []).filter((candidate) => candidate !== entry);
45
+ if (kept.length === 0)
46
+ this.attempts.delete(key);
47
+ else
48
+ this.attempts.set(key, kept);
49
+ }
50
+ }
51
+ export class LoginLimiter {
52
+ now;
53
+ byIp;
54
+ byIpAndEmail;
55
+ constructor(limits = DEFAULT_LOGIN_LIMITS, now = Date.now) {
56
+ this.now = now;
57
+ this.byIp = new SlidingWindow(limits.windowMs, limits.maxPerIp);
58
+ this.byIpAndEmail = new SlidingWindow(limits.windowMs, limits.maxPerIpAndEmail);
59
+ }
60
+ /**
61
+ * Admet et compte une tentative de `ip` sur `email`, ou la refuse quand une
62
+ * de ses clés a atteint sa limite. `succeeded` retire la tentative admise.
63
+ */
64
+ begin(ip, email) {
65
+ const now = this.now();
66
+ const pair = email === null ? null : `${ip}\n${email}`;
67
+ const waitMs = Math.max(this.byIp.retryAfter(ip, now), pair === null ? 0 : this.byIpAndEmail.retryAfter(pair, now));
68
+ if (waitMs > 0)
69
+ return { admitted: false, retryAfterMs: waitMs };
70
+ const ipEntry = this.byIp.record(ip, now);
71
+ const pairEntry = pair === null ? null : this.byIpAndEmail.record(pair, now);
72
+ return {
73
+ admitted: true,
74
+ succeeded: () => {
75
+ this.byIp.remove(ip, ipEntry);
76
+ if (pair !== null && pairEntry !== null)
77
+ this.byIpAndEmail.remove(pair, pairEntry);
78
+ },
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Mode site (`mcp-organizer --server`) : un processus web permanent qui
3
+ * détient la base, sert la GUI et son API, et écoute sur `HOST:PORT`.
4
+ */
5
+ export declare function runServerMode(rootDir: string): Promise<void>;
@@ -0,0 +1,88 @@
1
+ import path from "node:path";
2
+ import { serve } from "@hono/node-server";
3
+ import { APP_VERSION } from "../version.js";
4
+ import { resolveDataDir } from "../core/data-dir.js";
5
+ import { resolveLogDir } from "../core/log-dir.js";
6
+ import { createLogger, setupLogging } from "../core/logger.js";
7
+ import { Store } from "../core/store.js";
8
+ import { AttachmentStorage } from "../core/attachments.js";
9
+ import { bootstrapAdmin } from "../core/accounts.js";
10
+ import { mailerFromEnv } from "../core/mail.js";
11
+ import { NotificationMailer } from "../core/notifications.js";
12
+ import { transcriberFromEnv } from "../core/transcription.js";
13
+ import { ConfigError } from "../core/errors.js";
14
+ import { loadSiteConfig } from "./config.js";
15
+ import { createSiteApp } from "./app.js";
16
+ /**
17
+ * Mode site (`mcp-organizer --server`) : un processus web permanent qui
18
+ * détient la base, sert la GUI et son API, et écoute sur `HOST:PORT`.
19
+ */
20
+ export async function runServerMode(rootDir) {
21
+ const config = loadSiteConfig();
22
+ setupLogging("mcp-organizer:site", resolveLogDir(rootDir));
23
+ const logger = createLogger("site");
24
+ const dataDir = resolveDataDir(rootDir);
25
+ let store;
26
+ try {
27
+ store = Store.open(dataDir);
28
+ }
29
+ catch (err) {
30
+ throw new ConfigError(`Impossible d’ouvrir la base de mcp-organizer dans ${dataDir} : ${err?.message ?? err}`);
31
+ }
32
+ const mailer = mailerFromEnv();
33
+ if (!mailer.enabled)
34
+ logger.warn(mailer.disabledReason);
35
+ const transcriber = transcriberFromEnv();
36
+ if (!transcriber.enabled)
37
+ logger.warn(transcriber.disabledReason);
38
+ new NotificationMailer(store, { mailer, publicUrl: config.publicUrl, logError: (message) => logger.error(message) }).attach();
39
+ let bootstrap;
40
+ try {
41
+ bootstrap = await bootstrapAdmin(store, {
42
+ email: process.env.ORGANIZER_ADMIN_EMAIL,
43
+ displayName: process.env.ORGANIZER_ADMIN_NAME,
44
+ password: process.env.ORGANIZER_ADMIN_PASSWORD,
45
+ });
46
+ }
47
+ catch (err) {
48
+ throw new ConfigError(`Cannot create the first account from ORGANIZER_ADMIN_EMAIL / ORGANIZER_ADMIN_NAME / ORGANIZER_ADMIN_PASSWORD: ${err?.message ?? err}`);
49
+ }
50
+ if (bootstrap.outcome === "missing")
51
+ logger.warn(bootstrap.message);
52
+ else
53
+ logger.info(bootstrap.message);
54
+ const app = createSiteApp({
55
+ config,
56
+ logger,
57
+ staticRoot: path.join(rootDir, "gui", "dist"),
58
+ api: {
59
+ store,
60
+ version: APP_VERSION,
61
+ mailer,
62
+ publicUrl: config.publicUrl,
63
+ attachments: new AttachmentStorage(dataDir),
64
+ transcriber,
65
+ },
66
+ });
67
+ const server = await new Promise((resolve, reject) => {
68
+ const instance = serve({ fetch: app.fetch, port: config.port, hostname: config.host }, () => resolve(instance));
69
+ instance.once("error", (err) => reject(err.code === "EADDRINUSE" ? new ConfigError(`Le port ${config.port} est déjà utilisé sur ${config.host} : libérez-le ou changez PORT.`) : err));
70
+ });
71
+ logger.info(`mcp-organizer v${APP_VERSION} listening on http://${config.host}:${config.port}, public URL ${config.publicUrl} (data dir: ${dataDir})`);
72
+ if (config.trustProxy)
73
+ logger.info("ORGANIZER_TRUST_PROXY=1: the client address is read from X-Forwarded-For.");
74
+ let shuttingDown = false;
75
+ const shutdown = (signal) => {
76
+ if (shuttingDown)
77
+ return;
78
+ shuttingDown = true;
79
+ logger.info(`${signal} received, shutting down`);
80
+ server.close(() => {
81
+ store.close();
82
+ process.exit(0);
83
+ });
84
+ server.closeAllConnections();
85
+ };
86
+ process.on("SIGINT", () => shutdown("SIGINT"));
87
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
88
+ }
@@ -0,0 +1 @@
1
+ export declare const APP_VERSION: string;
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // Lit la version dans le premier package.json trouvé en remontant depuis ce module.
5
+ function readPackageVersion() {
6
+ let dir = path.dirname(fileURLToPath(import.meta.url));
7
+ while (!existsSync(path.join(dir, "package.json"))) {
8
+ const parent = path.dirname(dir);
9
+ if (parent === dir)
10
+ throw new Error("package.json introuvable");
11
+ dir = parent;
12
+ }
13
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
14
+ return pkg.version;
15
+ }
16
+ export const APP_VERSION = readPackageVersion();