diff --git a/.gitignore b/.gitignore index 68e4d7d..a0ad980 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ config/services.local.env # logs/ : le répertoire reste versionné (README.md, logs/.gitignore) ; aucun fichier journal versionné logs/**/*.log +# .logs/ : journaux SSO / API globale (README + .gitignore versionnés) +.logs/**/*.log + # projects/ : ignorer tout répertoire d'id sous projects/ sauf les squelettes versionnés (conf.json par id, gabarit example/) projects/* !projects/README.md diff --git a/.logs/.gitignore b/.logs/.gitignore new file mode 100644 index 0000000..a988499 --- /dev/null +++ b/.logs/.gitignore @@ -0,0 +1 @@ +**/*.log diff --git a/.logs/README.md b/.logs/README.md new file mode 100644 index 0000000..7046f72 --- /dev/null +++ b/.logs/README.md @@ -0,0 +1,5 @@ +# Journaux locaux (`.logs/`) + +Fichiers produits par les services (ex. passerelle SSO, API globale) : **une ligne JSON par requête** dans `sso-gateway/access.log` et `global-api/access.log`. + +Les fichiers `*.log` sous `.logs/` sont ignorés par Git (voir `.gitignore` à la racine du monorepo). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..4e379d2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.2 diff --git a/config/README.md b/config/README.md index db9712e..2ea85f1 100644 --- a/config/README.md +++ b/config/README.md @@ -1,3 +1,5 @@ -# Configuration locale +# Configuration locale (`config/`) -- **`services.local.env.example`** : variables pour l’IDE et tous les services HTTP ; copier vers **`services.local.env`** (non versionné, voir racine **`.gitignore`**). +- **`services.local.env.example`** : variables d’environnement agrégées pour les micro-services (ports sur `127.0.0.1`, jetons, URL de l’API globale et du SSO). Copier vers **`services.local.env`** (gitignoré à la racine du monorepo). + +Voir aussi [docs/README.md](../docs/README.md). diff --git a/config/services.local.env.example b/config/services.local.env.example index de67e03..026d4a6 100644 --- a/config/services.local.env.example +++ b/config/services.local.env.example @@ -1,74 +1,59 @@ -# Local IDE / agent configuration for all smart_ide HTTP services and the tools bridge. -# Copy to config/services.local.env and fill secrets. Do not commit services.local.env. -# -# Load before starting processes, e.g.: set -a && source config/services.local.env && set +a +# Copier vers config/services.local.env (gitignoré) et adapter. +# Profil micro-services : écoute et cibles **127.0.0.1** uniquement pour le maillage smart_ide. +# Les jetons utilisateur OIDC sont validés par la passerelle SSO ; l’IdP peut être distant (docv / Enso) — ce fichier concerne les **ports et secrets locaux** du monorepo. -# Monorepo root (path validation for tools-bridge jobs) -SMART_IDE_MONOREPO_ROOT=/absolute/path/to/smart_ide +SMART_IDE_MONOREPO_ROOT= -# Optional: extra allowed path prefixes (comma-separated) for tools-bridge file jobs -# TOOLS_ALLOWED_PATH_PREFIXES=/data/clones,/home/user/projects +# --- smart-ide-global-api (démarrer avant le SSO) --- +GLOBAL_API_HOST=127.0.0.1 +GLOBAL_API_PORT=37149 +GLOBAL_API_INTERNAL_TOKEN= -# --- smart-ide-tools-bridge (Carbonyl / PageIndex / Chandra + registry) --- -TOOLS_BRIDGE_HOST=127.0.0.1 -TOOLS_BRIDGE_PORT=37147 -TOOLS_BRIDGE_TOKEN= -TOOLS_BRIDGE_JOB_TIMEOUT_MS=3600000 +# --- smart-ide-sso-gateway --- +SSO_GATEWAY_HOST=127.0.0.1 +SSO_GATEWAY_PORT=37148 +GLOBAL_API_URL=http://127.0.0.1:37149 +# OIDC_ISSUER= # URL issuer OpenID (JWKS) — hors maillage HTTP interne +# OIDC_AUDIENCE= +# OIDC_JWKS_URI= +# SSO_CORS_ORIGIN= +# SSO_GATEWAY_MAX_BODY_BYTES=33554432 + +# --- Jetons / hôtes micro-services (consommés par smart-ide-global-api) --- +ORCHESTRATOR_HOST=127.0.0.1 +ORCHESTRATOR_PORT=37145 +ORCHESTRATOR_TOKEN= -# --- Core HTTP micro-services --- REPOS_DEVTOOLS_HOST=127.0.0.1 REPOS_DEVTOOLS_PORT=37140 REPOS_DEVTOOLS_TOKEN= -REPOS_DEVTOOLS_ROOT= - -LANGEXTRACT_API_HOST=127.0.0.1 -LANGEXTRACT_API_PORT=37141 -LANGEXTRACT_SERVICE_TOKEN= - -CLAW_PROXY_HOST=127.0.0.1 -CLAW_PROXY_PORT=37142 -CLAW_PROXY_TOKEN= -CLAW_UPSTREAM_URL= - -REGEX_SEARCH_HOST=127.0.0.1 -REGEX_SEARCH_PORT=37143 -REGEX_SEARCH_TOKEN= -REGEX_SEARCH_ROOT= - -LOCAL_OFFICE_URL=http://127.0.0.1:8000 -LOCAL_OFFICE_API_KEY= IA_DEV_GATEWAY_HOST=127.0.0.1 IA_DEV_GATEWAY_PORT=37144 IA_DEV_GATEWAY_TOKEN= -ORCHESTRATOR_HOST=127.0.0.1 -ORCHESTRATOR_PORT=37145 -ORCHESTRATOR_TOKEN= - ANYTHINGLLM_DEVTOOLS_HOST=127.0.0.1 ANYTHINGLLM_DEVTOOLS_PORT=37146 ANYTHINGLLM_DEVTOOLS_TOKEN= -ANYTHINGLLM_BASE_URL= -ANYTHINGLLM_API_KEY= -REPOS_DEVTOOLS_URL=http://127.0.0.1:37140 -# Orchestrator → tools bridge (for intent resolution hints) -TOOLS_BRIDGE_URL=http://127.0.0.1:37147 +TOOLS_BRIDGE_HOST=127.0.0.1 +TOOLS_BRIDGE_PORT=37147 +TOOLS_BRIDGE_TOKEN= -# --- smart-ide-sso-gateway (OIDC user token → proxy to micro-services) --- -SSO_GATEWAY_HOST=127.0.0.1 -SSO_GATEWAY_PORT=37148 -# SSO_CORS_ORIGIN= -# Required when running the gateway: docv / Enso issuer URL -OIDC_ISSUER= -# OIDC_AUDIENCE= -# OIDC_JWKS_URI= +LANGEXTRACT_API_HOST=127.0.0.1 +LANGEXTRACT_API_PORT=37141 +LANGEXTRACT_SERVICE_TOKEN= -# Ollama / AnythingLLM (orchestrator) -OLLAMA_URL=http://127.0.0.1:11434 -ANYTHINGLLM_BASE_URL= +REGEX_SEARCH_HOST=127.0.0.1 +REGEX_SEARCH_PORT=37143 +REGEX_SEARCH_TOKEN= -# Carbonyl (docker / native — used by bridge open-plan response) -CARBONYL_DOCKER_IMAGE=fathyb/carbonyl -CARBONYL_RUNNER=docker +CLAW_PROXY_HOST=127.0.0.1 +CLAW_PROXY_PORT=37142 +CLAW_PROXY_TOKEN= + +LOCAL_OFFICE_URL=http://127.0.0.1:8000 +LOCAL_OFFICE_API_KEY= + +# --- claw-harness-proxy : amont HTTP du binaire claw (machine locale uniquement en profil strict) --- +# CLAW_UPSTREAM_URL=http://127.0.0.1: diff --git a/docs/API/README.md b/docs/API/README.md index 8511da6..2049112 100644 --- a/docs/API/README.md +++ b/docs/API/README.md @@ -15,6 +15,7 @@ Documentation des **API HTTP** exposées par les services sous [`services/`](../ | **smart_ide-orchestrator** | Bearer (spécification) | `37145` (spécification) | [orchestrator.md](./orchestrator.md) | | **anythingllm-devtools** | Bearer | `37146` | [anythingllm-devtools-api.md](./anythingllm-devtools-api.md) | | **smart-ide-tools-bridge** | Bearer (sauf `/health`) | `37147` | [smart-ide-tools-bridge-api.md](./smart-ide-tools-bridge-api.md) | +| **smart-ide-global-api** | Bearer interne (`GLOBAL_API_INTERNAL_TOKEN`, sauf `/health`) | `37149` | [global-api.md](./global-api.md) | | **smart-ide-sso-gateway** | Bearer utilisateur OIDC (sauf `/health`) | `37148` | [sso-gateway-api.md](./sso-gateway-api.md) | | **docv** (externe) | selon dépôt Enso | selon déploiement | [docv.md](./docv.md) | diff --git a/docs/API/global-api.md b/docs/API/global-api.md new file mode 100644 index 0000000..c8733ea --- /dev/null +++ b/docs/API/global-api.md @@ -0,0 +1,53 @@ +# API — smart-ide-global-api + +Écoute par défaut : **`127.0.0.1:37149`**. Configuration : `services/smart-ide-global-api/.env.example`, agrégat [config/services.local.env.example](../../config/services.local.env.example). + +## Rôle + +Agrégateur HTTP **interne** : reçoit les requêtes authentifiées par `Authorization: Bearer ` (fourni par `smart-ide-sso-gateway`), relaie vers chaque micro-service avec son **jeton technique** et propage les en-têtes `X-OIDC-Sub` / `X-OIDC-Email` lorsqu’ils sont présents. + +Les navigateurs et applications utilisateur ne doivent **pas** appeler ce port directement : passer par la **passerelle SSO** (`/proxy//...`). + +## Authentification + +| Route | Auth | +|-------|------| +| `GET /health` | Aucune | +| Toutes les autres | `Authorization: Bearer` égal à `GLOBAL_API_INTERNAL_TOKEN` | + +## Endpoints + +### `GET /health` + +Réponse `200` : `{ "status": "ok", "service": "smart-ide-global-api" }`. + +### `GET /v1/upstreams` + +Liste les clés d’amont : `{ "upstreams": [ "orchestrator", ... ] }` (même liste que côté SSO). + +### Proxy — `ANY /v1/upstream//` + +- **``** : `orchestrator`, `repos_devtools`, `ia_dev_gateway`, `anythingllm_devtools`, `tools_bridge`, `langextract`, `regex_search`, `claw_proxy`, `local_office`. +- **``** : transmis à l’URL de base du service (ex. `/v1/upstream/orchestrator/v1/...` → `http://127.0.0.1:37145/v1/...`). +- **Corps** : relayé (limite `GLOBAL_API_MAX_BODY_BYTES`, défaut 32 MiB). +- **Erreurs** : `401` si Bearer interne absent ou incorrect ; `404` si clé inconnue ; `503` si `local_office` sans `LOCAL_OFFICE_API_KEY`. + +## Journaux + +Fichier **`.logs/global-api/access.log`** : lignes JSON (`ts`, `method`, `path`, `upstream`, `status`, `durationMs`, `oidcSub` si présent). + +## Variables d’environnement + +| Variable | Rôle | +|----------|------| +| `GLOBAL_API_HOST` / `GLOBAL_API_PORT` | Bind HTTP | +| `GLOBAL_API_INTERNAL_TOKEN` | Obligatoire — secret partagé avec la passerelle SSO | +| `GLOBAL_API_MAX_BODY_BYTES` | Taille max du corps | +| `SMART_IDE_MONOREPO_ROOT` | Racine monorepo pour `.logs/` (sinon déduction depuis le module) | + +Jetons et hôtes des micro-services : mêmes noms que dans `config/services.local.env.example`. + +## Voir aussi + +- [sso-gateway-api.md](./sso-gateway-api.md) +- [sso-gateway-service.md](../features/sso-gateway-service.md) diff --git a/docs/API/sso-gateway-api.md b/docs/API/sso-gateway-api.md index c226ebe..dcf6383 100644 --- a/docs/API/sso-gateway-api.md +++ b/docs/API/sso-gateway-api.md @@ -27,11 +27,15 @@ Liste les clés de proxy disponibles : `{ "upstreams": [ "orchestrator", ... ] } ### Proxy — `ANY /proxy//` - **``** : voir liste ci-dessus (`repos_devtools`, `orchestrator`, etc.). -- **``** : chemin transmis tel quel à l’URL de base du service (ex. `/proxy/orchestrator/v1/...` → `http://ORCHESTRATOR_HOST:PORT/v1/...`). +- **``** : relayé vers **smart-ide-global-api** sous `/v1/upstream/` (ex. `/proxy/orchestrator/v1/...` → `GLOBAL_API_URL/v1/upstream/orchestrator/v1/...`). - **Corps** : relayé pour les méthodes avec body (limite `SSO_GATEWAY_MAX_BODY_BYTES`, défaut 32 MiB). -- **Réponses d’erreur** : `401` si Bearer utilisateur absent ou invalide ; `404` si clé inconnue ; `503` si `local_office` est ciblé sans `LOCAL_OFFICE_API_KEY`. +- **Réponses d’erreur** : `401` si Bearer utilisateur absent ou invalide ; `404` si clé inconnue ; erreurs amont si l’API globale ou un micro-service refuse la requête. -L’en-tête `Authorization` utilisateur n’est **pas** transmis à l’amont ; il est remplacé par le jeton de service configuré. Voir [sso-gateway-service.md](../features/sso-gateway-service.md). +L’en-tête `Authorization` utilisateur n’est **pas** transmis à l’API globale ; il est remplacé par `GLOBAL_API_INTERNAL_TOKEN`. Les claims OIDC sont transmis en `X-OIDC-Sub` / `X-OIDC-Email` jusqu’aux micro-services. Voir [sso-gateway-service.md](../features/sso-gateway-service.md) et [global-api.md](./global-api.md). + +### Journaux + +Fichier **`.logs/sso-gateway/access.log`** : lignes JSON (`ts`, `method`, `path`, `upstream`, `status`, `durationMs`, `oidcSub` si présent). Les `GET /health` et `OPTIONS` ne sont pas journalisés. ### Comptes et projets @@ -47,8 +51,11 @@ Aucun stockage d’**utilisateurs** ou de **comptes par projet** dans ce service | `SSO_GATEWAY_HOST` / `SSO_GATEWAY_PORT` | Bind HTTP | | `SSO_CORS_ORIGIN` | Si défini, en-têtes CORS sur les réponses | | `SSO_GATEWAY_MAX_BODY_BYTES` | Taille max du corps en entrée | +| `GLOBAL_API_URL` | Base HTTP de smart-ide-global-api (défaut `http://127.0.0.1:37149`) | +| `GLOBAL_API_INTERNAL_TOKEN` | Obligatoire — même valeur que sur smart-ide-global-api | +| `SMART_IDE_MONOREPO_ROOT` | Optionnel — racine pour écrire sous `.logs/sso-gateway/` | -Les jetons et hôtes des micro-services : mêmes noms que dans `config/services.local.env.example`. +Les jetons et hôtes des micro-services sont lus par **smart-ide-global-api** ; voir `config/services.local.env.example` et [global-api.md](./global-api.md). ## Voir aussi diff --git a/docs/README.md b/docs/README.md index d0673a6..4578073 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,8 @@ Vue d’ensemble et index complet : **[repo/README.md](./repo/README.md)**. Règ | [repo/service-carbonyl.md](./repo/service-carbonyl.md) | Carbonyl (navigateur terminal), prévisualisation test | | [repo/service-pageindex.md](./repo/service-pageindex.md) | PageIndex (index vectorless, définition sémantique documents) | | [repo/service-smart-ide-tools-bridge.md](./repo/service-smart-ide-tools-bridge.md) | Pont HTTP IDE + sous-modules CLI | -| [repo/service-smart-ide-sso-gateway.md](./repo/service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → micro-services | +| [repo/service-smart-ide-global-api.md](./repo/service-smart-ide-global-api.md) | API HTTP interne : proxy vers micro-services (jetons techniques) | +| [repo/service-smart-ide-sso-gateway.md](./repo/service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → API globale → micro-services | | [repo/service-chandra.md](./repo/service-chandra.md) | Chandra OCR (PDF / images structurés) | | [repo/extension-anythingllm-workspaces.md](./repo/extension-anythingllm-workspaces.md) | Extension AnythingLLM IDE (supprimée ; voir anythingllm-devtools) | diff --git a/docs/features/sso-docv-enso.md b/docs/features/sso-docv-enso.md index 9a617d4..f341da1 100644 --- a/docs/features/sso-docv-enso.md +++ b/docs/features/sso-docv-enso.md @@ -12,7 +12,7 @@ Permettre au **front web** de la plateforme `smart_ide` (déployé par environne | **docv / IdP Enso** | Émet `id_token` / `access_token`, expose JWKS | | **Front SPA** | Échange code OAuth (PKCE recommandé), stocke session | | **Backend API** (orchestrateur ou BFF) | Valide JWT (signature JWKS, `iss`, `aud`, `exp`), mappe rôles → droits policy | -| **smart-ide-sso-gateway** (monorepo) | Point d’entrée optionnel : même validation JWT + proxy vers les micro-services avec jetons techniques — [sso-gateway-service.md](./sso-gateway-service.md) | +| **smart-ide-sso-gateway** (monorepo) | Point d’entrée optionnel : validation JWT + proxy via **smart-ide-global-api** vers les micro-services avec jetons techniques — [sso-gateway-service.md](./sso-gateway-service.md) | ## Flux (authorization code + PKCE) diff --git a/docs/features/sso-gateway-service.md b/docs/features/sso-gateway-service.md index cb32d09..542fd14 100644 --- a/docs/features/sso-gateway-service.md +++ b/docs/features/sso-gateway-service.md @@ -2,12 +2,12 @@ ## Rôle -Le service **`smart-ide-sso-gateway`** (`services/smart-ide-sso-gateway/`) centralise la **validation OIDC** (jeton utilisateur émis par **docv / Enso**) et le **proxy** vers les micro-services du monorepo avec leurs **jetons techniques**. +Le service **`smart-ide-sso-gateway`** (`services/smart-ide-sso-gateway/`) centralise la **validation OIDC** (jeton utilisateur émis par **docv / Enso**) puis délègue le **proxy HTTP** au service **`smart-ide-global-api`** (`services/smart-ide-global-api/`), qui applique les **jetons techniques** vers chaque micro-service. - Le **navigateur** ou un **BFF** présente un `access_token` utilisateur (`Authorization: Bearer`). -- La passerelle vérifie la signature (**JWKS**), `iss`, éventuellement `aud`, puis appelle l’amont avec le **Bearer de service** ou la **clé API** attendue par chaque cible. +- La passerelle vérifie la signature (**JWKS**), `iss`, éventuellement `aud`, puis appelle **smart-ide-global-api** avec `GLOBAL_API_INTERNAL_TOKEN` et propage `X-OIDC-Sub` / `X-OIDC-Email` pour les journaux et règles amont. -Les appels **machine à machine** sans contexte utilisateur (scripts, `ia_dev`) restent possibles **en direct** vers chaque service, comme avant : la passerelle est une **option** pour les parcours « utilisateur authentifié par docv ». +Les micro-services **n’implémentent pas le SSO** ; ils écoutent en **local** (`127.0.0.1`) avec authentification par jeton de service. Les appels **machine à machine** sans contexte utilisateur (scripts, `ia_dev`) peuvent toujours cibler **directement** un micro-service ou, pour un seul point d’entrée technique, **smart-ide-global-api** avec le Bearer interne. ## Lien avec docv / Enso @@ -15,7 +15,9 @@ Le flux **authorization code + PKCE** et le rôle d’**IdP** restent décrits d ## Amonts proxy -Les clés exposées par `GET /v1/upstreams` et utilisées dans `/proxy//...` sont : `orchestrator`, `repos_devtools`, `ia_dev_gateway`, `anythingllm_devtools`, `tools_bridge`, `langextract`, `regex_search`, `claw_proxy`, `local_office`. Les variables d’environnement sont les mêmes que pour le reste de la plateforme ([`config/services.local.env.example`](../../config/services.local.env.example)). +Les clés exposées par `GET /v1/upstreams` et utilisées dans `/proxy//...` sont : `orchestrator`, `repos_devtools`, `ia_dev_gateway`, `anythingllm_devtools`, `tools_bridge`, `langextract`, `regex_search`, `claw_proxy`, `local_office`. La résolution des URL et jetons par clé est implémentée dans **smart-ide-global-api** ; fichier d’exemple agrégé : [`config/services.local.env.example`](../../config/services.local.env.example). + +**Journaux** : `.logs/sso-gateway/access.log` (passerelle) et `.logs/global-api/access.log` (agrégateur), lignes JSON par requête (hors `GET /health` pour l’API globale ; hors `GET /health` et `OPTIONS` pour le SSO). ## En-têtes vers l’amont @@ -37,4 +39,6 @@ Les services amont qui reçoivent `X-OIDC-Sub` / `X-OIDC-Email` sont responsable ## Documentation détaillée - [API/sso-gateway-api.md](../API/sso-gateway-api.md) +- [API/global-api.md](../API/global-api.md) - [services/smart-ide-sso-gateway/README.md](../../services/smart-ide-sso-gateway/README.md) +- [services/smart-ide-global-api/README.md](../../services/smart-ide-global-api/README.md) diff --git a/docs/repo/README.md b/docs/repo/README.md index e534e65..10e5967 100644 --- a/docs/repo/README.md +++ b/docs/repo/README.md @@ -41,7 +41,8 @@ Toute la documentation **opérationnelle** qui vivait auparavant sous des `READM | [service-pageindex.md](./service-pageindex.md) | PageIndex (index sémantique vectorless), sous-module amont | | [service-chandra.md](./service-chandra.md) | Chandra OCR, sous-module amont | | [service-smart-ide-tools-bridge.md](./service-smart-ide-tools-bridge.md) | Pont HTTP IDE + outils sous-modules | -| [service-smart-ide-sso-gateway.md](./service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → micro-services | +| [service-smart-ide-global-api.md](./service-smart-ide-global-api.md) | API HTTP interne : proxy vers micro-services (Bearer partagé avec SSO) | +| [service-smart-ide-sso-gateway.md](./service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → API globale → micro-services | | [extension-anythingllm-workspaces.md](./extension-anythingllm-workspaces.md) | Extension AnythingLLM IDE (supprimée ; anythingllm-devtools) | Les **spécifications** détaillées (contrats HTTP, sécurité, orchestration) restent dans [../API/README.md](../API/README.md) et [../features/](../features/). diff --git a/docs/repo/logs-directory.md b/docs/repo/logs-directory.md index f3bf9f2..545e54a 100644 --- a/docs/repo/logs-directory.md +++ b/docs/repo/logs-directory.md @@ -16,3 +16,7 @@ Variable interne : **`SMART_IDE_LOG_IA_DEV_ROOT`** (racine `ia_dev`), posée par Contrat service : [ia-dev-smart-ide-integration.md](./ia-dev-smart-ide-integration.md), implémentation `ia_dev/lib/smart_ide_logs.sh`. Configuration du pull planifié : [cron-git-pull.md](./cron-git-pull.md). + +## Journaux passerelle SSO et API globale (`.logs/`) + +Sous la **racine du monorepo**, le répertoire **`.logs/`** contient des journaux d’accès JSON (fichiers `*.log` ignorés par Git) pour **`smart-ide-sso-gateway`** (`sso-gateway/access.log`) et **`smart-ide-global-api`** (`global-api/access.log`). Voir [`.logs/README.md`](../../.logs/README.md), [sso-gateway-service.md](../features/sso-gateway-service.md) et [global-api.md](../API/global-api.md). diff --git a/docs/repo/service-smart-ide-global-api.md b/docs/repo/service-smart-ide-global-api.md new file mode 100644 index 0000000..ced7ab9 --- /dev/null +++ b/docs/repo/service-smart-ide-global-api.md @@ -0,0 +1,17 @@ +# Service smart-ide-global-api (`services/smart-ide-global-api/`) + +Couche HTTP **interne** : proxy vers les micro-services avec jetons techniques, **sans OIDC**. Consommée exclusivement par **`smart-ide-sso-gateway`** (Bearer `GLOBAL_API_INTERNAL_TOKEN`). + +## Configuration locale + +- Agrégat : **[`config/services.local.env.example`](../../config/services.local.env.example)** +- Service : **`services/smart-ide-global-api/.env.example`** + +## Exploitation + +Démarrer **avant** la passerelle SSO. Voir **[`services/smart-ide-global-api/README.md`](../../services/smart-ide-global-api/README.md)** et **[`docs/API/global-api.md`](../API/global-api.md)**. + +## Voir aussi + +- [service-smart-ide-sso-gateway.md](./service-smart-ide-sso-gateway.md) +- [sso-gateway-service.md](../features/sso-gateway-service.md) diff --git a/docs/repo/service-smart-ide-sso-gateway.md b/docs/repo/service-smart-ide-sso-gateway.md index cf41e2f..8b8146e 100644 --- a/docs/repo/service-smart-ide-sso-gateway.md +++ b/docs/repo/service-smart-ide-sso-gateway.md @@ -1,11 +1,11 @@ # Service smart-ide-sso-gateway (`services/smart-ide-sso-gateway/`) -Passerelle HTTP : validation **JWT utilisateur** (issuer docv / Enso) et **proxy** vers les micro-services `smart_ide` avec authentification **technique** par service. +Passerelle HTTP : validation **JWT utilisateur** (issuer docv / Enso), puis **proxy** via **smart-ide-global-api** vers les micro-services (jetons techniques côté API globale). ## Configuration locale -- Fichier agrégé : **[`config/services.local.env.example`](../../config/services.local.env.example)** — y ajouter `OIDC_ISSUER` (et optionnellement `SSO_GATEWAY_*`, `SSO_CORS_ORIGIN`) lorsque la passerelle est utilisée. -- Service : **`services/smart-ide-sso-gateway/.env.example`**. +- Fichier agrégé : **[`config/services.local.env.example`](../../config/services.local.env.example)** — `OIDC_ISSUER`, `GLOBAL_API_URL`, `GLOBAL_API_INTERNAL_TOKEN`, jetons micro-services pour l’API globale. +- Passerelle : **`services/smart-ide-sso-gateway/.env.example`** — API globale : **`services/smart-ide-global-api/.env.example`**. ## Exploitation @@ -13,4 +13,5 @@ Voir **[`services/smart-ide-sso-gateway/README.md`](../../services/smart-ide-sso ## Voir aussi +- [service-smart-ide-global-api.md](./service-smart-ide-global-api.md) - [sso-docv-enso.md](../features/sso-docv-enso.md) diff --git a/docs/services-functional-scope.md b/docs/services-functional-scope.md index df1a909..3b156c9 100644 --- a/docs/services-functional-scope.md +++ b/docs/services-functional-scope.md @@ -27,7 +27,7 @@ Tous écoutent en principe sur **`127.0.0.1`** ; ports par défaut : [API/README | **claw-harness-api** (proxy) | **Proxy HTTP** vers un exécuteur claw-code amont | Outillage modèles alternatifs en dev | **Optionnel** si politique projet autorise ce proxy en interne | | **local-office** | **Manipulation programmatique** de fichiers Office (docx, etc.), clé `X-API-Key` | Tests / génération de documents depuis l’IDE | **Possible** pour génération batch ou transformations Office côté serveur applicatif (hors UI ONLYOFFICE) | | **smart-ide-tools-bridge** | **Registre** des URLs locales ; **jobs** Carbonyl / PageIndex / Chandra (chemins validés sous `SMART_IDE_MONOREPO_ROOT`) | Découverte des services ; lancer OCR / index / plan terminal depuis l’IDE | **Non prévu pour le trafic utilisateur final** : jobs longs, liés au poste / au monorepo ; les **backs produit** doivent préférer leurs propres pipelines (OCR, index) en prod si besoin | -| **smart-ide-sso-gateway** | **Validation JWT** docv / Enso + **proxy** vers les micro-services avec jetons techniques | Alternative au BFF maison pour un front ou un outil qui appelle déjà l’IdP | **Possible** quand le produit veut un seul point d’entrée HTTP local vers la plateforme IA sous identité utilisateur OIDC ; ne remplace pas les appels M2M directs — [features/sso-gateway-service.md](./features/sso-gateway-service.md) | +| **smart-ide-sso-gateway** | **Validation JWT** docv / Enso + **proxy** via **smart-ide-global-api** vers les micro-services | Alternative au BFF maison pour un front ou un outil qui appelle déjà l’IdP | **Possible** quand le produit veut un seul point d’entrée HTTP local vers la plateforme IA sous identité utilisateur OIDC ; les appels M2M peuvent rester directs vers un micro-service ou vers l’API globale — [features/sso-gateway-service.md](./features/sso-gateway-service.md), [repo/service-smart-ide-global-api.md](./repo/service-smart-ide-global-api.md) | ## 3. Outils CLI et sous-modules (sans listener HTTP dédié) @@ -54,13 +54,14 @@ Tous écoutent en principe sur **`127.0.0.1`** ; ports par défaut : [API/README ## 6. Rôles respectifs (rappel) -- **IDE** : orchestrateur (intentions), tools-bridge (registre + jobs outils), devtools AnythingLLM, repos-devtools, regex search, ia-dev-gateway, **smart-ide-sso-gateway** (OIDC utilisateur → micro-services, si activé), accès directs Ollama / AnythingLLM selon l’UX. +- **IDE** : orchestrateur (intentions), tools-bridge (registre + jobs outils), devtools AnythingLLM, repos-devtools, regex search, ia-dev-gateway, **smart-ide-global-api** + **smart-ide-sso-gateway** (OIDC utilisateur → API globale → micro-services, si activé), accès directs Ollama / AnythingLLM selon l’UX. - **Backends applicatifs** : consommer en priorité les services **stables et documentés** pour l’IA générique (**langextract**, **AnythingLLM**, **Ollama**, éventuellement **orchestrateur**) ; éviter de dépendre du **tools-bridge** pour la charge utilisateur ; respecter périmètres fichiers (`REGEX_SEARCH_ROOT`, SSH / données déployées — [remote-deployed-data-ssh.md](./features/remote-deployed-data-ssh.md)). ## Voir aussi - [config/services.local.env.example](../config/services.local.env.example) - [repo/service-smart-ide-tools-bridge.md](./repo/service-smart-ide-tools-bridge.md) +- [repo/service-smart-ide-global-api.md](./repo/service-smart-ide-global-api.md) - [repo/service-smart-ide-sso-gateway.md](./repo/service-smart-ide-sso-gateway.md) - [platform-target.md](./platform-target.md) - [deployment-target.md](./deployment-target.md) diff --git a/docs/services.md b/docs/services.md index f6866d0..9848ce1 100644 --- a/docs/services.md +++ b/docs/services.md @@ -44,7 +44,9 @@ Services d’appoint sur **`127.0.0.1`** (souvent auth **Bearer**) : Git devtool **smart-ide-tools-bridge** (`services/smart-ide-tools-bridge/`) : **API** locale (registre + Carbonyl / PageIndex / Chandra) — [API/smart-ide-tools-bridge-api.md](../API/smart-ide-tools-bridge-api.md). -**smart-ide-sso-gateway** (`services/smart-ide-sso-gateway/`) : validation **JWT** docv / Enso et **proxy** vers les autres micro-services avec jetons techniques — [features/sso-gateway-service.md](./features/sso-gateway-service.md), [API/sso-gateway-api.md](./API/sso-gateway-api.md). +**smart-ide-global-api** (`services/smart-ide-global-api/`) : agrégateur HTTP **interne** (Bearer partagé avec le SSO), proxy vers les micro-services avec jetons techniques — [repo/service-smart-ide-global-api.md](./repo/service-smart-ide-global-api.md), [API/global-api.md](./API/global-api.md). + +**smart-ide-sso-gateway** (`services/smart-ide-sso-gateway/`) : validation **JWT** docv / Enso puis **proxy** via l’API globale — [features/sso-gateway-service.md](./features/sso-gateway-service.md), [API/sso-gateway-api.md](./API/sso-gateway-api.md). **Configuration locale** : [config/services.local.env.example](../config/services.local.env.example). diff --git a/services/claw-harness-api/proxy/.env.example b/services/claw-harness-api/proxy/.env.example index 56b69ad..48c0a59 100644 --- a/services/claw-harness-api/proxy/.env.example +++ b/services/claw-harness-api/proxy/.env.example @@ -1,4 +1,5 @@ CLAW_PROXY_HOST=127.0.0.1 CLAW_PROXY_PORT=37142 CLAW_PROXY_TOKEN= +# Profil strictement local : URL HTTP du serveur claw sur cette machine (ex. http://127.0.0.1:) CLAW_UPSTREAM_URL= diff --git a/services/smart-ide-global-api/.env.example b/services/smart-ide-global-api/.env.example new file mode 100644 index 0000000..3e48a2d --- /dev/null +++ b/services/smart-ide-global-api/.env.example @@ -0,0 +1,48 @@ +# smart-ide-global-api — copy to .env, do not commit .env +# Or merge into config/services.local.env (repo root) + +GLOBAL_API_HOST=127.0.0.1 +GLOBAL_API_PORT=37149 +# GLOBAL_API_MAX_BODY_BYTES=33554432 + +# Shared secret with smart-ide-sso-gateway (Bearer toward this service) +GLOBAL_API_INTERNAL_TOKEN= + +# Optional: monorepo root for .logs/ (default: three levels above dist/) +# SMART_IDE_MONOREPO_ROOT= + +# Micro-services: loopback only — same variables as historical SSO direct proxy +ORCHESTRATOR_HOST=127.0.0.1 +ORCHESTRATOR_PORT=37145 +ORCHESTRATOR_TOKEN= + +REPOS_DEVTOOLS_HOST=127.0.0.1 +REPOS_DEVTOOLS_PORT=37140 +REPOS_DEVTOOLS_TOKEN= + +IA_DEV_GATEWAY_HOST=127.0.0.1 +IA_DEV_GATEWAY_PORT=37144 +IA_DEV_GATEWAY_TOKEN= + +ANYTHINGLLM_DEVTOOLS_HOST=127.0.0.1 +ANYTHINGLLM_DEVTOOLS_PORT=37146 +ANYTHINGLLM_DEVTOOLS_TOKEN= + +TOOLS_BRIDGE_HOST=127.0.0.1 +TOOLS_BRIDGE_PORT=37147 +TOOLS_BRIDGE_TOKEN= + +LANGEXTRACT_API_HOST=127.0.0.1 +LANGEXTRACT_API_PORT=37141 +LANGEXTRACT_SERVICE_TOKEN= + +REGEX_SEARCH_HOST=127.0.0.1 +REGEX_SEARCH_PORT=37143 +REGEX_SEARCH_TOKEN= + +CLAW_PROXY_HOST=127.0.0.1 +CLAW_PROXY_PORT=37142 +CLAW_PROXY_TOKEN= + +LOCAL_OFFICE_URL=http://127.0.0.1:8000 +LOCAL_OFFICE_API_KEY= diff --git a/services/smart-ide-global-api/.gitignore b/services/smart-ide-global-api/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/services/smart-ide-global-api/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/services/smart-ide-global-api/README.md b/services/smart-ide-global-api/README.md new file mode 100644 index 0000000..2b328db --- /dev/null +++ b/services/smart-ide-global-api/README.md @@ -0,0 +1,30 @@ +# smart-ide-global-api + +Couche HTTP **interne** : agrège les appels vers les micro-services `smart_ide` avec les **jetons techniques** par service. **Pas d’OIDC** : seul `smart-ide-sso-gateway` appelle cette API, avec `Authorization: Bearer` égal à `GLOBAL_API_INTERNAL_TOKEN`. + +Les micro-services n’exposent pas le SSO ; ils restent sur **127.0.0.1** avec Bearer (ou clé API pour local-office). + +## Run + +Démarrer **avant** la passerelle SSO. + +```bash +cd services/smart-ide-global-api +cp .env.example .env +# définir GLOBAL_API_INTERNAL_TOKEN et les jetons des services +set -a && source .env && set +a +npm ci +npm run build +npm start +``` + +Écoute par défaut : `http://127.0.0.1:37149`. + +## Journaux + +Une ligne JSON par requête (hors `GET /health`) dans **`.logs/global-api/access.log`** (répertoire `.logs/` à la racine du monorepo, créé au besoin). + +## Documentation + +- API : [`docs/API/global-api.md`](../../docs/API/global-api.md) +- Passerelle SSO : [`docs/features/sso-gateway-service.md`](../../docs/features/sso-gateway-service.md) diff --git a/services/smart-ide-global-api/package-lock.json b/services/smart-ide-global-api/package-lock.json new file mode 100644 index 0000000..bfac5e4 --- /dev/null +++ b/services/smart-ide-global-api/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@4nk/smart-ide-global-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@4nk/smart-ide-global-api", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/services/smart-ide-global-api/package.json b/services/smart-ide-global-api/package.json new file mode 100644 index 0000000..8b3f64d --- /dev/null +++ b/services/smart-ide-global-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "@4nk/smart-ide-global-api", + "version": "0.1.0", + "private": true, + "description": "Internal HTTP aggregation layer: service tokens only, no OIDC. Consumed by smart-ide-sso-gateway.", + "license": "MIT", + "type": "module", + "main": "dist/server.js", + "scripts": { + "build": "tsc -p ./", + "start": "node dist/server.js" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + } +} diff --git a/services/smart-ide-global-api/src/accessLog.ts b/services/smart-ide-global-api/src/accessLog.ts new file mode 100644 index 0000000..28981e5 --- /dev/null +++ b/services/smart-ide-global-api/src/accessLog.ts @@ -0,0 +1,19 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { repoRoot } from "./repoRoot.js"; + +const logFile = async (): Promise => { + const dir = path.join(repoRoot(), ".logs", "global-api"); + await fs.mkdir(dir, { recursive: true }); + return path.join(dir, "access.log"); +}; + +export const appendGlobalApiAccessLog = async ( + entry: Record, +): Promise => { + const file = await logFile(); + const line = + JSON.stringify({ ts: new Date().toISOString(), service: "smart-ide-global-api", ...entry }) + + "\n"; + await fs.appendFile(file, line, "utf8"); +}; diff --git a/services/smart-ide-global-api/src/repoRoot.ts b/services/smart-ide-global-api/src/repoRoot.ts new file mode 100644 index 0000000..ca52cf1 --- /dev/null +++ b/services/smart-ide-global-api/src/repoRoot.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const repoRoot = (): string => { + const fromEnv = process.env.SMART_IDE_MONOREPO_ROOT?.trim(); + if (fromEnv) { + return path.resolve(fromEnv); + } + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, "..", "..", ".."); +}; diff --git a/services/smart-ide-global-api/src/server.ts b/services/smart-ide-global-api/src/server.ts new file mode 100644 index 0000000..e248093 --- /dev/null +++ b/services/smart-ide-global-api/src/server.ts @@ -0,0 +1,204 @@ +import * as http from "node:http"; +import { appendGlobalApiAccessLog } from "./accessLog.js"; +import { listUpstreamKeys, resolveUpstream, type UpstreamAuth } from "./upstreams.js"; + +const HOST = process.env.GLOBAL_API_HOST ?? "127.0.0.1"; +const PORT = Number(process.env.GLOBAL_API_PORT ?? "37149"); +const MAX_BODY_BYTES = Number(process.env.GLOBAL_API_MAX_BODY_BYTES ?? "33554432"); + +const readExpectedToken = (): string => process.env.GLOBAL_API_INTERNAL_TOKEN?.trim() ?? ""; + +const json = (res: http.ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +}; + +const readBearer = (req: http.IncomingMessage): string | null => { + const raw = req.headers.authorization ?? ""; + const m = /^Bearer\s+(.+)$/i.exec(raw); + return m?.[1]?.trim() ?? null; +}; + +const readBodyBuffer = async (req: http.IncomingMessage): Promise => { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + total += b.length; + if (total > MAX_BODY_BYTES) { + throw new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`); + } + chunks.push(b); + } + return Buffer.concat(chunks); +}; + +const hopByHop = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", +]); + +const buildForwardHeaders = (req: http.IncomingMessage, serviceAuth: UpstreamAuth): Headers => { + const out = new Headers(); + for (const [k, v] of Object.entries(req.headers)) { + if (!v) { + continue; + } + const lk = k.toLowerCase(); + if (hopByHop.has(lk)) { + continue; + } + if (lk === "authorization") { + continue; + } + out.set(k, Array.isArray(v) ? v.join(", ") : v); + } + if (serviceAuth.kind === "bearer") { + if (serviceAuth.token) { + out.set("Authorization", `Bearer ${serviceAuth.token}`); + } + } else if (serviceAuth.value) { + out.set(serviceAuth.name, serviceAuth.value); + } + return out; +}; + +const responseHopByHop = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "content-encoding", +]); + +const proxyToUpstream = async ( + res: http.ServerResponse, + targetUrl: string, + headers: Headers, + body: Buffer, + method: string, +): Promise => { + const init: RequestInit = { + method, + headers, + redirect: "manual", + }; + if (method !== "GET" && method !== "HEAD" && body.length > 0) { + init.body = new Uint8Array(body); + } + const out = await fetch(targetUrl, init); + res.statusCode = out.status; + for (const [k, v] of out.headers) { + if (responseHopByHop.has(k.toLowerCase())) { + continue; + } + res.setHeader(k, v); + } + const buf = Buffer.from(await out.arrayBuffer()); + res.end(buf); + return out.status; +}; + +const main = (): void => { + const internal = readExpectedToken(); + if (internal.length === 0) { + console.error("smart-ide-global-api: set GLOBAL_API_INTERNAL_TOKEN (non-empty secret)."); + process.exit(1); + } + + const server = http.createServer((req, res) => { + void (async () => { + const started = Date.now(); + const method = req.method ?? "GET"; + const url = new URL(req.url ?? "/", `http://${HOST}`); + const pathname = url.pathname; + let logPath = pathname; + let upstreamKey = ""; + let status = 0; + + try { + if (method === "GET" && (pathname === "/health" || pathname === "/health/")) { + status = 200; + json(res, status, { status: "ok", service: "smart-ide-global-api" }); + return; + } + + const token = readBearer(req); + if (!token || token !== internal) { + status = 401; + json(res, status, { error: "Missing or invalid internal Authorization bearer" }); + return; + } + + if (method === "GET" && pathname === "/v1/upstreams") { + status = 200; + json(res, status, { upstreams: listUpstreamKeys() }); + return; + } + + const proxyMatch = /^\/v1\/upstream\/([^/]+)(\/.*)?$/.exec(pathname); + if (!proxyMatch) { + status = 404; + json(res, status, { error: "Not found" }); + return; + } + upstreamKey = proxyMatch[1]; + const rest = proxyMatch[2] ?? "/"; + logPath = `/proxy/${upstreamKey}${rest}`; + const upstream = resolveUpstream(upstreamKey); + if (!upstream) { + status = 404; + json(res, status, { error: `Unknown upstream: ${upstreamKey}` }); + return; + } + if (upstream.auth.kind === "header" && !upstream.auth.value) { + status = 503; + json(res, status, { + error: `API key not configured for upstream ${upstreamKey} (LOCAL_OFFICE_API_KEY)`, + }); + return; + } + + const body = await readBodyBuffer(req); + const targetUrl = `${upstream.baseUrl}${rest}${url.search}`; + const headers = buildForwardHeaders(req, upstream.auth); + status = await proxyToUpstream(res, targetUrl, headers, body, method); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + status = status || 400; + if (!res.headersSent) { + json(res, 400, { error: msg }); + status = 400; + } + } finally { + const skipLog = + method === "GET" && (pathname === "/health" || pathname === "/health/"); + if (!skipLog) { + void appendGlobalApiAccessLog({ + method, + path: logPath, + upstream: upstreamKey || undefined, + status, + durationMs: Date.now() - started, + oidcSub: + typeof req.headers["x-oidc-sub"] === "string" + ? req.headers["x-oidc-sub"] + : undefined, + }); + } + } + })(); + }); + + server.listen(PORT, HOST, () => { + console.error(`smart-ide-global-api listening on http://${HOST}:${PORT}`); + }); +}; + +main(); diff --git a/services/smart-ide-global-api/src/upstreams.ts b/services/smart-ide-global-api/src/upstreams.ts new file mode 100644 index 0000000..81a41bd --- /dev/null +++ b/services/smart-ide-global-api/src/upstreams.ts @@ -0,0 +1,96 @@ +export type UpstreamAuth = + | { kind: "bearer"; token: string } + | { kind: "header"; name: string; value: string }; + +export type UpstreamTarget = { + baseUrl: string; + auth: UpstreamAuth; +}; + +const trimSlash = (s: string): string => s.replace(/\/+$/, ""); + +const env = (k: string, d: string): string => process.env[k]?.trim() ?? d; + +export const resolveUpstream = (key: string): UpstreamTarget | null => { + switch (key) { + case "orchestrator": + return { + baseUrl: trimSlash( + `http://${env("ORCHESTRATOR_HOST", "127.0.0.1")}:${env("ORCHESTRATOR_PORT", "37145")}`, + ), + auth: { kind: "bearer", token: env("ORCHESTRATOR_TOKEN", "") }, + }; + case "repos_devtools": + return { + baseUrl: trimSlash( + `http://${env("REPOS_DEVTOOLS_HOST", "127.0.0.1")}:${env("REPOS_DEVTOOLS_PORT", "37140")}`, + ), + auth: { kind: "bearer", token: env("REPOS_DEVTOOLS_TOKEN", "") }, + }; + case "ia_dev_gateway": + return { + baseUrl: trimSlash( + `http://${env("IA_DEV_GATEWAY_HOST", "127.0.0.1")}:${env("IA_DEV_GATEWAY_PORT", "37144")}`, + ), + auth: { kind: "bearer", token: env("IA_DEV_GATEWAY_TOKEN", "") }, + }; + case "anythingllm_devtools": + return { + baseUrl: trimSlash( + `http://${env("ANYTHINGLLM_DEVTOOLS_HOST", "127.0.0.1")}:${env("ANYTHINGLLM_DEVTOOLS_PORT", "37146")}`, + ), + auth: { kind: "bearer", token: env("ANYTHINGLLM_DEVTOOLS_TOKEN", "") }, + }; + case "tools_bridge": + return { + baseUrl: trimSlash( + `http://${env("TOOLS_BRIDGE_HOST", "127.0.0.1")}:${env("TOOLS_BRIDGE_PORT", "37147")}`, + ), + auth: { kind: "bearer", token: env("TOOLS_BRIDGE_TOKEN", "") }, + }; + case "langextract": + return { + baseUrl: trimSlash( + `http://${env("LANGEXTRACT_API_HOST", "127.0.0.1")}:${env("LANGEXTRACT_API_PORT", "37141")}`, + ), + auth: { kind: "bearer", token: env("LANGEXTRACT_SERVICE_TOKEN", "") }, + }; + case "regex_search": + return { + baseUrl: trimSlash( + `http://${env("REGEX_SEARCH_HOST", "127.0.0.1")}:${env("REGEX_SEARCH_PORT", "37143")}`, + ), + auth: { kind: "bearer", token: env("REGEX_SEARCH_TOKEN", "") }, + }; + case "claw_proxy": + return { + baseUrl: trimSlash( + `http://${env("CLAW_PROXY_HOST", "127.0.0.1")}:${env("CLAW_PROXY_PORT", "37142")}`, + ), + auth: { kind: "bearer", token: env("CLAW_PROXY_TOKEN", "") }, + }; + case "local_office": + return { + baseUrl: trimSlash(env("LOCAL_OFFICE_URL", "http://127.0.0.1:8000")), + auth: { + kind: "header", + name: "X-API-Key", + value: env("LOCAL_OFFICE_API_KEY", ""), + }, + }; + default: + return null; + } +}; + +export const listUpstreamKeys = (): string[] => [ + "orchestrator", + "repos_devtools", + "ia_dev_gateway", + "anythingllm_devtools", + "tools_bridge", + "langextract", + "regex_search", + "claw_proxy", + "local_office", +]; diff --git a/services/smart-ide-global-api/tsconfig.json b/services/smart-ide-global-api/tsconfig.json new file mode 100644 index 0000000..ae73f32 --- /dev/null +++ b/services/smart-ide-global-api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "declaration": false + }, + "include": ["src/**/*.ts"] +} diff --git a/services/smart-ide-sso-gateway/.env.example b/services/smart-ide-sso-gateway/.env.example index 4983a13..1250bf9 100644 --- a/services/smart-ide-sso-gateway/.env.example +++ b/services/smart-ide-sso-gateway/.env.example @@ -1,5 +1,5 @@ # smart-ide-sso-gateway — copy to .env, do not commit .env -# Or merge into config/services.local.env (see repo root config/services.local.env.example) +# Or merge into config/services.local.env (repo root) SSO_GATEWAY_HOST=127.0.0.1 SSO_GATEWAY_PORT=37148 @@ -7,45 +7,19 @@ SSO_GATEWAY_PORT=37148 # SSO_CORS_ORIGIN=https://app.example.test # SSO_GATEWAY_MAX_BODY_BYTES=33554432 -# Required: docv / Enso OpenID issuer URL (no trailing slash issues tolerated) +# Required: docv / Enso OpenID issuer URL (JWKS discovery) OIDC_ISSUER=https://docv.example.test # Optional: validate access_token audience # OIDC_AUDIENCE=smart-ide-gateway -# Optional: override JWKS URL (otherwise discovery or {issuer}/.well-known/jwks.json) +# Optional: override JWKS URL # OIDC_JWKS_URI=https://docv.example.test/.well-known/jwks.json -# Same tokens as other services — gateway injects them toward upstreams -ORCHESTRATOR_HOST=127.0.0.1 -ORCHESTRATOR_PORT=37145 -ORCHESTRATOR_TOKEN= +# smart-ide-global-api (must be running; same secret on both sides) +GLOBAL_API_URL=http://127.0.0.1:37149 +GLOBAL_API_INTERNAL_TOKEN= -REPOS_DEVTOOLS_HOST=127.0.0.1 -REPOS_DEVTOOLS_PORT=37140 -REPOS_DEVTOOLS_TOKEN= +# Optional: monorepo root for .logs/sso-gateway/ +# SMART_IDE_MONOREPO_ROOT= -IA_DEV_GATEWAY_HOST=127.0.0.1 -IA_DEV_GATEWAY_PORT=37144 -IA_DEV_GATEWAY_TOKEN= - -ANYTHINGLLM_DEVTOOLS_HOST=127.0.0.1 -ANYTHINGLLM_DEVTOOLS_PORT=37146 -ANYTHINGLLM_DEVTOOLS_TOKEN= - -TOOLS_BRIDGE_HOST=127.0.0.1 -TOOLS_BRIDGE_PORT=37147 -TOOLS_BRIDGE_TOKEN= - -LANGEXTRACT_API_HOST=127.0.0.1 -LANGEXTRACT_API_PORT=37141 -LANGEXTRACT_SERVICE_TOKEN= - -REGEX_SEARCH_HOST=127.0.0.1 -REGEX_SEARCH_PORT=37143 -REGEX_SEARCH_TOKEN= - -CLAW_PROXY_HOST=127.0.0.1 -CLAW_PROXY_PORT=37142 -CLAW_PROXY_TOKEN= - -LOCAL_OFFICE_URL=http://127.0.0.1:8000 -LOCAL_OFFICE_API_KEY= +# Micro-service tokens and hosts are read by smart-ide-global-api, not this process. +# See services/smart-ide-global-api/.env.example diff --git a/services/smart-ide-sso-gateway/README.md b/services/smart-ide-sso-gateway/README.md index 28fb99a..41e5373 100644 --- a/services/smart-ide-sso-gateway/README.md +++ b/services/smart-ide-sso-gateway/README.md @@ -1,21 +1,25 @@ # smart-ide-sso-gateway -HTTP gateway that validates **user** access tokens from the docv / Enso OIDC issuer, then proxies requests to internal `smart_ide` micro-services using each service’s **technical** credentials (Bearer or `X-API-Key`). +HTTP gateway that validates **user** access tokens from the docv / Enso OIDC issuer, then forwards requests to **`smart-ide-global-api`**, which proxies to internal `smart_ide` micro-services using each service’s **technical** credentials (Bearer or `X-API-Key`). ## Responsibilities - Verify `Authorization: Bearer ` with JWKS (`OIDC_ISSUER`, optional `OIDC_AUDIENCE`, optional `OIDC_JWKS_URI`). - Expose `GET /health` without auth. - Expose `GET /v1/token/verify` and `GET /v1/upstreams` with user Bearer. -- Proxy `ANY /proxy//` to the configured upstream, replacing the user token with the service token and adding `X-OIDC-Sub` / `X-OIDC-Email` when present in the JWT. +- Proxy `ANY /proxy//` to **smart-ide-global-api** (`GLOBAL_API_URL`, `GLOBAL_API_INTERNAL_TOKEN`), which relays to the target service and adds upstream auth plus `X-OIDC-Sub` / `X-OIDC-Email` when present in the JWT. + +Structured request logs (except `GET /health` and `OPTIONS`) are appended to **`.logs/sso-gateway/access.log`** under the monorepo root. User accounts, project membership, and product databases stay in **each application’s backend** (docv, Enso, etc.); this gateway does not store them. ## Run +Start **smart-ide-global-api** first, then: + ```bash cd services/smart-ide-sso-gateway -cp .env.example .env # edit OIDC_ISSUER and service tokens +cp .env.example .env # edit OIDC_ISSUER, GLOBAL_API_INTERNAL_TOKEN (match global API) set -a && source .env && set +a npm ci npm run build @@ -24,9 +28,10 @@ npm start Default listen: `http://127.0.0.1:37148`. -Upstream URLs and tokens reuse the same environment variables as the rest of the monorepo (`ORCHESTRATOR_*`, `TOOLS_BRIDGE_*`, `LOCAL_OFFICE_URL` / `LOCAL_OFFICE_API_KEY`, etc.). See `src/upstreams.ts`. +Micro-service URLs and tokens are configured on **smart-ide-global-api** (`services/smart-ide-global-api/.env.example` or aggregated `config/services.local.env`). ## Documentation - Feature: [`docs/features/sso-gateway-service.md`](../../docs/features/sso-gateway-service.md) - API: [`docs/API/sso-gateway-api.md`](../../docs/API/sso-gateway-api.md) +- Global API: [`docs/API/global-api.md`](../../docs/API/global-api.md) diff --git a/services/smart-ide-sso-gateway/src/accessLog.ts b/services/smart-ide-sso-gateway/src/accessLog.ts new file mode 100644 index 0000000..a0b90a2 --- /dev/null +++ b/services/smart-ide-sso-gateway/src/accessLog.ts @@ -0,0 +1,19 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { repoRoot } from "./repoRoot.js"; + +const logFile = async (): Promise => { + const dir = path.join(repoRoot(), ".logs", "sso-gateway"); + await fs.mkdir(dir, { recursive: true }); + return path.join(dir, "access.log"); +}; + +export const appendSsoAccessLog = async ( + entry: Record, +): Promise => { + const file = await logFile(); + const line = + JSON.stringify({ ts: new Date().toISOString(), service: "smart-ide-sso-gateway", ...entry }) + + "\n"; + await fs.appendFile(file, line, "utf8"); +}; diff --git a/services/smart-ide-sso-gateway/src/repoRoot.ts b/services/smart-ide-sso-gateway/src/repoRoot.ts new file mode 100644 index 0000000..ca52cf1 --- /dev/null +++ b/services/smart-ide-sso-gateway/src/repoRoot.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const repoRoot = (): string => { + const fromEnv = process.env.SMART_IDE_MONOREPO_ROOT?.trim(); + if (fromEnv) { + return path.resolve(fromEnv); + } + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, "..", "..", ".."); +}; diff --git a/services/smart-ide-sso-gateway/src/server.ts b/services/smart-ide-sso-gateway/src/server.ts index f4744a6..3076fb9 100644 --- a/services/smart-ide-sso-gateway/src/server.ts +++ b/services/smart-ide-sso-gateway/src/server.ts @@ -1,13 +1,19 @@ import * as http from "node:http"; import type { JWTPayload } from "jose"; +import { appendSsoAccessLog } from "./accessLog.js"; import { discoverJwksUri, createVerify, type VerifyFn } from "./oidc.js"; -import { listUpstreamKeys, resolveUpstream, type UpstreamAuth } from "./upstreams.js"; +import { listUpstreamKeys } from "./upstreams.js"; const HOST = process.env.SSO_GATEWAY_HOST ?? "127.0.0.1"; const PORT = Number(process.env.SSO_GATEWAY_PORT ?? "37148"); const MAX_BODY_BYTES = Number(process.env.SSO_GATEWAY_MAX_BODY_BYTES ?? "33554432"); const CORS_ORIGIN = process.env.SSO_CORS_ORIGIN?.trim() ?? ""; +const trimSlash = (s: string): string => s.replace(/\/+$/, ""); +const globalApiBase = (): string => + trimSlash(process.env.GLOBAL_API_URL ?? "http://127.0.0.1:37149"); +const globalApiToken = (): string => process.env.GLOBAL_API_INTERNAL_TOKEN?.trim() ?? ""; + const corsHeaders = (): Record => { if (!CORS_ORIGIN) { return {}; @@ -65,9 +71,8 @@ const hopByHop = new Set([ "host", ]); -const buildForwardHeaders = ( +const buildForwardHeadersToGlobalApi = ( req: http.IncomingMessage, - serviceAuth: UpstreamAuth, payload: JWTPayload, ): Headers => { const out = new Headers(); @@ -84,12 +89,9 @@ const buildForwardHeaders = ( } out.set(k, Array.isArray(v) ? v.join(", ") : v); } - if (serviceAuth.kind === "bearer") { - if (serviceAuth.token) { - out.set("Authorization", `Bearer ${serviceAuth.token}`); - } - } else if (serviceAuth.value) { - out.set(serviceAuth.name, serviceAuth.value); + const gToken = globalApiToken(); + if (gToken) { + out.set("Authorization", `Bearer ${gToken}`); } const sub = payload.sub; if (typeof sub === "string" && sub.length > 0) { @@ -109,13 +111,13 @@ const responseHopByHop = new Set([ "content-encoding", ]); -const proxyToUpstream = async ( +const proxyToGlobalApi = async ( req: http.IncomingMessage, res: http.ServerResponse, targetUrl: string, headers: Headers, body: Buffer, -): Promise => { +): Promise => { const method = req.method ?? "GET"; const init: RequestInit = { method, @@ -136,6 +138,7 @@ const proxyToUpstream = async ( } const buf = Buffer.from(await out.arrayBuffer()); res.end(buf); + return out.status; }; const publicClaims = (payload: JWTPayload): Record => { @@ -154,6 +157,12 @@ const main = async (): Promise => { console.error("smart-ide-sso-gateway: set OIDC_ISSUER (docv / Enso IdP issuer URL)."); process.exit(1); } + if (!globalApiToken()) { + console.error( + "smart-ide-sso-gateway: set GLOBAL_API_INTERNAL_TOKEN (must match smart-ide-global-api).", + ); + process.exit(1); + } const audience = process.env.OIDC_AUDIENCE?.trim(); const jwksUri = await discoverJwksUri(issuer); console.error(`smart-ide-sso-gateway: JWKS URI ${jwksUri}`); @@ -161,24 +170,32 @@ const main = async (): Promise => { const server = http.createServer((req, res) => { void (async () => { + const started = Date.now(); + const method = req.method ?? "GET"; + const url = new URL(req.url ?? "/", `http://${HOST}`); + const pathname = url.pathname; + let logPath = pathname; + let upstreamKey = ""; + let status = 0; + let oidcSub: string | undefined; + try { - if (req.method === "OPTIONS") { + if (method === "OPTIONS") { res.writeHead(204, corsHeaders()); res.end(); return; } - const url = new URL(req.url ?? "/", `http://${HOST}`); - const pathname = url.pathname; - - if (req.method === "GET" && (pathname === "/health" || pathname === "/health/")) { - json(res, 200, { status: "ok", service: "smart-ide-sso-gateway" }); + if (method === "GET" && (pathname === "/health" || pathname === "/health/")) { + status = 200; + json(res, status, { status: "ok", service: "smart-ide-sso-gateway" }); return; } const token = readBearer(req); if (!token) { - json(res, 401, { error: "Missing Authorization: Bearer " }); + status = 401; + json(res, status, { error: "Missing Authorization: Bearer " }); return; } @@ -186,45 +203,61 @@ const main = async (): Promise => { try { payload = await verify(token); } catch { - json(res, 401, { error: "Invalid or expired token" }); + status = 401; + json(res, status, { error: "Invalid or expired token" }); return; } - if (req.method === "GET" && pathname === "/v1/token/verify") { - json(res, 200, { valid: true, claims: publicClaims(payload) }); + if (typeof payload.sub === "string") { + oidcSub = payload.sub; + } + + if (method === "GET" && pathname === "/v1/token/verify") { + status = 200; + json(res, status, { valid: true, claims: publicClaims(payload) }); return; } - if (req.method === "GET" && pathname === "/v1/upstreams") { - json(res, 200, { upstreams: listUpstreamKeys() }); + if (method === "GET" && pathname === "/v1/upstreams") { + status = 200; + json(res, status, { upstreams: listUpstreamKeys() }); return; } const proxyMatch = /^\/proxy\/([^/]+)(\/.*)?$/.exec(pathname); - if (!proxyMatch || !req.method) { - json(res, 404, { error: "Not found" }); + if (!proxyMatch || !method) { + status = 404; + json(res, status, { error: "Not found" }); return; } - const key = proxyMatch[1]; + upstreamKey = proxyMatch[1]; const rest = proxyMatch[2] ?? "/"; - const upstream = resolveUpstream(key); - if (!upstream) { - json(res, 404, { error: `Unknown upstream: ${key}` }); - return; - } - if (upstream.auth.kind === "header" && !upstream.auth.value) { - json(res, 503, { error: `API key not configured for upstream ${key} (LOCAL_OFFICE_API_KEY)` }); - return; - } - + logPath = pathname; + const targetUrl = `${globalApiBase()}/v1/upstream/${upstreamKey}${rest}${url.search}`; const body = await readBodyBuffer(req); - const targetUrl = `${upstream.baseUrl}${rest}${url.search}`; - const headers = buildForwardHeaders(req, upstream.auth, payload); - await proxyToUpstream(req, res, targetUrl, headers, body); + const headers = buildForwardHeadersToGlobalApi(req, payload); + status = await proxyToGlobalApi(req, res, targetUrl, headers, body); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (!res.headersSent) { - json(res, 400, { error: msg }); + status = 400; + json(res, status, { error: msg }); + } else if (status === 0) { + status = 500; + } + } finally { + const skipLog = + method === "OPTIONS" || + (method === "GET" && (pathname === "/health" || pathname === "/health/")); + if (!skipLog) { + void appendSsoAccessLog({ + method, + path: logPath, + upstream: upstreamKey || undefined, + status, + durationMs: Date.now() - started, + oidcSub, + }); } } })(); diff --git a/services/smart-ide-sso-gateway/src/upstreams.ts b/services/smart-ide-sso-gateway/src/upstreams.ts index 81a41bd..46c069b 100644 --- a/services/smart-ide-sso-gateway/src/upstreams.ts +++ b/services/smart-ide-sso-gateway/src/upstreams.ts @@ -1,88 +1,4 @@ -export type UpstreamAuth = - | { kind: "bearer"; token: string } - | { kind: "header"; name: string; value: string }; - -export type UpstreamTarget = { - baseUrl: string; - auth: UpstreamAuth; -}; - -const trimSlash = (s: string): string => s.replace(/\/+$/, ""); - -const env = (k: string, d: string): string => process.env[k]?.trim() ?? d; - -export const resolveUpstream = (key: string): UpstreamTarget | null => { - switch (key) { - case "orchestrator": - return { - baseUrl: trimSlash( - `http://${env("ORCHESTRATOR_HOST", "127.0.0.1")}:${env("ORCHESTRATOR_PORT", "37145")}`, - ), - auth: { kind: "bearer", token: env("ORCHESTRATOR_TOKEN", "") }, - }; - case "repos_devtools": - return { - baseUrl: trimSlash( - `http://${env("REPOS_DEVTOOLS_HOST", "127.0.0.1")}:${env("REPOS_DEVTOOLS_PORT", "37140")}`, - ), - auth: { kind: "bearer", token: env("REPOS_DEVTOOLS_TOKEN", "") }, - }; - case "ia_dev_gateway": - return { - baseUrl: trimSlash( - `http://${env("IA_DEV_GATEWAY_HOST", "127.0.0.1")}:${env("IA_DEV_GATEWAY_PORT", "37144")}`, - ), - auth: { kind: "bearer", token: env("IA_DEV_GATEWAY_TOKEN", "") }, - }; - case "anythingllm_devtools": - return { - baseUrl: trimSlash( - `http://${env("ANYTHINGLLM_DEVTOOLS_HOST", "127.0.0.1")}:${env("ANYTHINGLLM_DEVTOOLS_PORT", "37146")}`, - ), - auth: { kind: "bearer", token: env("ANYTHINGLLM_DEVTOOLS_TOKEN", "") }, - }; - case "tools_bridge": - return { - baseUrl: trimSlash( - `http://${env("TOOLS_BRIDGE_HOST", "127.0.0.1")}:${env("TOOLS_BRIDGE_PORT", "37147")}`, - ), - auth: { kind: "bearer", token: env("TOOLS_BRIDGE_TOKEN", "") }, - }; - case "langextract": - return { - baseUrl: trimSlash( - `http://${env("LANGEXTRACT_API_HOST", "127.0.0.1")}:${env("LANGEXTRACT_API_PORT", "37141")}`, - ), - auth: { kind: "bearer", token: env("LANGEXTRACT_SERVICE_TOKEN", "") }, - }; - case "regex_search": - return { - baseUrl: trimSlash( - `http://${env("REGEX_SEARCH_HOST", "127.0.0.1")}:${env("REGEX_SEARCH_PORT", "37143")}`, - ), - auth: { kind: "bearer", token: env("REGEX_SEARCH_TOKEN", "") }, - }; - case "claw_proxy": - return { - baseUrl: trimSlash( - `http://${env("CLAW_PROXY_HOST", "127.0.0.1")}:${env("CLAW_PROXY_PORT", "37142")}`, - ), - auth: { kind: "bearer", token: env("CLAW_PROXY_TOKEN", "") }, - }; - case "local_office": - return { - baseUrl: trimSlash(env("LOCAL_OFFICE_URL", "http://127.0.0.1:8000")), - auth: { - kind: "header", - name: "X-API-Key", - value: env("LOCAL_OFFICE_API_KEY", ""), - }, - }; - default: - return null; - } -}; - +/** Upstream keys exposed to OIDC-authenticated clients; traffic is relayed via smart-ide-global-api. */ export const listUpstreamKeys = (): string[] => [ "orchestrator", "repos_devtools",