@imenam/mcp-organizer 0.0.0-stage → 1.0.2

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 (165) hide show
  1. package/README.md +548 -3
  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 +269 -0
  13. package/dist/shared/schemas/agent-api.schema.js +97 -0
  14. package/dist/shared/schemas/agent-tools.schema.d.ts +818 -0
  15. package/dist/shared/schemas/agent-tools.schema.js +198 -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/notifications.d.ts +16 -0
  39. package/dist/src/agent-api/notifications.js +91 -0
  40. package/dist/src/agent-api/requests.d.ts +3 -0
  41. package/dist/src/agent-api/requests.js +60 -0
  42. package/dist/src/agent-api/router.d.ts +3 -0
  43. package/dist/src/agent-api/router.js +41 -0
  44. package/dist/src/agent-api/tasks.d.ts +2 -0
  45. package/dist/src/agent-api/tasks.js +290 -0
  46. package/dist/src/agent-api/types.d.ts +41 -0
  47. package/dist/src/agent-api/types.js +14 -0
  48. package/dist/src/cli/backup.d.ts +12 -0
  49. package/dist/src/cli/backup.js +126 -0
  50. package/dist/src/cli/prompt.d.ts +7 -0
  51. package/dist/src/cli/prompt.js +40 -0
  52. package/dist/src/cli/set-user.d.ts +2 -0
  53. package/dist/src/cli/set-user.js +114 -0
  54. package/dist/src/cli/setup-mcp.d.ts +10 -0
  55. package/dist/src/cli/setup-mcp.js +110 -0
  56. package/dist/src/cli/setup.d.ts +39 -0
  57. package/dist/src/cli/setup.js +626 -0
  58. package/dist/src/cli/watch.d.ts +4 -0
  59. package/dist/src/cli/watch.js +62 -0
  60. package/dist/src/core/accounts.d.ts +129 -0
  61. package/dist/src/core/accounts.js +284 -0
  62. package/dist/src/core/agent-tokens.d.ts +31 -0
  63. package/dist/src/core/agent-tokens.js +120 -0
  64. package/dist/src/core/agent.d.ts +71 -0
  65. package/dist/src/core/agent.js +358 -0
  66. package/dist/src/core/attachments.d.ts +21 -0
  67. package/dist/src/core/attachments.js +44 -0
  68. package/dist/src/core/data-dir.d.ts +5 -0
  69. package/dist/src/core/data-dir.js +11 -0
  70. package/dist/src/core/db.d.ts +13 -0
  71. package/dist/src/core/db.js +38 -0
  72. package/dist/src/core/env-file.d.ts +11 -0
  73. package/dist/src/core/env-file.js +36 -0
  74. package/dist/src/core/errors.d.ts +24 -0
  75. package/dist/src/core/errors.js +42 -0
  76. package/dist/src/core/history.d.ts +34 -0
  77. package/dist/src/core/history.js +279 -0
  78. package/dist/src/core/ideas.d.ts +16 -0
  79. package/dist/src/core/ideas.js +100 -0
  80. package/dist/src/core/ids.d.ts +2 -0
  81. package/dist/src/core/ids.js +5 -0
  82. package/dist/src/core/log-dir.d.ts +2 -0
  83. package/dist/src/core/log-dir.js +9 -0
  84. package/dist/src/core/logger.d.ts +14 -0
  85. package/dist/src/core/logger.js +37 -0
  86. package/dist/src/core/mail.d.ts +43 -0
  87. package/dist/src/core/mail.js +44 -0
  88. package/dist/src/core/mcp-entry.d.ts +20 -0
  89. package/dist/src/core/mcp-entry.js +15 -0
  90. package/dist/src/core/migrations.d.ts +18 -0
  91. package/dist/src/core/migrations.js +404 -0
  92. package/dist/src/core/native-sqlite.d.ts +2 -0
  93. package/dist/src/core/native-sqlite.js +23 -0
  94. package/dist/src/core/notifications.d.ts +46 -0
  95. package/dist/src/core/notifications.js +173 -0
  96. package/dist/src/core/project-root.d.ts +2 -0
  97. package/dist/src/core/project-root.js +13 -0
  98. package/dist/src/core/requests.d.ts +50 -0
  99. package/dist/src/core/requests.js +240 -0
  100. package/dist/src/core/snapshots.d.ts +32 -0
  101. package/dist/src/core/snapshots.js +35 -0
  102. package/dist/src/core/spaces.d.ts +66 -0
  103. package/dist/src/core/spaces.js +252 -0
  104. package/dist/src/core/store.d.ts +577 -0
  105. package/dist/src/core/store.js +2119 -0
  106. package/dist/src/core/task-history.d.ts +7 -0
  107. package/dist/src/core/task-history.js +41 -0
  108. package/dist/src/core/tasks.d.ts +58 -0
  109. package/dist/src/core/tasks.js +389 -0
  110. package/dist/src/core/test-tasks.d.ts +19 -0
  111. package/dist/src/core/test-tasks.js +135 -0
  112. package/dist/src/core/transcription.d.ts +45 -0
  113. package/dist/src/core/transcription.js +92 -0
  114. package/dist/src/core/watchers.d.ts +30 -0
  115. package/dist/src/core/watchers.js +50 -0
  116. package/dist/src/deploy/env-render.d.ts +26 -0
  117. package/dist/src/deploy/env-render.js +98 -0
  118. package/dist/src/deploy/templates.d.ts +38 -0
  119. package/dist/src/deploy/templates.js +130 -0
  120. package/dist/src/gui-api.d.ts +37 -0
  121. package/dist/src/gui-api.js +383 -0
  122. package/dist/src/index.d.ts +2 -0
  123. package/dist/src/index.js +110 -0
  124. package/dist/src/mcp/client.d.ts +38 -0
  125. package/dist/src/mcp/client.js +106 -0
  126. package/dist/src/mcp/notifications.d.ts +13 -0
  127. package/dist/src/mcp/notifications.js +54 -0
  128. package/dist/src/mcp/start.d.ts +14 -0
  129. package/dist/src/mcp/start.js +123 -0
  130. package/dist/src/mcp/tools.d.ts +46 -0
  131. package/dist/src/mcp/tools.js +348 -0
  132. package/dist/src/mcp/watch-handoff.d.ts +35 -0
  133. package/dist/src/mcp/watch-handoff.js +91 -0
  134. package/dist/src/server/app.d.ts +25 -0
  135. package/dist/src/server/app.js +305 -0
  136. package/dist/src/server/config.d.ts +23 -0
  137. package/dist/src/server/config.js +75 -0
  138. package/dist/src/server/limits.d.ts +2 -0
  139. package/dist/src/server/limits.js +4 -0
  140. package/dist/src/server/login-limiter.d.ts +36 -0
  141. package/dist/src/server/login-limiter.js +81 -0
  142. package/dist/src/server/start.d.ts +5 -0
  143. package/dist/src/server/start.js +88 -0
  144. package/dist/src/version.d.ts +1 -0
  145. package/dist/src/version.js +16 -0
  146. package/gui/dist/assets/index-BVN0agCc.css +1 -0
  147. package/gui/dist/assets/index-DI1_kr81.js +70 -0
  148. package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  149. package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  150. package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  151. package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  152. package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  153. package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  154. package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  155. package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  156. package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  157. package/gui/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  158. package/gui/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  159. package/gui/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  160. package/gui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  161. package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  162. package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  163. package/gui/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  164. package/gui/dist/index.html +13 -0
  165. package/package.json +66 -4
