Nicolas Cantu 61cec6f430 Sync ia_dev: token resolution via .secrets/<env>/ia_token, doc updates
**Motivations:**
- Align master with current codebase (token from projects/<id>/.secrets/<env>/ia_token)
- Id resolution by mail To or by API token; no slug

**Root causes:**
- Token moved from conf.json to .secrets/<env>/ia_token; env from directory name

**Correctifs:**
- Server and scripts resolve project+env by scanning all projects and envs

**Evolutions:**
- tickets-fetch-inbox routes by To address; notary-ai agents and API doc updated

**Pages affectées:**
- ai_working_help/server.js, docs, project_config.py, lib/project_config.sh
- projects/README.md, lecoffreio/docs/API.md, gitea-issues/tickets-fetch-inbox.py
2026-03-16 15:00:23 +01:00

164 lines
5.7 KiB
JavaScript

/**
* ai_working_help API server.
* Routes: POST /v1/enqueue, GET /v1/response/:request_uid, GET /health, GET /v1/health.
* Project id and env are resolved from the Bearer token by searching all
* projects/<id>/.secrets/<env>/ia_token files; the matching project id is used for the spooler.
* Spooler: projects/<id>/data/notary-ai/{pending,responded}.
*/
const express = require("express");
const bodyParser = require("body-parser");
const fs = require("fs");
const path = require("path");
const app = express();
app.use(bodyParser.json({ limit: "1mb" }));
const PORT = process.env.AI_WORKING_HELP_PORT || 3020;
const IA_DEV_ROOT = path.resolve(__dirname, "..");
const PROJECTS_DIR = path.join(IA_DEV_ROOT, "projects");
/**
* Resolve project id and env from token by scanning projects/<id>/.secrets/<env>/ia_token.
* @returns {{ projectId: string, env: string } | null}
*/
function resolveProjectAndEnvByToken(token) {
if (!token || typeof token !== "string") return null;
const t = token.trim();
if (!t) return null;
const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const secretsDir = path.join(PROJECTS_DIR, d.name, ".secrets");
try {
if (!fs.existsSync(secretsDir) || !fs.statSync(secretsDir).isDirectory()) continue;
const envDirs = fs.readdirSync(secretsDir, { withFileTypes: true });
for (const ed of envDirs) {
if (!ed.isDirectory()) continue;
const tokenPath = path.join(secretsDir, ed.name, "ia_token");
if (!fs.existsSync(tokenPath) || !fs.statSync(tokenPath).isFile()) continue;
const content = fs.readFileSync(tokenPath, "utf8").trim();
const envName = ed.name;
// Token is either full value in file (content) or base in file + env suffix: nicolecoffreio<env>
if (content === t || content + envName === t) return { projectId: d.name, env: envName };
}
} catch (_) {
// skip
}
}
return null;
}
function resolveProjectIdByToken(token) {
const resolved = resolveProjectAndEnvByToken(token);
return resolved ? resolved.projectId : null;
}
function requireApiTokenAndResolveProject(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== "string") {
return res.status(401).json({ message: "Invalid or missing token" });
}
const match = authHeader.match(/^Bearer\s+(.+)$/i);
const token = match ? String(match[1]).trim() : "";
const resolved = resolveProjectAndEnvByToken(token);
if (!resolved) {
return res.status(401).json({ message: "Invalid or missing token" });
}
req.projectId = resolved.projectId;
req.projectEnv = resolved.env;
next();
}
function safeUid(uid) {
return String(uid).replace(/[^a-zA-Z0-9-_]/g, "_").slice(0, 128) || "unknown";
}
function projectDataDir(projectId) {
if (!projectId || /[^a-zA-Z0-9-_]/.test(projectId)) return null;
return path.join(PROJECTS_DIR, projectId, "data", "notary-ai");
}
function findFileByRequestUid(dir, requestUid) {
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir);
for (const f of files) {
if (!f.endsWith(".json")) continue;
const filePath = path.join(dir, f);
try {
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (data.request_uid === requestUid) return { path: filePath, data };
} catch (_) {
// skip invalid json
}
}
return null;
}
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.get("/v1/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.post("/v1/enqueue", requireApiTokenAndResolveProject, (req, res) => {
const dir = projectDataDir(req.projectId);
if (!dir) {
return res.status(400).json({ message: "Invalid project" });
}
const pendingDir = path.join(dir, "pending");
const respondedDir = path.join(dir, "responded");
const body = req.body || {};
const requestUid = body.request_uid;
if (!requestUid || typeof requestUid !== "string") {
return res.status(400).json({ message: "Missing request_uid" });
}
const payload = {
request_uid: requestUid,
folder_uid: body.folder_uid,
office_uid: body.office_uid,
user_id: body.user_id,
question: body.question,
folder_context: body.folder_context || {},
status: "pending",
};
try {
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
if (!fs.existsSync(respondedDir)) fs.mkdirSync(respondedDir, { recursive: true });
const safe = safeUid(requestUid);
const filePath = path.join(pendingDir, `${safe}.json`);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
res.status(202).json({ request_uid: requestUid });
} catch (err) {
console.error("[ai_working_help] enqueue write error", err);
res.status(500).json({ message: "Write failed" });
}
});
app.get("/v1/response/:request_uid", requireApiTokenAndResolveProject, (req, res) => {
const requestUid = req.params.request_uid;
const dir = projectDataDir(req.projectId);
if (!dir) {
return res.status(400).json({ message: "Invalid project" });
}
const respondedDir = path.join(dir, "responded");
const foundResponded = findFileByRequestUid(respondedDir, requestUid);
if (foundResponded && foundResponded.data.response) {
return res.status(200).json({
status: "responded",
response: foundResponded.data.response,
});
}
const pendingDir = path.join(dir, "pending");
const foundPending = findFileByRequestUid(pendingDir, requestUid);
if (foundPending) {
return res.status(200).json({ status: "pending" });
}
res.status(200).json({ status: "pending" });
});
app.listen(PORT, () => {
console.log(`[ai_working_help] listening on port ${PORT}`);
});