@@ -0,0 +1,62 @@
1
+ import { MAX_WAIT_SECONDS } from "../../shared/schemas/agent-tools.schema.js";
2
+ import { ConfigError } from "../core/errors.js";
3
+ import { OrganizerSiteClient, SiteError } from "../mcp/client.js";
4
+ import { readHandoff } from "../mcp/watch-handoff.js";
5
+ /**
6
+ * Mode veilleur (`--watch <identifiant du jeton>`).
7
+ *
8
+ * Une ligne sur stdout = une demande passée en attente. Ce flux est consommé
9
+ * par l'outil `Monitor` de l'hôte, qui réveille la session de l'agent à chaque
10
+ * ligne, y compris quand elle est inactive. Le journal part sur stderr.
11
+ *
12
+ * Une panne réseau ou du site est réessayée avec un délai croissant. Un refus
13
+ * du site (jeton révoqué, expiré, route inconnue) arrête le veilleur après une
14
+ * dernière ligne qui en donne la raison.
15
+ */
16
+ const BACKOFF_MIN_MS = 1_000;
17
+ const BACKOFF_MAX_MS = 30_000;
18
+ /** Longueur maximale de l'extrait du texte de la demande. */
19
+ const EXCERPT_LENGTH = 120;
20
+ /** Ligne d'une demande : une seule ligne, lisible telle quelle dans la notification. */
21
+ export function formatRequestEvent(request) {
22
+ const text = request.text.replace(/\s+/g, " ").trim();
23
+ const excerpt = text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 1)}…` : text;
24
+ const context = request.contextTask ? ` · ${request.contextTask.key}` : "";
25
+ return `[organizer] ${request.kind} · ${request.author.name} — "${excerpt}"${context} (${request.id})`;
26
+ }
27
+ /** Refus définitif du site : un nouvel essai donnerait la même réponse. */
28
+ function isRefusal(error) {
29
+ return error instanceof SiteError && error.status !== undefined && error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429;
30
+ }
31
+ export async function runWatchMode(tokenId) {
32
+ if (!tokenId || tokenId.startsWith("--")) {
33
+ throw new ConfigError("--watch needs the token id given by the enable_notifications tool: `mcp-organizer --watch <token id>`.");
34
+ }
35
+ const handoff = readHandoff(tokenId);
36
+ if (!handoff) {
37
+ throw new ConfigError(`No notification handoff for token ${tokenId}: call the enable_notifications tool of the organizer MCP server, then arm the watcher with the command it returns.`);
38
+ }
39
+ const client = new OrganizerSiteClient({ url: handoff.url, token: handoff.token });
40
+ process.stderr.write(`watching token ${tokenId} on ${handoff.url}\n`);
41
+ let since;
42
+ let backoff = BACKOFF_MIN_MS;
43
+ for (;;) {
44
+ try {
45
+ const result = await client.waitForRequests({ timeout_seconds: MAX_WAIT_SECONDS, since, watcher: true });
46
+ backoff = BACKOFF_MIN_MS;
47
+ since = result.cursor;
48
+ for (const request of result.requests)
49
+ process.stdout.write(`${formatRequestEvent(request)}\n`);
50
+ }
51
+ catch (error) {
52
+ const detail = error instanceof Error ? error.message : String(error);
53
+ if (isRefusal(error)) {
54
+ process.stdout.write(`[organizer] notifications stopped: ${detail.replace(/\s+/g, " ")}\n`);
55
+ return 1;
56
+ }
57
+ process.stderr.write(`wait failed, retrying in ${Math.round(backoff / 1000)}s: ${detail}\n`);
58
+ await new Promise((resolve) => setTimeout(resolve, backoff));
59
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,129 @@
1
+ import type { Mutation, Store } from "./store.js";
2
+ import { type AccessLink, type User } from "../../shared/schemas/entities.schema.js";
3
+ import type { AccessLinkPurpose } from "../../shared/schemas/enums.schema.js";
4
+ /** Durée d'une session sans activité. */
5
+ export declare const SESSION_TTL_MS: number;
6
+ /** Intervalle minimal entre deux prolongations d'une même session. */
7
+ export declare const SESSION_REFRESH_INTERVAL_MS: number;
8
+ /** Durée de validité d'un lien d'invitation ou de réinitialisation. */
9
+ export declare const ACCESS_LINK_TTL_MS: number;
10
+ export declare function hashPassword(password: string): Promise<string>;
11
+ /** Vérifie un mot de passe ; un compte sans mot de passe et un hash illisible sont refusés. */
12
+ export declare function verifyPassword(hash: string | null, password: string): Promise<boolean>;
13
+ /** Jeton opaque de 32 octets, en base64url. */
14
+ export declare function newToken(): string;
15
+ /** Hash sha256 (hexadécimal) sous lequel un jeton est stocké. */
16
+ export declare function hashToken(token: string): string;
17
+ /** Adresse validée et normalisée, ou erreur explicite. */
18
+ export declare function normalizeEmail(email: string): string;
19
+ /** Nom affiché validé (non vide, longueur bornée), ou erreur explicite. */
20
+ export declare function normalizeDisplayName(name: string): string;
21
+ /**
22
+ * Rend un compte actif : nom affiché et mot de passe posés, espace personnel
23
+ * créé s'il manque. Les invitations en attente du compte restent en attente :
24
+ * il les accepte ou les refuse une fois connecté.
25
+ */
26
+ export declare function activateAccount(tx: Mutation, user: User, input: {
27
+ displayName: string;
28
+ passwordHash: string;
29
+ }): User;
30
+ /** Accepte une invitation en attente pour le compte `userId`, qui rejoint l'espace. */
31
+ export declare function joinInvitedSpace(tx: Mutation, userId: string, invitationId: string): void;
32
+ /** Crée l'espace personnel d'un compte s'il n'en a pas. */
33
+ export declare function ensurePersonalSpace(tx: Mutation, userId: string): void;
34
+ export interface BootstrapResult {
35
+ /** `created` : compte créé ; `exists` : des comptes existent ; `missing` : aucun compte ni variable. */
36
+ outcome: "created" | "exists" | "missing";
37
+ message: string;
38
+ }
39
+ /**
40
+ * Crée le premier compte depuis les variables d'environnement, si et
41
+ * seulement si la table est vide. Une adresse et un mot de passe sans nom
42
+ * affiché sont refusés.
43
+ */
44
+ export declare function bootstrapAdmin(store: Store, credentials: {
45
+ email?: string;
46
+ displayName?: string;
47
+ password?: string;
48
+ }): Promise<BootstrapResult>;
49
+ /** Compte à créer ou à réactiver. `displayName` est requis pour un compte qui n'en a pas encore. */
50
+ export interface SetAccountInput {
51
+ email: string;
52
+ password: string;
53
+ displayName?: string;
54
+ }
55
+ /** Message d'un compte créé sans nom affiché. */
56
+ export declare const DISPLAY_NAME_REQUIRED_MESSAGE = "Le nom affich\u00E9 du compte est requis.";
57
+ /**
58
+ * Crée un compte actif, ou remplace le mot de passe d'un compte existant, qui
59
+ * redevient actif (et prend le nom affiché donné). Les sessions ouvertes du
60
+ * compte sont fermées.
61
+ */
62
+ export declare function setAccount(store: Store, input: SetAccountInput): Promise<{
63
+ created: boolean;
64
+ user: User;
65
+ }>;
66
+ /** Change le nom affiché d'un compte. */
67
+ export declare function updateDisplayName(store: Store, userId: string, displayName: string): User;
68
+ /**
69
+ * Change le mot de passe d'un compte après vérification de l'ancien. Les
70
+ * autres sessions du compte sont fermées ; celle de `keepSessionToken` reste ouverte.
71
+ */
72
+ export declare function changePassword(store: Store, userId: string, input: {
73
+ currentPassword: string;
74
+ newPassword: string;
75
+ }, keepSessionToken: string | null): Promise<void>;
76
+ /** Session ouverte : le jeton à placer dans le cookie et sa fin de validité. */
77
+ export interface IssuedSession {
78
+ token: string;
79
+ expiresAt: string;
80
+ }
81
+ /** Ouvre une session pour `userId` dans la mutation en cours. */
82
+ export declare function openSession(tx: Mutation, userId: string, now: Date): IssuedSession;
83
+ /** Vérifie e-mail et mot de passe et ouvre une session. */
84
+ export declare function login(store: Store, email: string, password: string, now?: Date): Promise<{
85
+ user: User;
86
+ session: IssuedSession;
87
+ }>;
88
+ /**
89
+ * Utilisateur d'une session valide, ou null. Une session expirée, ou d'un
90
+ * compte qui n'est plus actif, est supprimée. L'expiration glisse : une
91
+ * session utilisée est prolongée (au plus une fois par
92
+ * `SESSION_REFRESH_INTERVAL_MS`), et `refreshed` porte alors le cookie à renvoyer.
93
+ */
94
+ export declare function authenticate(store: Store, token: string | null, now?: Date): {
95
+ user: User;
96
+ refreshed: IssuedSession | null;
97
+ } | null;
98
+ /** Ferme la session du jeton, si elle existe. */
99
+ export declare function logout(store: Store, token: string | null): void;
100
+ /** Lien émis : le jeton en clair, qui n'est montré qu'une fois. */
101
+ export interface IssuedAccessLink {
102
+ token: string;
103
+ expiresAt: string;
104
+ }
105
+ /** Chemin de la GUI qui ouvre l'écran d'activation d'un lien. */
106
+ export declare function accessLinkPath(token: string): string;
107
+ /**
108
+ * Émet un lien pour un compte, dans la mutation en cours. Un lien
109
+ * d'invitation est rattaché à `invitationId` et remplace le lien précédent de
110
+ * cette invitation ; les liens des autres invitations restent valables.
111
+ */
112
+ export declare function issueAccessLink(tx: Mutation, userId: string, purpose: AccessLinkPurpose, invitationId: string | null, now: Date): IssuedAccessLink;
113
+ /** Lien valide et son compte ; sinon une erreur qui dit pourquoi le lien ne fonctionne pas. */
114
+ export declare function resolveAccessLink(store: Store, token: string, now?: Date): {
115
+ link: AccessLink;
116
+ user: User;
117
+ };
118
+ /**
119
+ * Consomme un lien : nom affiché et mot de passe posés, compte activé (espace
120
+ * personnel créé), session ouverte. Un lien d'invitation accepte l'invitation
121
+ * qui l'a émis, et elle seule.
122
+ */
123
+ export declare function redeemAccessLink(store: Store, token: string, input: {
124
+ displayName: string;
125
+ password: string;
126
+ }, now?: Date): Promise<{
127
+ user: User;
128
+ session: IssuedSession;
129
+ }>;
@@ -0,0 +1,284 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import argon2 from "argon2";
3
+ import { InvariantError, NotFoundError, OrganizerError, UnauthorizedError } from "./errors.js";
4
+ import { newId } from "./ids.js";
5
+ import { DisplayNameSchema, MIN_PASSWORD_LENGTH } from "../../shared/schemas/api.schema.js";
6
+ import { EmailSchema } from "../../shared/schemas/entities.schema.js";
7
+ import { PERSONAL_KEY_PREFIX, PERSONAL_SPACE_NAME } from "../../shared/logic/spaces.js";
8
+ import { NO_ACCOUNT_MESSAGE } from "../../shared/logic/first-account.js";
9
+ /**
10
+ * Comptes, mots de passe, sessions et liens d'accès.
11
+ *
12
+ * Les comptes se créent sur invitation. Le premier compte vient des variables
13
+ * `ORGANIZER_ADMIN_EMAIL` / `ORGANIZER_ADMIN_NAME` / `ORGANIZER_ADMIN_PASSWORD` (au démarrage, si la
14
+ * table est vide) ou de la commande `--set-user`. Les mots de passe sont hachés
15
+ * en argon2id ; les jetons de session et de lien ne sont stockés que hachés
16
+ * (sha256).
17
+ */
18
+ /**
19
+ * Paramètres argon2id (recommandation OWASP : 19 Mio, 2 passes, 1 fil), fixés
20
+ * explicitement pour qu'une mise à jour de la bibliothèque ne change pas le coût.
21
+ */
22
+ const ARGON2_OPTIONS = {
23
+ type: argon2.argon2id,
24
+ memoryCost: 19_456,
25
+ timeCost: 2,
26
+ parallelism: 1,
27
+ };
28
+ /** Durée d'une session sans activité. */
29
+ export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
30
+ /** Intervalle minimal entre deux prolongations d'une même session. */
31
+ export const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
32
+ /** Durée de validité d'un lien d'invitation ou de réinitialisation. */
33
+ export const ACCESS_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1000;
34
+ const BAD_CREDENTIALS_MESSAGE = "Adresse e-mail ou mot de passe incorrect.";
35
+ export async function hashPassword(password) {
36
+ if (password.length < MIN_PASSWORD_LENGTH) {
37
+ throw new OrganizerError(`Le mot de passe doit faire au moins ${MIN_PASSWORD_LENGTH} caractères.`);
38
+ }
39
+ return argon2.hash(password, ARGON2_OPTIONS);
40
+ }
41
+ /** Vérifie un mot de passe ; un compte sans mot de passe et un hash illisible sont refusés. */
42
+ export async function verifyPassword(hash, password) {
43
+ if (hash === null)
44
+ return false;
45
+ try {
46
+ return await argon2.verify(hash, password);
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /** Hash d'un mot de passe arbitraire, vérifié quand l'adresse est inconnue pour garder un temps de réponse constant. */
53
+ let decoyHash = null;
54
+ function decoy() {
55
+ decoyHash ??= argon2.hash(randomBytes(16).toString("hex"), ARGON2_OPTIONS);
56
+ return decoyHash;
57
+ }
58
+ /** Jeton opaque de 32 octets, en base64url. */
59
+ export function newToken() {
60
+ return randomBytes(32).toString("base64url");
61
+ }
62
+ /** Hash sha256 (hexadécimal) sous lequel un jeton est stocké. */
63
+ export function hashToken(token) {
64
+ return createHash("sha256").update(token, "utf8").digest("hex");
65
+ }
66
+ /** Adresse validée et normalisée, ou erreur explicite. */
67
+ export function normalizeEmail(email) {
68
+ const parsed = EmailSchema.safeParse(email);
69
+ if (!parsed.success)
70
+ throw new OrganizerError(`Adresse e-mail invalide : « ${email} ».`);
71
+ return parsed.data;
72
+ }
73
+ /** Nom affiché validé (non vide, longueur bornée), ou erreur explicite. */
74
+ export function normalizeDisplayName(name) {
75
+ const parsed = DisplayNameSchema.safeParse(name);
76
+ if (!parsed.success)
77
+ throw new OrganizerError(parsed.error.issues.map((issue) => issue.message).join(" "));
78
+ return parsed.data;
79
+ }
80
+ // --- Activation ---------------------------------------------------------------
81
+ /**
82
+ * Rend un compte actif : nom affiché et mot de passe posés, espace personnel
83
+ * créé s'il manque. Les invitations en attente du compte restent en attente :
84
+ * il les accepte ou les refuse une fois connecté.
85
+ */
86
+ export function activateAccount(tx, user, input) {
87
+ const active = tx.updateUser(user.id, { displayName: input.displayName, passwordHash: input.passwordHash, status: "active" });
88
+ ensurePersonalSpace(tx, user.id);
89
+ return active;
90
+ }
91
+ /** Accepte une invitation en attente pour le compte `userId`, qui rejoint l'espace. */
92
+ export function joinInvitedSpace(tx, userId, invitationId) {
93
+ const invitation = tx.acceptInvitation(invitationId);
94
+ if (!tx.store.isMember(invitation.spaceId, userId))
95
+ tx.addMember(invitation.spaceId, userId);
96
+ }
97
+ /** Crée l'espace personnel d'un compte s'il n'en a pas. */
98
+ export function ensurePersonalSpace(tx, userId) {
99
+ if (tx.store.findPersonalSpace(userId))
100
+ return;
101
+ tx.createSpace({ name: PERSONAL_SPACE_NAME, kind: "personal", ownerId: userId, keyPrefix: PERSONAL_KEY_PREFIX });
102
+ }
103
+ /**
104
+ * Crée le premier compte depuis les variables d'environnement, si et
105
+ * seulement si la table est vide. Une adresse et un mot de passe sans nom
106
+ * affiché sont refusés.
107
+ */
108
+ export async function bootstrapAdmin(store, credentials) {
109
+ const count = store.countUsers();
110
+ if (count > 0)
111
+ return { outcome: "exists", message: `${count} account(s) in the database.` };
112
+ const email = credentials.email?.trim();
113
+ const password = credentials.password;
114
+ if (!email || !password) {
115
+ return {
116
+ outcome: "missing",
117
+ message: "No account exists: the GUI refuses every login. Create the first one with ORGANIZER_ADMIN_EMAIL, ORGANIZER_ADMIN_NAME and ORGANIZER_ADMIN_PASSWORD, " +
118
+ 'or with: node dist/src/index.js --set-user <email> --name="First Last" (cloned site) or npx -y @imenam/mcp-organizer --set-user <email> --name="First Last" (npm package)',
119
+ };
120
+ }
121
+ const displayName = credentials.displayName?.trim();
122
+ if (!displayName)
123
+ throw new OrganizerError("ORGANIZER_ADMIN_NAME is not set: give the display name of the first account (for example: Matthieu Pin).");
124
+ const result = await setAccount(store, { email, password, displayName });
125
+ return { outcome: "created", message: `First account ${result.user.email} created from ORGANIZER_ADMIN_EMAIL / ORGANIZER_ADMIN_NAME / ORGANIZER_ADMIN_PASSWORD.` };
126
+ }
127
+ /** Message d'un compte créé sans nom affiché. */
128
+ export const DISPLAY_NAME_REQUIRED_MESSAGE = "Le nom affiché du compte est requis.";
129
+ /**
130
+ * Crée un compte actif, ou remplace le mot de passe d'un compte existant, qui
131
+ * redevient actif (et prend le nom affiché donné). Les sessions ouvertes du
132
+ * compte sont fermées.
133
+ */
134
+ export async function setAccount(store, input) {
135
+ const normalized = normalizeEmail(input.email);
136
+ const displayName = input.displayName === undefined ? undefined : normalizeDisplayName(input.displayName);
137
+ const passwordHash = await hashPassword(input.password);
138
+ const userId = store.findUserByEmail(normalized)?.id ?? newId();
139
+ return store.mutate({ type: "user", userId }, (tx) => {
140
+ const existing = tx.store.findUserByEmail(normalized);
141
+ const name = displayName ?? existing?.displayName;
142
+ if (!name)
143
+ throw new OrganizerError(DISPLAY_NAME_REQUIRED_MESSAGE);
144
+ if (existing) {
145
+ const user = activateAccount(tx, existing, { displayName: name, passwordHash });
146
+ tx.deleteSessionsOf(user.id);
147
+ tx.discardUnusedAccessLinks(user.id);
148
+ return { created: false, user };
149
+ }
150
+ const created = tx.createUser({ id: userId, email: normalized, displayName: name, passwordHash, status: "active" });
151
+ ensurePersonalSpace(tx, created.id);
152
+ return { created: true, user: created };
153
+ });
154
+ }
155
+ // --- Profil ---------------------------------------------------------------------
156
+ /** Change le nom affiché d'un compte. */
157
+ export function updateDisplayName(store, userId, displayName) {
158
+ const name = normalizeDisplayName(displayName);
159
+ return store.mutate({ type: "user", userId }, (tx) => tx.updateUser(userId, { displayName: name }));
160
+ }
161
+ /**
162
+ * Change le mot de passe d'un compte après vérification de l'ancien. Les
163
+ * autres sessions du compte sont fermées ; celle de `keepSessionToken` reste ouverte.
164
+ */
165
+ export async function changePassword(store, userId, input, keepSessionToken) {
166
+ const user = store.getUser(userId);
167
+ if (!(await verifyPassword(user.passwordHash, input.currentPassword)))
168
+ throw new OrganizerError("Le mot de passe actuel est incorrect.");
169
+ const passwordHash = await hashPassword(input.newPassword);
170
+ store.mutate({ type: "user", userId }, (tx) => {
171
+ tx.updateUser(userId, { passwordHash });
172
+ tx.deleteSessionsOf(userId, keepSessionToken === null ? null : hashToken(keepSessionToken));
173
+ });
174
+ }
175
+ /** Ouvre une session pour `userId` dans la mutation en cours. */
176
+ export function openSession(tx, userId, now) {
177
+ const token = newToken();
178
+ const expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString();
179
+ tx.deleteExpiredSessions(now.toISOString());
180
+ tx.createSession({ idHash: hashToken(token), userId, createdAt: now.toISOString(), expiresAt });
181
+ return { token, expiresAt };
182
+ }
183
+ /** Vérifie e-mail et mot de passe et ouvre une session. */
184
+ export async function login(store, email, password, now = new Date()) {
185
+ if (store.countUsers() === 0)
186
+ throw new UnauthorizedError(NO_ACCOUNT_MESSAGE);
187
+ const user = store.findUserByEmail(email);
188
+ if (!user || user.passwordHash === null) {
189
+ await verifyPassword(await decoy(), password);
190
+ throw new UnauthorizedError(BAD_CREDENTIALS_MESSAGE);
191
+ }
192
+ if (!(await verifyPassword(user.passwordHash, password)))
193
+ throw new UnauthorizedError(BAD_CREDENTIALS_MESSAGE);
194
+ if (user.status !== "active")
195
+ throw new UnauthorizedError("Ce compte n'est pas actif.");
196
+ const session = store.mutate({ type: "user", userId: user.id }, (tx) => openSession(tx, user.id, now));
197
+ return { user, session };
198
+ }
199
+ /**
200
+ * Utilisateur d'une session valide, ou null. Une session expirée, ou d'un
201
+ * compte qui n'est plus actif, est supprimée. L'expiration glisse : une
202
+ * session utilisée est prolongée (au plus une fois par
203
+ * `SESSION_REFRESH_INTERVAL_MS`), et `refreshed` porte alors le cookie à renvoyer.
204
+ */
205
+ export function authenticate(store, token, now = new Date()) {
206
+ if (!token)
207
+ return null;
208
+ const idHash = hashToken(token);
209
+ const session = store.findSessionByHash(idHash);
210
+ if (!session)
211
+ return null;
212
+ const user = store.findUser(session.userId);
213
+ if (!user || user.status !== "active" || Date.parse(session.expiresAt) <= now.getTime()) {
214
+ store.mutate({ type: "user", userId: session.userId }, (tx) => tx.deleteSession(idHash));
215
+ return null;
216
+ }
217
+ const remaining = Date.parse(session.expiresAt) - now.getTime();
218
+ if (remaining > SESSION_TTL_MS - SESSION_REFRESH_INTERVAL_MS)
219
+ return { user, refreshed: null };
220
+ const expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString();
221
+ store.mutate({ type: "user", userId: user.id }, (tx) => tx.extendSession(idHash, expiresAt));
222
+ return { user, refreshed: { token, expiresAt } };
223
+ }
224
+ /** Ferme la session du jeton, si elle existe. */
225
+ export function logout(store, token) {
226
+ if (!token)
227
+ return;
228
+ const session = store.findSessionByHash(hashToken(token));
229
+ if (!session)
230
+ return;
231
+ store.mutate({ type: "user", userId: session.userId }, (tx) => tx.deleteSession(session.idHash));
232
+ }
233
+ /** Chemin de la GUI qui ouvre l'écran d'activation d'un lien. */
234
+ export function accessLinkPath(token) {
235
+ return `#/activation/${token}`;
236
+ }
237
+ /**
238
+ * Émet un lien pour un compte, dans la mutation en cours. Un lien
239
+ * d'invitation est rattaché à `invitationId` et remplace le lien précédent de
240
+ * cette invitation ; les liens des autres invitations restent valables.
241
+ */
242
+ export function issueAccessLink(tx, userId, purpose, invitationId, now) {
243
+ const token = newToken();
244
+ const expiresAt = new Date(now.getTime() + ACCESS_LINK_TTL_MS).toISOString();
245
+ tx.createAccessLink({ userId, purpose, invitationId, tokenHash: hashToken(token), expiresAt });
246
+ return { token, expiresAt };
247
+ }
248
+ /** Lien valide et son compte ; sinon une erreur qui dit pourquoi le lien ne fonctionne pas. */
249
+ export function resolveAccessLink(store, token, now = new Date()) {
250
+ const link = store.findAccessLinkByHash(hashToken(token));
251
+ if (!link) {
252
+ throw new NotFoundError("Ce lien n'est pas valide : il a été révoqué ou remplacé par un lien plus récent. Demandez un nouveau lien à la personne qui vous a invité.");
253
+ }
254
+ if (link.usedAt !== null)
255
+ throw new InvariantError("Ce lien a déjà été utilisé. Connectez-vous avec votre adresse e-mail et votre mot de passe.");
256
+ if (Date.parse(link.expiresAt) <= now.getTime()) {
257
+ throw new InvariantError("Ce lien a expiré. Demandez un nouveau lien à la personne qui vous a invité.");
258
+ }
259
+ const user = store.getUser(link.userId);
260
+ if (user.status === "disabled")
261
+ throw new InvariantError("Ce compte est désactivé.");
262
+ if (link.purpose === "invite" && user.status !== "invited") {
263
+ throw new InvariantError("Ce compte est déjà activé : connectez-vous avec votre adresse e-mail et votre mot de passe. L'invitation vous attend dans « Invitations reçues », dans le menu de l'espace.");
264
+ }
265
+ return { link, user };
266
+ }
267
+ /**
268
+ * Consomme un lien : nom affiché et mot de passe posés, compte activé (espace
269
+ * personnel créé), session ouverte. Un lien d'invitation accepte l'invitation
270
+ * qui l'a émis, et elle seule.
271
+ */
272
+ export async function redeemAccessLink(store, token, input, now = new Date()) {
273
+ const { user: owner } = resolveAccessLink(store, token, now);
274
+ const passwordHash = await hashPassword(input.password);
275
+ return store.mutate({ type: "user", userId: owner.id }, (tx) => {
276
+ const { link, user } = resolveAccessLink(tx.store, token, now);
277
+ tx.markAccessLinkUsed(link.id);
278
+ const active = activateAccount(tx, user, { displayName: input.displayName, passwordHash });
279
+ if (link.invitationId !== null)
280
+ joinInvitedSpace(tx, user.id, link.invitationId);
281
+ tx.deleteSessionsOf(user.id);
282
+ return { user: active, session: openSession(tx, user.id, now) };
283
+ });
284
+ }
@@ -0,0 +1,31 @@
1
+ import type { Store } from "./store.js";
2
+ import type { AgentToken, Space, User } from "../../shared/schemas/entities.schema.js";
3
+ import { type AgentTokenView, type CreateTokenRequest, type CreatedTokenResponse, type TokenListResponse, type TokenState } from "../../shared/schemas/tokens-api.schema.js";
4
+ /** Nouveau jeton d'agent en clair. */
5
+ export declare function generateAgentToken(): string;
6
+ /** Hash sha256 (hexadécimal) d'un jeton. */
7
+ export declare function hashAgentToken(token: string): string;
8
+ /** État d'un jeton à l'instant `now` ; la révocation l'emporte sur l'expiration, l'expiration sur la perte d'accès. */
9
+ export declare function tokenState(store: Store, token: AgentToken, now: Date): TokenState;
10
+ /** Jetons de l'utilisateur : actifs d'abord, puis du plus récent au plus ancien. */
11
+ export declare function listAgentTokens(store: Store, userId: string, now?: Date): TokenListResponse;
12
+ /**
13
+ * Crée un jeton de l'utilisateur sur un espace dont il est membre ; renvoie
14
+ * sa valeur, montrée une seule fois, et l'entrée `.mcp.json` du mode MCP
15
+ * relié au site `publicUrl`.
16
+ */
17
+ export declare function createAgentToken(store: Store, userId: string, input: CreateTokenRequest, publicUrl: string, now?: Date): CreatedTokenResponse;
18
+ /** Révoque un jeton de l'utilisateur ; un jeton d'un autre est introuvable. */
19
+ export declare function revokeAgentToken(store: Store, userId: string, tokenId: string, now?: Date): AgentTokenView;
20
+ /** Jeton résolu par l'API agent : le jeton, son propriétaire et l'espace couvert. */
21
+ export interface ResolvedAgentToken {
22
+ token: AgentToken;
23
+ user: User;
24
+ space: Space;
25
+ }
26
+ /**
27
+ * Résout le jeton présenté par un agent et note son usage. Un jeton absent,
28
+ * inconnu, révoqué ou expiré est refusé (401) ; un propriétaire inactif ou
29
+ * qui n'est plus membre de l'espace couvert aussi (403).
30
+ */
31
+ export declare function resolveAgentToken(store: Store, raw: string | null, now?: Date): ResolvedAgentToken;
@@ -0,0 +1,120 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { ForbiddenError, NotFoundError, UnauthorizedError } from "./errors.js";
3
+ import { requireMember } from "./spaces.js";
4
+ import { buildAgentEntry, mcpJsonText } from "./mcp-entry.js";
5
+ import { AGENT_TOKEN_PREFIX, } from "../../shared/schemas/tokens-api.schema.js";
6
+ /**
7
+ * Jetons d'agent. Un utilisateur crée un jeton pour un espace dont il est
8
+ * membre ; le jeton (`org_` + 32 octets aléatoires en base64url) n'est
9
+ * montré qu'à sa création, seul son hash sha256 est stocké. À chaque appel
10
+ * de l'API agent, le jeton est résolu : non révoqué, non expiré, propriétaire
11
+ * actif et toujours membre de l'espace couvert. Quitter un espace, ou en être
12
+ * retiré, révoque les jetons du membre sur cet espace.
13
+ */
14
+ /** Nombre de caractères aléatoires gardés dans le préfixe affiché d'un jeton. */
15
+ const PREFIX_RANDOM_CHARS = 8;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const asUser = (userId) => ({ type: "user", userId });
18
+ /** Nouveau jeton d'agent en clair. */
19
+ export function generateAgentToken() {
20
+ return `${AGENT_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
21
+ }
22
+ /** Hash sha256 (hexadécimal) d'un jeton. */
23
+ export function hashAgentToken(token) {
24
+ return createHash("sha256").update(token, "utf8").digest("hex");
25
+ }
26
+ /** État d'un jeton à l'instant `now` ; la révocation l'emporte sur l'expiration, l'expiration sur la perte d'accès. */
27
+ export function tokenState(store, token, now) {
28
+ if (token.revokedAt !== null)
29
+ return "revoked";
30
+ if (token.expiresAt !== null && Date.parse(token.expiresAt) <= now.getTime())
31
+ return "expired";
32
+ const user = store.findUser(token.userId);
33
+ if (!user || user.status !== "active" || !store.isMember(token.spaceId, token.userId))
34
+ return "inactive";
35
+ return "active";
36
+ }
37
+ function tokenView(store, token, now) {
38
+ const space = store.getSpace(token.spaceId);
39
+ return {
40
+ id: token.id,
41
+ name: token.name,
42
+ prefix: token.prefix,
43
+ space: { id: space.id, name: space.name, kind: space.kind },
44
+ state: tokenState(store, token, now),
45
+ createdAt: token.createdAt,
46
+ lastUsedAt: token.lastUsedAt,
47
+ expiresAt: token.expiresAt,
48
+ revokedAt: token.revokedAt,
49
+ };
50
+ }
51
+ const STATE_ORDER = { active: 0, inactive: 1, expired: 2, revoked: 3 };
52
+ /** Jetons de l'utilisateur : actifs d'abord, puis du plus récent au plus ancien. */
53
+ export function listAgentTokens(store, userId, now = new Date()) {
54
+ const tokens = store.listAgentTokensOf(userId).map((token) => tokenView(store, token, now));
55
+ tokens.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || b.createdAt.localeCompare(a.createdAt));
56
+ return { tokens };
57
+ }
58
+ /**
59
+ * Crée un jeton de l'utilisateur sur un espace dont il est membre ; renvoie
60
+ * sa valeur, montrée une seule fois, et l'entrée `.mcp.json` du mode MCP
61
+ * relié au site `publicUrl`.
62
+ */
63
+ export function createAgentToken(store, userId, input, publicUrl, now = new Date()) {
64
+ const value = generateAgentToken();
65
+ const token = store.mutate(asUser(userId), (tx) => {
66
+ requireMember(tx.store, input.spaceId, userId);
67
+ return tx.createAgentToken({
68
+ spaceId: input.spaceId,
69
+ name: input.name,
70
+ prefix: value.slice(0, AGENT_TOKEN_PREFIX.length + PREFIX_RANDOM_CHARS),
71
+ tokenHash: hashAgentToken(value),
72
+ expiresAt: input.expiresInDays === null ? null : new Date(now.getTime() + input.expiresInDays * DAY_MS).toISOString(),
73
+ });
74
+ });
75
+ return { token: tokenView(store, token, now), value, mcpJson: mcpJsonText(buildAgentEntry({ url: publicUrl, token: value })) };
76
+ }
77
+ /** Révoque un jeton de l'utilisateur ; un jeton d'un autre est introuvable. */
78
+ export function revokeAgentToken(store, userId, tokenId, now = new Date()) {
79
+ const token = store.mutate(asUser(userId), (tx) => {
80
+ const found = tx.store.findAgentToken(tokenId);
81
+ if (!found || found.userId !== userId)
82
+ throw new NotFoundError("Jeton introuvable.");
83
+ return tx.revokeAgentToken(tokenId);
84
+ });
85
+ return tokenView(store, token, now);
86
+ }
87
+ /** Marche à suivre pour obtenir un jeton valide, rappelée dans les refus. */
88
+ const NEW_TOKEN_HINT = "Create a token in the organizer web app (Paramètres > Agents) and set it as ORGANIZER_TOKEN.";
89
+ /**
90
+ * Résout le jeton présenté par un agent et note son usage. Un jeton absent,
91
+ * inconnu, révoqué ou expiré est refusé (401) ; un propriétaire inactif ou
92
+ * qui n'est plus membre de l'espace couvert aussi (403).
93
+ */
94
+ export function resolveAgentToken(store, raw, now = new Date()) {
95
+ if (raw === null || raw.trim() === "") {
96
+ throw new UnauthorizedError(`Missing agent token: send "Authorization: Bearer org_…". ${NEW_TOKEN_HINT}`);
97
+ }
98
+ const value = raw.trim();
99
+ if (!value.startsWith(AGENT_TOKEN_PREFIX))
100
+ throw new UnauthorizedError(`This is not an organizer agent token (they start with "${AGENT_TOKEN_PREFIX}"). ${NEW_TOKEN_HINT}`);
101
+ const token = store.findAgentTokenByHash(hashAgentToken(value));
102
+ if (!token)
103
+ throw new UnauthorizedError(`Unknown agent token. ${NEW_TOKEN_HINT}`);
104
+ if (token.revokedAt !== null) {
105
+ throw new UnauthorizedError(`Agent token "${token.name}" was revoked on ${token.revokedAt.slice(0, 10)}, by its owner or because its owner left or was removed from the space it covers. ${NEW_TOKEN_HINT}`);
106
+ }
107
+ if (token.expiresAt !== null && Date.parse(token.expiresAt) <= now.getTime()) {
108
+ throw new UnauthorizedError(`Agent token "${token.name}" expired on ${token.expiresAt.slice(0, 10)}. ${NEW_TOKEN_HINT}`);
109
+ }
110
+ const user = store.getUser(token.userId);
111
+ if (user.status !== "active")
112
+ throw new ForbiddenError(`The account that owns agent token "${token.name}" is not active: the token cannot be used.`);
113
+ const space = store.getSpace(token.spaceId);
114
+ if (!store.isMember(space.id, user.id)) {
115
+ throw new ForbiddenError(`The owner of agent token "${token.name}" is no longer a member of space "${space.name}": the token cannot be used. ${NEW_TOKEN_HINT}`);
116
+ }
117
+ const usedAt = now.toISOString();
118
+ store.mutate(asUser(user.id), (tx) => tx.recordAgentTokenUse(token.id, usedAt));
119
+ return { token: { ...token, lastUsedAt: usedAt }, user, space };
120
+ }