Compare commits
36
Commits
v1.4.1
..
77f00268ec
+18
-14
@@ -41,26 +41,30 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
# @arboretum/shared (paquet workspace NON publié) est INLINÉ dans dist/_shared au prepack
|
# @arboretum/shared (dépendance runtime non publiée) est embarquée dans le tarball
|
||||||
# (scripts/inline-shared.mjs) : le tarball est 100 % autonome — aucun node_modules embarqué,
|
# via bundleDependencies (matérialisée au prepack). On packe donc UN SEUL tarball
|
||||||
# aucune bundleDependency, aucun symlink. On packe en mode workspace (-w), EXACTEMENT comme
|
# et on l'installe seul, comme un vrai consommateur depuis le registre.
|
||||||
# le fait « npm publish » dans release.yml, puis on l'installe seul comme un vrai consommateur.
|
|
||||||
- name: Pack tarball
|
- name: Pack tarball
|
||||||
run: |
|
run: |
|
||||||
rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs
|
rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs
|
||||||
npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs
|
# Le hook prepack (copy-web + vendor-shared + copy-meta) matérialise
|
||||||
|
# @arboretum/shared comme VRAI dossier dans le node_modules du serveur —
|
||||||
|
# condition pour que npm l'embarque via bundleDependencies. Selon la version
|
||||||
|
# de npm du runner, « npm pack -w » ne déclenche PAS toujours ce lifecycle
|
||||||
|
# (contrairement à « npm publish -w », qui lance bien prepack). On l'exécute
|
||||||
|
# donc explicitement, on vérifie la matérialisation, puis on packe avec
|
||||||
|
# --ignore-scripts (prepack déjà fait, dossier physique présent → bundlé).
|
||||||
|
npm run prepack -w @johanleroy/git-arboretum
|
||||||
|
test -f packages/server/node_modules/@arboretum/shared/dist/index.js \
|
||||||
|
|| { echo "ERREUR: prepack n'a pas matérialisé @arboretum/shared/dist/index.js"; exit 1; }
|
||||||
|
npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs --ignore-scripts
|
||||||
ls -l /tmp/tarballs
|
ls -l /tmp/tarballs
|
||||||
- name: Assert the package is self-contained (@arboretum/shared inlined)
|
- name: Assert @arboretum/shared is bundled in the tarball
|
||||||
run: |
|
run: |
|
||||||
tgz=$(ls /tmp/tarballs/*.tgz)
|
tgz=$(ls /tmp/tarballs/*.tgz)
|
||||||
rm -rf /tmp/inspect && mkdir -p /tmp/inspect && tar -xzf "$tgz" -C /tmp/inspect
|
tar -tzf "$tgz" | grep -q 'node_modules/@arboretum/shared/dist/index.js' \
|
||||||
test -f /tmp/inspect/package/dist/_shared/index.js \
|
|| { echo "ERREUR: @arboretum/shared non embarqué dans $tgz"; exit 1; }
|
||||||
|| { echo "ERREUR: dist/_shared/index.js absent de $tgz — inline-shared n'a pas tourné ?"; exit 1; }
|
echo "OK: bundleDependency @arboretum/shared présente dans $tgz"
|
||||||
if grep -rq '@arboretum/shared' /tmp/inspect/package/dist; then
|
|
||||||
echo "ERREUR: import bare '@arboretum/shared' encore présent dans le JS publié"
|
|
||||||
grep -rn '@arboretum/shared' /tmp/inspect/package/dist; exit 1
|
|
||||||
fi
|
|
||||||
echo "OK: paquet autonome — shared inliné dans dist/_shared, aucun import externe"
|
|
||||||
- name: Install tarball in an empty project
|
- name: Install tarball in an empty project
|
||||||
run: |
|
run: |
|
||||||
mkdir /tmp/smoke
|
mkdir /tmp/smoke
|
||||||
|
|||||||
@@ -35,39 +35,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: tag $tag == version $pkg"
|
echo "OK: tag $tag == version $pkg"
|
||||||
# Publication idempotente : le signal faisant autorité d'une version déjà présente est le
|
- run: npm publish -w @johanleroy/git-arboretum
|
||||||
# 409 « already exists » renvoyé par npm publish lui-même (npm view est non fiable contre le
|
|
||||||
# registre npm de Gitea — faux négatif masqué par >/dev/null). On tente toujours le publish ;
|
|
||||||
# un 409 = succès idempotent, tout autre échec reste fatal. Le shell Actions tourne en
|
|
||||||
# `bash -eo pipefail` : on isole l'échec attendu dans la condition d'un `if` pour ne pas
|
|
||||||
# déclencher `set -e`. Le secret du registre est mappé sur NODE_AUTH_TOKEN lu par le .npmrc
|
|
||||||
# de setup-node.
|
|
||||||
- name: Publish (idempotent — tolère un 409 « already exists »)
|
|
||||||
run: |
|
|
||||||
if out="$(npm publish -w @johanleroy/git-arboretum 2>&1)"; then
|
|
||||||
printf '%s\n' "$out"
|
|
||||||
echo "Publication réussie."
|
|
||||||
else
|
|
||||||
code=$?
|
|
||||||
printf '%s\n' "$out"
|
|
||||||
if printf '%s' "$out" | grep -qiE 'E409|409 Conflict|already exists'; then
|
|
||||||
echo "::notice::Version déjà présente sur le registre (409) — publication idempotente, étape ignorée."
|
|
||||||
else
|
|
||||||
echo "::error::Échec de la publication (code $code)."
|
|
||||||
exit "$code"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
env:
|
env:
|
||||||
|
# Mapper le secret du registre (PAT Gitea write:package, ou le token auto
|
||||||
|
# GITEA_TOKEN s'il a ce scope) sur NODE_AUTH_TOKEN lu par le .npmrc de setup-node.
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
# SBOM (transparence supply-chain entreprise) : généré pour le paquet publié et exposé en
|
|
||||||
# artefact. C'est un bonus : un échec de génération/upload ne doit jamais faire rougir une
|
|
||||||
# release dont le publish a réussi → continue-on-error.
|
|
||||||
# upload-artifact@v3 : Gitea Actions (GHES-like) ne supporte pas @v4 (@actions/artifact v2+).
|
|
||||||
- name: Generate SBOM (CycloneDX)
|
|
||||||
continue-on-error: true
|
|
||||||
run: npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json -w @johanleroy/git-arboretum || npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
name: sbom
|
|
||||||
path: sbom.json
|
|
||||||
|
|||||||
@@ -209,13 +209,9 @@ Un terminal web, c'est de l'exécution de code à distance *par conception*. Les
|
|||||||
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
||||||
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
||||||
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel.
|
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel.
|
||||||
- Envoie des en-têtes HTTP durcis (CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, HSTS conditionnel, `no-store` sur l'API), restreint le dossier de données à `0o700` et la base à `0o600`, et **chiffre les secrets sensibles au repos** (AES-256-GCM).
|
|
||||||
- Tient un **journal d'audit** des opérations sensibles et offre l'**export/effacement RGPD** des données (Réglages → Sécurité & conformité).
|
|
||||||
|
|
||||||
Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet.
|
Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet.
|
||||||
|
|
||||||
Voir [`SECURITY.md`](SECURITY.md) pour le modèle de menace complet et [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) pour le durcissement en environnement réglementé.
|
|
||||||
|
|
||||||
## Ce qui le distingue
|
## Ce qui le distingue
|
||||||
|
|
||||||
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
@@ -255,12 +251,6 @@ node packages/server/scripts/acceptance-p4.mjs # Web Push + commande WS `answe
|
|||||||
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
## Soutenir le projet
|
|
||||||
|
|
||||||
Arboretum est un projet personnel libre et auto-financé. S'il vous fait gagner du temps, vous pouvez soutenir son développement :
|
|
||||||
|
|
||||||
[](https://buymeacoffee.com/johanleroy)
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT — voir [LICENSE](LICENSE).
|
MIT — voir [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -209,13 +209,9 @@ A web terminal is remote code execution *by design*. Arboretum's guardrails are
|
|||||||
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
||||||
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
||||||
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff.
|
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff.
|
||||||
- Sends hardened HTTP headers (CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, conditional HSTS, `no-store` on the API), restricts the data directory to `0o700` and the database to `0o600`, and **encrypts sensitive secrets at rest** (AES-256-GCM).
|
|
||||||
- Keeps an **audit log** of sensitive operations and offers **GDPR** data export/erasure (Settings → Security & compliance).
|
|
||||||
|
|
||||||
Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet.
|
Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet.
|
||||||
|
|
||||||
See [`SECURITY.md`](SECURITY.md) for the full threat model and [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) for hardening in regulated environments.
|
|
||||||
|
|
||||||
## What makes it different
|
## What makes it different
|
||||||
|
|
||||||
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
@@ -255,12 +251,6 @@ node packages/server/scripts/acceptance-p4.mjs # Web Push + WS `answer` comman
|
|||||||
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Arboretum is a free, self-funded side project. If it saves you time, you can support its development:
|
|
||||||
|
|
||||||
[](https://buymeacoffee.com/johanleroy)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
Arboretum is a self-hosted daemon that serves a web dashboard to drive git worktrees and the
|
|
||||||
Claude Code sessions running on them. **A web terminal is remote code execution by design** — that
|
|
||||||
is the product, not a bug. Arboretum's security model is therefore built on *structural* guards
|
|
||||||
(loopback-only binding, authenticated access, strict Origin checks) far more than on cryptography
|
|
||||||
alone.
|
|
||||||
|
|
||||||
This document describes the threat model, the controls that are implemented, the deliberate
|
|
||||||
trade-offs, and how to report a vulnerability. For hardening a deployment in a regulated
|
|
||||||
environment, see [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md).
|
|
||||||
|
|
||||||
## Threat model
|
|
||||||
|
|
||||||
- **Single-user by design.** Arboretum runs on the owner's machine and is meant for one operator.
|
|
||||||
There is no multi-tenant isolation and no RBAC — and none is claimed.
|
|
||||||
- **Loopback by default.** The server binds `127.0.0.1`; `config.ts` *refuses* any non-loopback bind
|
|
||||||
unless you pass `--i-know-this-exposes-a-terminal`. Remote access is expected via **Tailscale Serve**
|
|
||||||
(TLS + tailnet identity), never by opening a port.
|
|
||||||
- **The terminal is RCE.** Anyone who can authenticate can run code. The controls below exist to make
|
|
||||||
sure only the authenticated operator reaches it, and that the surrounding surface (cookies, headers,
|
|
||||||
data at rest) is hardened.
|
|
||||||
|
|
||||||
## Implemented controls
|
|
||||||
|
|
||||||
| Area | Control | Where |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Network | Loopback-only default; non-loopback refused without explicit flag | `packages/server/src/config.ts` |
|
|
||||||
| AuthN | Global guard on **all** `/api/**` and `/ws` (only login is public, and rate-limited) | `packages/server/src/app.ts` |
|
|
||||||
| AuthN | Tokens stored **hashed** (SHA-256), compared in constant time; bootstrap token shown once | `packages/server/src/auth/service.ts` |
|
|
||||||
| Sessions | Cookie is an HMAC-SHA256 signed payload, `HttpOnly` + `SameSite=Strict`, `Secure` when HTTPS | `packages/server/src/routes/auth.ts` |
|
|
||||||
| CSRF / WS | Strict `Origin` check on every `/api/**` and `/ws` request (anti cross-site WS hijacking) | `packages/server/src/app.ts` |
|
|
||||||
| CSRF | Mutations carrying a body must be `application/json` | `packages/server/src/app.ts` |
|
|
||||||
| Rate limit | Global login rate limit with exponential backoff (not per-IP — Tailscale fronts everything as 127.0.0.1) | `packages/server/src/auth/service.ts` |
|
|
||||||
| HTTP headers | CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, conditional HSTS, `Cache-Control: no-store` on API; `Server` header stripped | `packages/server/src/app.ts` |
|
|
||||||
| Injection | All SQL is parameterized; all git calls use `execFile` (no shell); path-traversal guards | `packages/server/src/**` |
|
|
||||||
| Data at rest | DB file/dir forced to `0o600`/`0o700`; sensitive secrets (server secret, VAPID private key) encrypted with AES-256-GCM | `packages/server/src/db/index.ts`, `core/secret-box.ts` |
|
|
||||||
| Audit | Persistent audit log of sensitive mutations (tokens, settings, secrets, push, groups) | `packages/server/src/core/audit-log.ts` |
|
|
||||||
| Privacy | GDPR export (`/api/v1/data/export`) and erasure (`/api/v1/data/delete-my-data`) | `packages/server/src/routes/data.ts` |
|
|
||||||
| Install | Runs as a **user** service (systemd user unit / launchd LaunchAgent), never root | `packages/server/src/cli/install.ts` |
|
|
||||||
|
|
||||||
## Deliberate trade-offs
|
|
||||||
|
|
||||||
- **Long-lived API tokens.** Tokens do not expire by age (CLI automation stability) but can be revoked
|
|
||||||
instantly, and `last_used_at` is tracked. Review and rotate tokens periodically.
|
|
||||||
- **Encryption-at-rest key management.** With no `ARBORETUM_SECRET_KEY` set, the encryption key lives in
|
|
||||||
`dataDir/secret.key` (`0o600`) next to the database — this protects a leaked database *copy* (backup,
|
|
||||||
WAL) but not a full `dataDir` compromise. For strong protection, set `ARBORETUM_SECRET_KEY` and store it
|
|
||||||
separately from database backups. Full-DB SQLCipher is intentionally avoided (it breaks the `npx`
|
|
||||||
prebuilt portability).
|
|
||||||
- **No multi-user model.** If you need multiple operators with isolation, Arboretum is not the right tool.
|
|
||||||
|
|
||||||
## Reporting a vulnerability
|
|
||||||
|
|
||||||
Please report security issues **privately** — do not open a public issue.
|
|
||||||
|
|
||||||
- Email: **security@johanleroy.fr** (or `contact@johanleroy.fr`).
|
|
||||||
- Include a description, affected version, and reproduction steps.
|
|
||||||
- Expect an acknowledgement within **7 days** and a coordinated disclosure window of up to **90 days**.
|
|
||||||
|
|
||||||
Thank you for helping keep Arboretum users safe.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# Enterprise deployment guide
|
|
||||||
|
|
||||||
This guide complements [`../SECURITY.md`](../SECURITY.md) with the operational steps a regulated or
|
|
||||||
security-conscious organization needs to deploy Arboretum with confidence.
|
|
||||||
|
|
||||||
## 1. Remote access: Tailscale Serve (recommended)
|
|
||||||
|
|
||||||
Never open a public port. Keep the default `127.0.0.1` bind and put Arboretum behind Tailscale Serve:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# on the host running arboretum (default bind 127.0.0.1:7317)
|
|
||||||
tailscale serve --bg 7317
|
|
||||||
```
|
|
||||||
|
|
||||||
This gives you TLS, a stable `*.ts.net` hostname, and tailnet identity. Then add that origin to the
|
|
||||||
allow-list so the strict Origin check accepts it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
arboretum --allow-origin https://my-host.tailnet.ts.net
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--i-know-this-exposes-a-terminal` flag exists only as an escape hatch for non-loopback binds; it
|
|
||||||
is **never** a deployment mode and is never injected automatically by `arboretum install`.
|
|
||||||
|
|
||||||
## 2. Encryption at rest
|
|
||||||
|
|
||||||
Sensitive secrets (the HMAC server secret and the VAPID private key) are encrypted with AES-256-GCM
|
|
||||||
before being stored in SQLite. Token values are never stored — only their SHA-256 hashes.
|
|
||||||
|
|
||||||
For **strong** protection (key not co-located with the database), provide a passphrase via the
|
|
||||||
environment instead of the on-disk key file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ARBORETUM_SECRET_KEY='<a long random passphrase from your secrets manager>' arboretum
|
|
||||||
```
|
|
||||||
|
|
||||||
- Store this passphrase in your secrets manager / KMS, **separately** from database backups.
|
|
||||||
- Without it, the key is auto-generated in `dataDir/secret.key` (`0o600`). This still protects a leaked
|
|
||||||
database copy, but not a full `dataDir` compromise.
|
|
||||||
- Rotating the passphrase requires re-encrypting existing values; the simplest path is to revoke and
|
|
||||||
recreate the bootstrap token after rotation.
|
|
||||||
|
|
||||||
## 3. Filesystem permissions
|
|
||||||
|
|
||||||
On startup Arboretum forces `dataDir` to `0o700` and the database files (`*.db`, `-wal`, `-shm`) to
|
|
||||||
`0o600`. Verify after first run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stat -c '%a %n' ~/.local/share/arboretum ~/.local/share/arboretum/*.db
|
|
||||||
# expect: 700 …/arboretum and 600 …/arboretum.db
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep `$HOME` private (standard `0o700`). If you relocate data with `--db` or `XDG_DATA_HOME`, make sure
|
|
||||||
the target directory is not world-readable.
|
|
||||||
|
|
||||||
## 4. Audit logging
|
|
||||||
|
|
||||||
All sensitive mutations are recorded in an append-only `audit_logs` table: token create/revoke, login
|
|
||||||
success/failure, settings changes, secret generation, push subscribe/unsubscribe, group CRUD, and data
|
|
||||||
erasure. Query it via the API (paginated):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
'http://127.0.0.1:7317/api/v1/audit-logs?limit=100'
|
|
||||||
```
|
|
||||||
|
|
||||||
The audit log never contains secret values — only non-sensitive metadata (ids, labels, counters). It is
|
|
||||||
also visible in the dashboard under **Settings → Security & compliance**.
|
|
||||||
|
|
||||||
## 5. GDPR (data subject requests)
|
|
||||||
|
|
||||||
- **Export**: `GET /api/v1/data/export` returns every record tied to the authenticated token (token
|
|
||||||
metadata, push subscriptions, session history, settings) as JSON. Also available as a one-click
|
|
||||||
download in **Settings → Security & compliance**.
|
|
||||||
- **Erasure**: `POST /api/v1/data/delete-my-data` is a two-step call — the first response returns a
|
|
||||||
`confirm` code that must be POSTed back to execute. It purges the token's push subscriptions and
|
|
||||||
revokes the token (unless it is the last active one).
|
|
||||||
|
|
||||||
## 6. Backups & retention
|
|
||||||
|
|
||||||
- Back up the SQLite database (`arboretum.db`) with the WAL checkpointed. Treat backups as sensitive.
|
|
||||||
- If you use `ARBORETUM_SECRET_KEY`, back the key up **separately** — a database backup is useless (and
|
|
||||||
safe) without it, which is the point.
|
|
||||||
- Session history is retained until the database is reset. To start clean, stop the service and remove
|
|
||||||
the database file.
|
|
||||||
|
|
||||||
## 7. Logging hygiene
|
|
||||||
|
|
||||||
The log level is controlled by `ARBORETUM_LOG` (default `info`). Do **not** run `debug`/`trace` in
|
|
||||||
production: verbose levels may log request metadata. Keep `info` or higher.
|
|
||||||
|
|
||||||
## 8. Supply chain
|
|
||||||
|
|
||||||
Each published release ships with a CycloneDX SBOM (`sbom.json`) generated in CI, so you can scan the
|
|
||||||
dependency tree for known vulnerabilities before deploying.
|
|
||||||
Generated
+5
-6
@@ -4840,9 +4840,13 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.4.1",
|
"version": "1.3.0",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@arboretum/shared"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@arboretum/shared": "0.1.0",
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
@@ -4855,16 +4859,11 @@
|
|||||||
"arboretum": "dist/index.js"
|
"arboretum": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.0"
|
"@types/ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.16"
|
"node": ">=22.16"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "buymeacoffee",
|
|
||||||
"url": "https://buymeacoffee.com/johanleroy"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.4.1",
|
"version": "1.3.0",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,10 +14,6 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://git.lidge.fr/johanleroy/arboretum/issues"
|
"url": "https://git.lidge.fr/johanleroy/arboretum/issues"
|
||||||
},
|
},
|
||||||
"funding": {
|
|
||||||
"type": "buymeacoffee",
|
|
||||||
"url": "https://buymeacoffee.com/johanleroy"
|
|
||||||
},
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"git",
|
"git",
|
||||||
"worktree",
|
"worktree",
|
||||||
@@ -48,10 +44,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"dev": "tsc -b --watch & node --watch dist/index.js",
|
"dev": "tsc -b --watch & node --watch dist/index.js",
|
||||||
"prepack": "node scripts/copy-web.mjs && node scripts/inline-shared.mjs && node scripts/copy-meta.mjs",
|
"prepack": "node scripts/copy-web.mjs && node scripts/vendor-shared.mjs && node scripts/copy-meta.mjs",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@arboretum/shared"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@arboretum/shared": "0.1.0",
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.0"
|
"@types/ws": "^8.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Inline @arboretum/shared (paquet workspace NON publié) directement dans le dist du serveur,
|
|
||||||
// pour que le tarball npm soit 100 % autonome. Branché sur le hook "prepack".
|
|
||||||
//
|
|
||||||
// Pourquoi PAS bundleDependencies : embarquer une dépendance qui est aussi un *workspace* via
|
|
||||||
// bundleDependencies est instable selon l'environnement npm (mode -w, exécution en root sur un
|
|
||||||
// runner CI, version d'arborist) — npm voit le nœud comme un lien workspace et n'embarque parfois
|
|
||||||
// AUCUN fichier ("bundled files: 0"), produisant un paquet cassé chez le consommateur. On élimine
|
|
||||||
// donc toute magie de bundling : on copie le JS compilé de shared dans dist/_shared et on réécrit
|
|
||||||
// l'import bare '@arboretum/shared' du serveur vers ce chemin relatif. Zéro node_modules embarqué,
|
|
||||||
// zéro symlink, résultat identique partout.
|
|
||||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join, dirname, relative, sep } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const serverDist = join(serverDir, 'dist');
|
|
||||||
const sharedDist = join(serverDir, '..', 'shared', 'dist');
|
|
||||||
const inlineDir = join(serverDist, '_shared');
|
|
||||||
|
|
||||||
for (const [label, p] of [['dist serveur', serverDist], ['dist shared', sharedDist]]) {
|
|
||||||
if (!existsSync(p)) {
|
|
||||||
console.error(`inline-shared: ${label} introuvable (${p}) — lance "npm run build" avant le pack.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Copier le JS compilé de shared dans dist/_shared (uniquement *.js : seul le runtime compte ;
|
|
||||||
// les .d.ts/.map ne sont de toute façon pas publiés via le glob `files`). index.js réexporte
|
|
||||||
// ./protocol.js et ./api.js en relatif → la copie complète préserve la résolution interne.
|
|
||||||
rmSync(inlineDir, { recursive: true, force: true });
|
|
||||||
mkdirSync(inlineDir, { recursive: true });
|
|
||||||
let copied = 0;
|
|
||||||
for (const name of readdirSync(sharedDist)) {
|
|
||||||
if (name.endsWith('.js')) {
|
|
||||||
cpSync(join(sharedDist, name), join(inlineDir, name));
|
|
||||||
copied++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (copied === 0) {
|
|
||||||
console.error(`inline-shared: aucun .js dans ${sharedDist} — shared n'est pas compilé.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Réécrire l'import bare '@arboretum/shared' de chaque .js du serveur vers le chemin relatif
|
|
||||||
// (POSIX) pointant sur dist/_shared/index.js, calculé par fichier (profondeur variable).
|
|
||||||
const walk = (dir) =>
|
|
||||||
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
||||||
const p = join(dir, e.name);
|
|
||||||
if (e.isDirectory()) return p === inlineDir ? [] : walk(p); // ne pas se réécrire soi-même
|
|
||||||
return e.name.endsWith('.js') ? [p] : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
let rewritten = 0;
|
|
||||||
for (const file of walk(serverDist)) {
|
|
||||||
const src = readFileSync(file, 'utf8');
|
|
||||||
if (!src.includes('@arboretum/shared')) continue;
|
|
||||||
let rel = relative(dirname(file), join(inlineDir, 'index.js')).split(sep).join('/');
|
|
||||||
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
||||||
const out = src.replaceAll(`'@arboretum/shared'`, `'${rel}'`).replaceAll(`"@arboretum/shared"`, `"${rel}"`);
|
|
||||||
if (out !== src) {
|
|
||||||
writeFileSync(file, out);
|
|
||||||
rewritten++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rewritten === 0) {
|
|
||||||
console.error(`inline-shared: aucun import '@arboretum/shared' réécrit dans ${serverDist} — build manquant ?`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`inline-shared: ${copied} fichier(s) shared -> dist/_shared, import réécrit dans ${rewritten} fichier(s) serveur`);
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Embarque @arboretum/shared (dépendance runtime non publiée) DANS le tarball npm.
|
||||||
|
// Branché sur le hook "prepack", aux côtés de bundleDependencies dans package.json.
|
||||||
|
//
|
||||||
|
// Pourquoi un vrai dossier et pas le symlink workspace : sous npm workspaces, shared
|
||||||
|
// est seulement symlinké dans le node_modules racine ; `npm pack` n'embarque une
|
||||||
|
// bundleDependency que si elle existe comme VRAI dossier dans le node_modules du
|
||||||
|
// paquet packé (packages/server/node_modules/@arboretum/shared). On le matérialise ici.
|
||||||
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const sharedDir = join(serverDir, '..', 'shared');
|
||||||
|
const sharedDist = join(sharedDir, 'dist');
|
||||||
|
const target = join(serverDir, 'node_modules', '@arboretum', 'shared');
|
||||||
|
|
||||||
|
if (!existsSync(sharedDist)) {
|
||||||
|
console.error(
|
||||||
|
`vendor-shared: ${sharedDist} introuvable — lance "npm run build" (shared doit être compilé) avant le pack.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// package.json minimal et déterministe : on reprend les champs de résolution réels de
|
||||||
|
// shared (version incluse, pour rester synchro), sans scripts ni files inutiles au runtime.
|
||||||
|
const shared = JSON.parse(readFileSync(join(sharedDir, 'package.json'), 'utf8'));
|
||||||
|
const minimal = {
|
||||||
|
name: shared.name,
|
||||||
|
version: shared.version,
|
||||||
|
type: shared.type,
|
||||||
|
main: shared.main,
|
||||||
|
types: shared.types,
|
||||||
|
exports: shared.exports,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idempotent : on repart d'un dossier propre à chaque pack.
|
||||||
|
rmSync(target, { recursive: true, force: true });
|
||||||
|
mkdirSync(target, { recursive: true });
|
||||||
|
writeFileSync(join(target, 'package.json'), JSON.stringify(minimal, null, 2) + '\n');
|
||||||
|
cpSync(sharedDist, join(target, 'dist'), { recursive: true });
|
||||||
|
|
||||||
|
console.log(`vendor-shared: embarqué ${shared.name}@${shared.version} -> ${target}`);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
|
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import fastifyWebsocket from '@fastify/websocket';
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
@@ -14,7 +14,6 @@ import { WorktreeManager } from './core/worktree-manager.js';
|
|||||||
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
||||||
import { GroupManager } from './core/group-manager.js';
|
import { GroupManager } from './core/group-manager.js';
|
||||||
import { PushService } from './core/push-service.js';
|
import { PushService } from './core/push-service.js';
|
||||||
import { loadSecretBox } from './core/secret-box.js';
|
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerRepoRoutes } from './routes/repos.js';
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
@@ -23,37 +22,7 @@ import { registerWorktreeRoutes } from './routes/worktrees.js';
|
|||||||
import { registerPushRoutes } from './routes/push.js';
|
import { registerPushRoutes } from './routes/push.js';
|
||||||
import { registerSettingsRoutes } from './routes/settings.js';
|
import { registerSettingsRoutes } from './routes/settings.js';
|
||||||
import { registerFsRoutes } from './routes/fs.js';
|
import { registerFsRoutes } from './routes/fs.js';
|
||||||
import { registerAuditRoutes } from './routes/audit.js';
|
|
||||||
import { registerDataRoutes } from './routes/data.js';
|
|
||||||
import { registerWsGateway } from './ws/gateway.js';
|
import { registerWsGateway } from './ws/gateway.js';
|
||||||
import { isHttpsRequest } from './routes/auth.js';
|
|
||||||
|
|
||||||
// En-têtes de sécurité posés sur TOUTES les réponses (defense-in-depth + conformité scanners
|
|
||||||
// entreprise). Le terminal web reste du RCE par conception : ces en-têtes durcissent la SPA et
|
|
||||||
// le transport, ils ne remplacent pas le modèle loopback + auth + Origin.
|
|
||||||
const SECURITY_HEADERS: Record<string, string> = {
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
'X-Frame-Options': 'DENY',
|
|
||||||
'Referrer-Policy': 'no-referrer',
|
|
||||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
||||||
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()',
|
|
||||||
// CSP calibrée pour la SPA : Tailwind/xterm injectent du style inline ; le WebSocket impose
|
|
||||||
// ws:/wss: en connect-src ; le service worker push impose worker-src 'self'.
|
|
||||||
'Content-Security-Policy': [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self'",
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"img-src 'self' data:",
|
|
||||||
"font-src 'self' data:",
|
|
||||||
"connect-src 'self' ws: wss:",
|
|
||||||
"worker-src 'self'",
|
|
||||||
"manifest-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"object-src 'none'",
|
|
||||||
"form-action 'self'",
|
|
||||||
].join('; '),
|
|
||||||
};
|
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
@@ -77,11 +46,9 @@ export interface AppBundle {
|
|||||||
|
|
||||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||||
// Chiffrement au repos des secrets (server_secret, clé privée VAPID) dans la base.
|
const auth = new AuthService(db);
|
||||||
const box = loadSecretBox(config.dataDir);
|
|
||||||
const auth = new AuthService(db, box);
|
|
||||||
const limiter = new LoginRateLimiter();
|
const limiter = new LoginRateLimiter();
|
||||||
const push = new PushService(db, config.vapidContact, undefined, box);
|
const push = new PushService(db, config.vapidContact);
|
||||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||||
const discovery = new DiscoveryService({
|
const discovery = new DiscoveryService({
|
||||||
ptyManager: manager,
|
ptyManager: manager,
|
||||||
@@ -93,43 +60,6 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
||||||
const groups = new GroupManager(db);
|
const groups = new GroupManager(db);
|
||||||
|
|
||||||
// En-têtes de sécurité sur toute réponse + no-store sur les réponses sensibles (API/WS).
|
|
||||||
// onSend DOIT retourner le payload (sinon Fastify vide la réponse).
|
|
||||||
app.addHook('onSend', async (req, reply, payload) => {
|
|
||||||
reply.removeHeader('Server'); // anti-fingerprinting (no-op si absent)
|
|
||||||
for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v);
|
|
||||||
if (isHttpsRequest(req)) {
|
|
||||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
||||||
}
|
|
||||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
|
||||||
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Garde CSRF defense-in-depth : une mutation porteuse d'un corps DOIT être en application/json
|
|
||||||
// (bloque les soumissions de formulaire cross-site en text/plain, que Fastify parserait sinon).
|
|
||||||
app.addHook('preHandler', async (req, reply) => {
|
|
||||||
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return;
|
|
||||||
const len = req.headers['content-length'];
|
|
||||||
if (!len || len === '0') return; // pas de corps : rien à valider
|
|
||||||
const ct = (req.headers['content-type'] ?? '').toLowerCase();
|
|
||||||
if (!ct.startsWith('application/json')) {
|
|
||||||
return reply.status(415).send({ error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Content-Type must be application/json' } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handler d'erreur : ne jamais fuiter de stack/chemin au client ; détail complet côté logs.
|
|
||||||
app.setErrorHandler((err: FastifyError, req, reply) => {
|
|
||||||
const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 500;
|
|
||||||
if (status >= 500) {
|
|
||||||
req.log.error({ err }, 'unhandled error');
|
|
||||||
return reply.status(status).send({ error: { code: 'INTERNAL', message: 'Internal Server Error' } });
|
|
||||||
}
|
|
||||||
// erreurs client (4xx) : code générique, message court non sensible.
|
|
||||||
return reply.status(status).send({ error: { code: err.code ?? 'BAD_REQUEST', message: err.message } });
|
|
||||||
});
|
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
options: { maxPayload: 1024 * 1024 },
|
options: { maxPayload: 1024 * 1024 },
|
||||||
@@ -167,16 +97,14 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups, db);
|
registerGroupRoutes(app, groups);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
registerPushRoutes(app, push, db);
|
registerPushRoutes(app, push);
|
||||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||||
registerFsRoutes(app);
|
registerFsRoutes(app);
|
||||||
registerAuditRoutes(app, db);
|
|
||||||
registerDataRoutes(app, db, auth);
|
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
void app.register(async (scoped) => {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||||
import type { SecretBox } from '../core/secret-box.js';
|
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'arb_session';
|
const COOKIE_NAME = 'arb_session';
|
||||||
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
||||||
@@ -14,21 +12,11 @@ export interface AuthContext {
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly secret: Buffer;
|
private readonly secret: Buffer;
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly db: Db) {
|
||||||
private readonly db: Db,
|
let secretHex = getSetting(db, 'server_secret');
|
||||||
box?: SecretBox,
|
if (!secretHex) {
|
||||||
) {
|
|
||||||
// server_secret chiffré au repos quand un SecretBox est fourni (prod). Migration douce :
|
|
||||||
// une valeur en clair pré-existante est re-chiffrée à la lecture.
|
|
||||||
const stored = getSetting(db, 'server_secret');
|
|
||||||
let secretHex: string;
|
|
||||||
if (!stored) {
|
|
||||||
secretHex = randomBytes(32).toString('hex');
|
secretHex = randomBytes(32).toString('hex');
|
||||||
setSetting(db, 'server_secret', box ? box.encrypt(secretHex) : secretHex);
|
setSetting(db, 'server_secret', secretHex);
|
||||||
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'server_secret' });
|
|
||||||
} else {
|
|
||||||
secretHex = box ? box.decrypt(stored) : stored;
|
|
||||||
if (box && !box.isEncrypted(stored)) setSetting(db, 'server_secret', box.encrypt(secretHex));
|
|
||||||
}
|
}
|
||||||
this.secret = Buffer.from(secretHex, 'hex');
|
this.secret = Buffer.from(secretHex, 'hex');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { chmodSync, mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -53,14 +53,6 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
|
|
||||||
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
// La DB contient des secrets (server_secret, clé privée VAPID, hashs de tokens) : le dossier de
|
|
||||||
// données ne doit jamais être lisible par d'autres utilisateurs du système. chmod best-effort
|
|
||||||
// (peut échouer sur certains FS Windows/montés ; le démarrage avertit alors sans bloquer).
|
|
||||||
try {
|
|
||||||
chmodSync(dataDir, 0o700);
|
|
||||||
} catch {
|
|
||||||
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
|
|
||||||
}
|
|
||||||
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
||||||
return {
|
return {
|
||||||
port: Number(values.port),
|
port: Number(values.port),
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
// Journal d'audit : trace persistante des opérations sensibles (création/révocation de tokens,
|
|
||||||
// changements de réglages, génération de secrets, abonnements push, CRUD groupes). Exigence de
|
|
||||||
// conformité entreprise (GDPR/SOX/ISO 27001). Règle ABSOLUE : ne JAMAIS journaliser un secret en
|
|
||||||
// clair — `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import type { AuditLogEntry } from '@arboretum/shared';
|
|
||||||
import type { Db } from '../db/index.js';
|
|
||||||
|
|
||||||
export type AuditResult = 'ok' | 'denied' | 'error';
|
|
||||||
|
|
||||||
export interface AuditEntry {
|
|
||||||
/** tokenId de l'acteur, ou 'system' (opérations automatiques), ou 'anonymous' (avant auth). */
|
|
||||||
actor: string;
|
|
||||||
/** verbe.objet, ex. 'token.create', 'settings.update', 'login.failure'. */
|
|
||||||
action: string;
|
|
||||||
resourceId?: string | null;
|
|
||||||
details?: Record<string, unknown> | null;
|
|
||||||
result?: AuditResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enregistre une entrée d'audit. Best-effort : une erreur d'écriture ne casse jamais l'opération métier. */
|
|
||||||
export function recordAudit(db: Db, e: AuditEntry): void {
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
'INSERT INTO audit_logs (id, ts, actor, action, resource_id, details, result) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
).run(
|
|
||||||
randomUUID(),
|
|
||||||
new Date().toISOString(),
|
|
||||||
e.actor,
|
|
||||||
e.action,
|
|
||||||
e.resourceId ?? null,
|
|
||||||
e.details ? JSON.stringify(e.details) : null,
|
|
||||||
e.result ?? 'ok',
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* l'audit ne doit jamais faire échouer l'action auditée */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Liste paginée par date décroissante (curseur `before` = ts strictement inférieur). */
|
|
||||||
export function listAudit(db: Db, opts: { limit: number; before?: string | null }): AuditLogEntry[] {
|
|
||||||
const limit = Math.min(Math.max(Math.trunc(opts.limit) || 50, 1), 200);
|
|
||||||
const rows = (
|
|
||||||
opts.before
|
|
||||||
? db
|
|
||||||
.prepare(
|
|
||||||
'SELECT id, ts, actor, action, resource_id AS resourceId, details, result FROM audit_logs WHERE ts < ? ORDER BY ts DESC LIMIT ?',
|
|
||||||
)
|
|
||||||
.all(opts.before, limit)
|
|
||||||
: db
|
|
||||||
.prepare(
|
|
||||||
'SELECT id, ts, actor, action, resource_id AS resourceId, details, result FROM audit_logs ORDER BY ts DESC LIMIT ?',
|
|
||||||
)
|
|
||||||
.all(limit)
|
|
||||||
) as Array<{ id: string; ts: string; actor: string; action: string; resourceId: string | null; details: string | null; result: string }>;
|
|
||||||
return rows.map((r) => ({ ...r, details: r.details ? safeParse(r.details) : null }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeParse(s: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(s);
|
|
||||||
} catch {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||||
import type { SecretBox } from './secret-box.js';
|
|
||||||
import { recordAudit } from './audit-log.js';
|
|
||||||
|
|
||||||
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
||||||
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
||||||
@@ -49,22 +47,16 @@ export class PushService {
|
|||||||
private readonly db: Db,
|
private readonly db: Db,
|
||||||
private readonly contact: string = 'mailto:arboretum@localhost',
|
private readonly contact: string = 'mailto:arboretum@localhost',
|
||||||
sender?: PushSender,
|
sender?: PushSender,
|
||||||
box?: SecretBox,
|
|
||||||
) {
|
) {
|
||||||
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
||||||
let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer)
|
let pub = getSetting(db, 'vapid_public');
|
||||||
const storedPriv = getSetting(db, 'vapid_private');
|
let priv = getSetting(db, 'vapid_private');
|
||||||
let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null;
|
|
||||||
if (!pub || !priv) {
|
if (!pub || !priv) {
|
||||||
const keys = webpush.generateVAPIDKeys();
|
const keys = webpush.generateVAPIDKeys();
|
||||||
pub = keys.publicKey;
|
pub = keys.publicKey;
|
||||||
priv = keys.privateKey;
|
priv = keys.privateKey;
|
||||||
setSetting(db, 'vapid_public', pub);
|
setSetting(db, 'vapid_public', pub);
|
||||||
setSetting(db, 'vapid_private', box ? box.encrypt(priv) : priv);
|
setSetting(db, 'vapid_private', priv);
|
||||||
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'vapid' });
|
|
||||||
} else if (box && storedPriv && !box.isEncrypted(storedPriv)) {
|
|
||||||
// migration douce : clé privée pré-existante en clair → re-chiffrée.
|
|
||||||
setSetting(db, 'vapid_private', box.encrypt(priv));
|
|
||||||
}
|
}
|
||||||
this.vapidPublic = pub;
|
this.vapidPublic = pub;
|
||||||
this.vapidPrivate = priv;
|
this.vapidPrivate = priv;
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ interface Frame {
|
|||||||
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
|
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
|
||||||
* Règles :
|
* Règles :
|
||||||
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
||||||
* on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
|
* on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ;
|
||||||
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
|
|
||||||
* conteneur — on l'enregistre ET on continue de descendre pour trouver les repos internes ;
|
|
||||||
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
||||||
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
||||||
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
||||||
@@ -70,13 +68,10 @@ export async function scanForRepos(
|
|||||||
if (seen.has(dir)) continue;
|
if (seen.has(dir)) continue;
|
||||||
seen.add(dir);
|
seen.add(dir);
|
||||||
|
|
||||||
// Un dossier avec `.git` est un repo. En PROFONDEUR (depth > 0) c'est une feuille : on
|
// un repo : on l'enregistre et on ne descend pas.
|
||||||
// l'enregistre sans descendre (on n'ouvre pas les sous-modules/worktrees imbriqués). Mais une
|
|
||||||
// RACINE fournie explicitement (depth 0) est un CONTENEUR de scan : si elle est elle-même un
|
|
||||||
// repo on l'enregistre, puis on CONTINUE de descendre pour découvrir les dépôts qu'elle contient.
|
|
||||||
if (existsSync(join(dir, '.git'))) {
|
if (existsSync(join(dir, '.git'))) {
|
||||||
found.add(dir);
|
found.add(dir);
|
||||||
if (depth > 0) continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (depth >= limits.maxDepth) continue;
|
if (depth >= limits.maxDepth) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
||||||
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
||||||
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
||||||
|
import { homedir } from 'node:os';
|
||||||
import { getSetting } from '../db/index.js';
|
import { getSetting } from '../db/index.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
import { isSafeAbsolutePath } from './git.js';
|
import { isSafeAbsolutePath } from './git.js';
|
||||||
@@ -14,14 +15,12 @@ export const DEFAULT_SCAN_INTERVAL_MIN = 5;
|
|||||||
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
||||||
export const MAX_SCAN_ROOTS = 16;
|
export const MAX_SCAN_ROOTS = 16;
|
||||||
|
|
||||||
/**
|
/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */
|
||||||
* Racines à scanner. Défaut : AUCUNE racine → aucun scan (clean install).
|
|
||||||
* L'utilisateur ajoute ses racines via Réglages → Découverte. Lecture tolérante (JSON malformé → []).
|
|
||||||
*/
|
|
||||||
export function readScanRoots(db: Db): string[] {
|
export function readScanRoots(db: Db): string[] {
|
||||||
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [homedir()];
|
||||||
return normalizeScanRoots(safeParse(raw)) ?? [];
|
const parsed = normalizeScanRoots(safeParse(raw));
|
||||||
|
return parsed && parsed.length > 0 ? parsed : [homedir()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
// Chiffrement au repos des secrets applicatifs (server_secret HMAC, clé privée VAPID) stockés dans
|
|
||||||
// la table `settings`. node:sqlite (DatabaseSync) ne supporte pas sqlcipher → on chiffre au niveau
|
|
||||||
// applicatif les VALEURS sensibles, en AES-256-GCM (authentifié), avant insertion.
|
|
||||||
//
|
|
||||||
// Gestion de clé (par ordre de priorité) :
|
|
||||||
// 1. ARBORETUM_SECRET_KEY (variable d'env) → vraie protection : la clé ne vit pas sur le disque,
|
|
||||||
// donc une fuite de la base seule (backup, WAL) ne révèle pas les secrets ;
|
|
||||||
// 2. sinon, fichier clé `dataDir/secret.key` (0o600), généré au 1er démarrage. Protection partielle :
|
|
||||||
// couvre la fuite de la base seule, PAS celle du dossier de données complet (clé + base ensemble).
|
|
||||||
// Compromis assumé et documenté (docs/ENTERPRISE_DEPLOYMENT.md) : pour une protection forte, fournir
|
|
||||||
// ARBORETUM_SECRET_KEY et sauvegarder cette clé séparément des backups de la base.
|
|
||||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
||||||
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const PREFIX = 'v1:'; // versionne le format ; absence de préfixe = valeur en clair (legacy → migration douce)
|
|
||||||
const SCRYPT_SALT = 'arboretum-secret-box-v1'; // sel fixe : l'entropie vient de la passphrase fournie
|
|
||||||
|
|
||||||
export class SecretBox {
|
|
||||||
constructor(private readonly key: Buffer) {}
|
|
||||||
|
|
||||||
/** Chiffre en `v1:<iv>:<tag>:<ciphertext>` (hex). */
|
|
||||||
encrypt(plaintext: string): string {
|
|
||||||
const iv = randomBytes(12);
|
|
||||||
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
|
||||||
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
return `${PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Déchiffre une valeur `v1:` ; une valeur sans préfixe est retournée telle quelle (clair legacy). */
|
|
||||||
decrypt(stored: string): string {
|
|
||||||
if (!this.isEncrypted(stored)) return stored;
|
|
||||||
const [, ivHex, tagHex, ctHex] = stored.split(':');
|
|
||||||
if (!ivHex || !tagHex || !ctHex) throw new Error('secret-box: format chiffré invalide');
|
|
||||||
const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(ivHex, 'hex'));
|
|
||||||
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
|
||||||
return Buffer.concat([decipher.update(Buffer.from(ctHex, 'hex')), decipher.final()]).toString('utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
isEncrypted(stored: string): boolean {
|
|
||||||
return stored.startsWith(PREFIX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construit le SecretBox de production : clé dérivée d'ARBORETUM_SECRET_KEY si fournie, sinon
|
|
||||||
* d'un fichier clé `dataDir/secret.key` (0o600) généré au besoin.
|
|
||||||
*/
|
|
||||||
export function loadSecretBox(dataDir: string): SecretBox {
|
|
||||||
const passphrase = process.env.ARBORETUM_SECRET_KEY;
|
|
||||||
if (passphrase && passphrase.length > 0) {
|
|
||||||
return new SecretBox(scryptSync(passphrase, SCRYPT_SALT, 32));
|
|
||||||
}
|
|
||||||
const keyPath = join(dataDir, 'secret.key');
|
|
||||||
let keyHex: string;
|
|
||||||
if (existsSync(keyPath)) {
|
|
||||||
keyHex = readFileSync(keyPath, 'utf8').trim();
|
|
||||||
} else {
|
|
||||||
keyHex = randomBytes(32).toString('hex');
|
|
||||||
writeFileSync(keyPath, keyHex + '\n', { mode: 0o600 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
chmodSync(keyPath, 0o600);
|
|
||||||
} catch {
|
|
||||||
/* FS sans permissions POSIX : ignoré */
|
|
||||||
}
|
|
||||||
return new SecretBox(Buffer.from(keyHex, 'hex'));
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { chmodSync, existsSync } from 'node:fs';
|
|
||||||
|
|
||||||
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||||
{
|
{
|
||||||
@@ -100,24 +99,6 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
id: 6,
|
id: 6,
|
||||||
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
|
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations
|
|
||||||
// sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS
|
|
||||||
// de secret en clair — `details` est un JSON de métadonnées non sensibles.
|
|
||||||
id: 7,
|
|
||||||
sql: `
|
|
||||||
CREATE TABLE audit_logs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
ts TEXT NOT NULL,
|
|
||||||
actor TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
resource_id TEXT,
|
|
||||||
details TEXT,
|
|
||||||
result TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_audit_ts ON audit_logs(ts);
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
@@ -126,27 +107,10 @@ export function openDb(path: string): Db {
|
|||||||
const db = new DatabaseSync(path);
|
const db = new DatabaseSync(path);
|
||||||
db.exec('PRAGMA journal_mode = WAL');
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
db.exec('PRAGMA foreign_keys = ON');
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
hardenDbPermissions(path);
|
|
||||||
migrate(db);
|
migrate(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Restreint la base (et ses fichiers WAL/SHM) à 0o600 — proprio uniquement. La base contient des
|
|
||||||
* secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par
|
|
||||||
* d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX.
|
|
||||||
*/
|
|
||||||
function hardenDbPermissions(path: string): void {
|
|
||||||
if (path === ':memory:') return;
|
|
||||||
for (const p of [path, `${path}-wal`, `${path}-shm`]) {
|
|
||||||
try {
|
|
||||||
if (existsSync(p)) chmodSync(p, 0o600);
|
|
||||||
} catch {
|
|
||||||
/* FS sans permissions POSIX (Windows / montage) : ignoré */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrate(db: DatabaseSync): void {
|
function migrate(db: DatabaseSync): void {
|
||||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||||
const applied = new Set(
|
const applied = new Set(
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
// Consultation du journal d'audit (onglet Réglages / outils de conformité). Lecture seule, sous
|
|
||||||
// l'auth globale. Pagination par curseur `before` (ts décroissant).
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { AuditLogsResponse } from '@arboretum/shared';
|
|
||||||
import type { Db } from '../db/index.js';
|
|
||||||
import { listAudit } from '../core/audit-log.js';
|
|
||||||
|
|
||||||
export function registerAuditRoutes(app: FastifyInstance, db: Db): void {
|
|
||||||
app.get('/api/v1/audit-logs', async (req): Promise<AuditLogsResponse> => {
|
|
||||||
const q = req.query as { limit?: string; before?: string };
|
|
||||||
const limit = Math.min(Math.max(Math.trunc(Number(q.limit) || 50), 1), 200);
|
|
||||||
const entries = listAudit(db, { limit, before: q.before ?? null });
|
|
||||||
// s'il reste potentiellement des entrées (page pleine), expose le curseur suivant.
|
|
||||||
const nextBefore = entries.length === limit ? entries[entries.length - 1]!.ts : null;
|
|
||||||
return { entries, nextBefore };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,13 +8,11 @@ import type {
|
|||||||
TokensListResponse,
|
TokensListResponse,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||||
import type { Db } from '../db/index.js';
|
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
|
||||||
|
|
||||||
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||||
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
||||||
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
||||||
export function isHttpsRequest(req: FastifyRequest): boolean {
|
function isHttpsRequest(req: FastifyRequest): boolean {
|
||||||
const xfp = req.headers['x-forwarded-proto'];
|
const xfp = req.headers['x-forwarded-proto'];
|
||||||
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
||||||
return proto === 'https';
|
return proto === 'https';
|
||||||
@@ -25,7 +23,6 @@ export function registerAuthRoutes(
|
|||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
limiter: LoginRateLimiter,
|
limiter: LoginRateLimiter,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
db: Db,
|
|
||||||
): void {
|
): void {
|
||||||
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
||||||
const wait = limiter.check();
|
const wait = limiter.check();
|
||||||
@@ -36,11 +33,9 @@ export function registerAuthRoutes(
|
|||||||
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
limiter.recordFailure();
|
limiter.recordFailure();
|
||||||
recordAudit(db, { actor: 'anonymous', action: 'login.failure', result: 'denied' });
|
|
||||||
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
||||||
}
|
}
|
||||||
limiter.recordSuccess();
|
limiter.recordSuccess();
|
||||||
recordAudit(db, { actor: ctx.tokenId, action: 'login.success' });
|
|
||||||
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -83,22 +78,18 @@ export function registerAuthRoutes(
|
|||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } });
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } });
|
||||||
}
|
}
|
||||||
const { id, token } = auth.createTokenRecord(label);
|
const { id, token } = auth.createTokenRecord(label);
|
||||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'token.create', resourceId: id, details: { label } });
|
|
||||||
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
|
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
|
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = auth.revokeToken(id);
|
const result = auth.revokeToken(id);
|
||||||
const actor = req.authContext?.tokenId ?? 'unknown';
|
|
||||||
if (result === 'not_found') {
|
if (result === 'not_found') {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
|
||||||
}
|
}
|
||||||
if (result === 'last') {
|
if (result === 'last') {
|
||||||
recordAudit(db, { actor, action: 'token.revoke', resourceId: id, result: 'denied', details: { reason: 'last_active_token' } });
|
|
||||||
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
|
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
|
||||||
}
|
}
|
||||||
recordAudit(db, { actor, action: 'token.revoke', resourceId: id });
|
|
||||||
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
|
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
// RGPD (droits d'accès & d'effacement) : export et suppression des données rattachées au token
|
|
||||||
// authentifié. Modèle mono-utilisateur → le détenteur du token EST le sujet des données. Sous l'auth
|
|
||||||
// globale. La suppression se fait en deux temps (code de confirmation) pour éviter les clics accidentels.
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DataExportResponse, DeleteMyDataRequest, DeleteMyDataResponse } from '@arboretum/shared';
|
|
||||||
import type { Db } from '../db/index.js';
|
|
||||||
import type { AuthService } from '../auth/service.js';
|
|
||||||
import { readScanIntervalMin, readScanRoots } from '../core/scan-settings.js';
|
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
|
||||||
|
|
||||||
export function registerDataRoutes(app: FastifyInstance, db: Db, auth: AuthService): void {
|
|
||||||
app.get('/api/v1/data/export', async (req): Promise<DataExportResponse> => {
|
|
||||||
const current = req.authContext!.tokenId;
|
|
||||||
const tokens = auth.listTokens().map((t) => ({ ...t, current: t.id === current }));
|
|
||||||
const pushSubscriptions = db
|
|
||||||
.prepare(
|
|
||||||
'SELECT endpoint, user_agent AS userAgent, created_at AS createdAt, last_ok_at AS lastOkAt FROM push_subscriptions WHERE token_id = ?',
|
|
||||||
)
|
|
||||||
.all(current) as DataExportResponse['pushSubscriptions'];
|
|
||||||
const sessions = db
|
|
||||||
.prepare(
|
|
||||||
'SELECT id, cwd, command, title, created_at AS createdAt, ended_at AS endedAt, exit_code AS exitCode FROM sessions ORDER BY created_at',
|
|
||||||
)
|
|
||||||
.all() as DataExportResponse['sessions'];
|
|
||||||
return {
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
tokens,
|
|
||||||
pushSubscriptions,
|
|
||||||
sessions,
|
|
||||||
settings: { scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db) },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/v1/data/delete-my-data', async (req, reply) => {
|
|
||||||
const current = req.authContext!.tokenId;
|
|
||||||
// code déterministe lié au token (sans état serveur) : renvoyé au 1er appel, exigé au 2nd.
|
|
||||||
const code = createHash('sha256').update(`delete:${current}`).digest('hex').slice(0, 12);
|
|
||||||
const body = (req.body as DeleteMyDataRequest | null) ?? {};
|
|
||||||
const subsCount = (db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE token_id = ?').get(current) as { n: number }).n;
|
|
||||||
|
|
||||||
if (body.confirm !== code) {
|
|
||||||
const res: DeleteMyDataResponse = { status: 'pending', confirm: code, summary: { pushSubscriptions: subsCount, tokenRevoked: false } };
|
|
||||||
return reply.send(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM push_subscriptions WHERE token_id = ?').run(current);
|
|
||||||
// révoque le token courant (sauf si c'est le dernier actif → garde anti lock-out conservée).
|
|
||||||
const revoked = auth.revokeToken(current) === 'ok';
|
|
||||||
recordAudit(db, { actor: current, action: 'data.delete', details: { pushSubscriptions: subsCount, tokenRevoked: revoked } });
|
|
||||||
const res: DeleteMyDataResponse = { status: 'done', summary: { pushSubscriptions: subsCount, tokenRevoked: revoked } };
|
|
||||||
return reply.send(res);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,11 +7,9 @@ import type {
|
|||||||
UpdateGroupRequest,
|
UpdateGroupRequest,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { GroupManager } from '../core/group-manager.js';
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
import type { Db } from '../db/index.js';
|
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
|
||||||
import { sendManagerError } from './repos.js';
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void {
|
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void {
|
||||||
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||||
|
|
||||||
app.get('/api/v1/groups/:id', async (req, reply) => {
|
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||||
@@ -35,7 +33,6 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db:
|
|||||||
...(body.color !== undefined ? { color: body.color } : {}),
|
...(body.color !== undefined ? { color: body.color } : {}),
|
||||||
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
||||||
});
|
});
|
||||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.create', resourceId: group.id, details: { label: group.label } });
|
|
||||||
return reply.status(201).send({ group } satisfies GroupResponse);
|
return reply.status(201).send({ group } satisfies GroupResponse);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
@@ -51,7 +48,6 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db:
|
|||||||
...(body.description !== undefined ? { description: body.description } : {}),
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
...(body.color !== undefined ? { color: body.color } : {}),
|
...(body.color !== undefined ? { color: body.color } : {}),
|
||||||
});
|
});
|
||||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.update', resourceId: id });
|
|
||||||
return reply.send({ group } satisfies GroupResponse);
|
return reply.send({ group } satisfies GroupResponse);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
@@ -63,7 +59,6 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db:
|
|||||||
if (!gm.deleteGroup(id)) {
|
if (!gm.deleteGroup(id)) {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
||||||
}
|
}
|
||||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.delete', resourceId: id });
|
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
||||||
import type { PushService } from '../core/push-service.js';
|
import type { PushService } from '../core/push-service.js';
|
||||||
import type { Db } from '../db/index.js';
|
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
|
||||||
|
|
||||||
export function registerPushRoutes(app: FastifyInstance, push: PushService, db: Db): void {
|
export function registerPushRoutes(app: FastifyInstance, push: PushService): void {
|
||||||
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
||||||
|
|
||||||
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
||||||
@@ -17,7 +15,6 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService, db:
|
|||||||
// garanti non-null : la route est protégée par le preValidation global.
|
// garanti non-null : la route est protégée par le preValidation global.
|
||||||
const tokenId = req.authContext!.tokenId;
|
const tokenId = req.authContext!.tokenId;
|
||||||
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
|
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
|
||||||
recordAudit(db, { actor: tokenId, action: 'push.subscribe' });
|
|
||||||
return reply.status(201).send({ ok: true });
|
return reply.status(201).send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,7 +24,6 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService, db:
|
|||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
||||||
}
|
}
|
||||||
push.unsubscribe(body.endpoint);
|
push.unsubscribe(body.endpoint);
|
||||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'push.unsubscribe' });
|
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { type Db, setSetting } from '../db/index.js';
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||||
import type { PushService } from '../core/push-service.js';
|
import type { PushService } from '../core/push-service.js';
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
|
||||||
import {
|
import {
|
||||||
SCAN_INTERVAL_KEY,
|
SCAN_INTERVAL_KEY,
|
||||||
SCAN_ROOTS_KEY,
|
SCAN_ROOTS_KEY,
|
||||||
@@ -16,7 +15,23 @@ import {
|
|||||||
readScanRoots,
|
readScanRoots,
|
||||||
} from '../core/scan-settings.js';
|
} from '../core/scan-settings.js';
|
||||||
|
|
||||||
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||||
|
const GITEA_URL_KEY = 'gitea_url';
|
||||||
|
|
||||||
|
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
|
||||||
|
function normalizeGiteaUrl(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSettingsRoutes(
|
export function registerSettingsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -24,6 +39,8 @@ export function registerSettingsRoutes(
|
|||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
push: PushService,
|
push: PushService,
|
||||||
): void {
|
): void {
|
||||||
|
// '' (effacé) est normalisé en null côté réponse.
|
||||||
|
const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null;
|
||||||
const serverInfo = (): ServerInfo => ({
|
const serverInfo = (): ServerInfo => ({
|
||||||
version: serverVersion,
|
version: serverVersion,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
@@ -35,6 +52,7 @@ export function registerSettingsRoutes(
|
|||||||
});
|
});
|
||||||
const snapshot = (): SettingsResponse => ({
|
const snapshot = (): SettingsResponse => ({
|
||||||
settings: {
|
settings: {
|
||||||
|
giteaUrl: readGiteaUrl(),
|
||||||
scanRoots: readScanRoots(db),
|
scanRoots: readScanRoots(db),
|
||||||
scanIntervalMin: readScanIntervalMin(db),
|
scanIntervalMin: readScanIntervalMin(db),
|
||||||
},
|
},
|
||||||
@@ -45,6 +63,20 @@ export function registerSettingsRoutes(
|
|||||||
|
|
||||||
app.patch('/api/v1/settings', async (req, reply) => {
|
app.patch('/api/v1/settings', async (req, reply) => {
|
||||||
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {};
|
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {};
|
||||||
|
if ('giteaUrl' in body) {
|
||||||
|
const v = body.giteaUrl;
|
||||||
|
if (v === null || v === '') {
|
||||||
|
setSetting(db, GITEA_URL_KEY, ''); // effacement
|
||||||
|
} else if (typeof v === 'string') {
|
||||||
|
const normalized = normalizeGiteaUrl(v);
|
||||||
|
if (!normalized) {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a valid http(s) URL' } });
|
||||||
|
}
|
||||||
|
setSetting(db, GITEA_URL_KEY, normalized);
|
||||||
|
} else {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
if ('scanRoots' in body) {
|
if ('scanRoots' in body) {
|
||||||
const roots = normalizeScanRoots(body.scanRoots);
|
const roots = normalizeScanRoots(body.scanRoots);
|
||||||
if (!roots) {
|
if (!roots) {
|
||||||
@@ -59,11 +91,6 @@ export function registerSettingsRoutes(
|
|||||||
}
|
}
|
||||||
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
||||||
}
|
}
|
||||||
recordAudit(db, {
|
|
||||||
actor: req.authContext?.tokenId ?? 'unknown',
|
|
||||||
action: 'settings.update',
|
|
||||||
details: { keys: Object.keys(body) },
|
|
||||||
});
|
|
||||||
return reply.send(snapshot());
|
return reply.send(snapshot());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
// Lot 5 — sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression),
|
|
||||||
// garde Content-Type. Vérifie le câblage de bout en bout via buildApp + token bootstrap.
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { buildApp, type AppBundle } from '../src/app.js';
|
|
||||||
import { openDb, type Db } from '../src/db/index.js';
|
|
||||||
import type { Config } from '../src/config.js';
|
|
||||||
import type { AuditLogsResponse, DataExportResponse, DeleteMyDataResponse } from '@arboretum/shared';
|
|
||||||
|
|
||||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
|
||||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
|
||||||
class FakePty {
|
|
||||||
pid = 424242;
|
|
||||||
write = vi.fn();
|
|
||||||
resize = vi.fn();
|
|
||||||
pause = vi.fn();
|
|
||||||
resume = vi.fn();
|
|
||||||
kill = vi.fn();
|
|
||||||
onData(): { dispose: () => void } {
|
|
||||||
return { dispose: () => {} };
|
|
||||||
}
|
|
||||||
onExit(): { dispose: () => void } {
|
|
||||||
return { dispose: () => {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { default: { spawn: (): FakePty => new FakePty() } };
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env.ARBORETUM_LOG = 'silent';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
let bundle: AppBundle;
|
|
||||||
let db: Db;
|
|
||||||
let token: string;
|
|
||||||
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
dir = mkdtempSync(join(tmpdir(), 'arboretum-audit-'));
|
|
||||||
const dbPath = join(dir, 'audit.db');
|
|
||||||
db = openDb(dbPath);
|
|
||||||
const config: Config = {
|
|
||||||
port: 9998,
|
|
||||||
bind: '127.0.0.1',
|
|
||||||
dbPath,
|
|
||||||
dataDir: dir,
|
|
||||||
allowedOrigins: ['https://host.tailnet.ts.net'],
|
|
||||||
printToken: false,
|
|
||||||
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
|
||||||
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
|
||||||
vapidContact: 'mailto:test@localhost',
|
|
||||||
autoDiscover: false,
|
|
||||||
};
|
|
||||||
bundle = buildApp(config, db, '1.2.3-test');
|
|
||||||
const t = bundle.auth.ensureBootstrapToken();
|
|
||||||
if (!t) throw new Error('bootstrap token attendu');
|
|
||||||
token = t;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await bundle.app.close();
|
|
||||||
db.close();
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('En-têtes de sécurité', () => {
|
|
||||||
it('pose les en-têtes durs + no-store sur /api, sans header Server', async () => {
|
|
||||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
||||||
expect(res.headers['x-frame-options']).toBe('DENY');
|
|
||||||
expect(res.headers['referrer-policy']).toBe('no-referrer');
|
|
||||||
expect(res.headers['content-security-policy']).toContain("default-src 'self'");
|
|
||||||
expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
|
|
||||||
expect(String(res.headers['cache-control'])).toContain('no-store');
|
|
||||||
expect(res.headers['server']).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('pose HSTS uniquement derrière HTTPS (x-forwarded-proto)', async () => {
|
|
||||||
const http = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
|
|
||||||
expect(http.headers['strict-transport-security']).toBeUndefined();
|
|
||||||
const https = await bundle.app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/auth/me',
|
|
||||||
headers: { ...auth(), 'x-forwarded-proto': 'https' },
|
|
||||||
});
|
|
||||||
expect(String(https.headers['strict-transport-security'])).toContain('max-age=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejette une mutation avec corps non-JSON (415)', async () => {
|
|
||||||
const res = await bundle.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/auth/tokens',
|
|
||||||
headers: { ...auth(), 'content-type': 'text/plain' },
|
|
||||||
payload: 'label=pwned',
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(415);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Journal d’audit', () => {
|
|
||||||
it('journalise la création de token et l’expose via GET /audit-logs', async () => {
|
|
||||||
const create = await bundle.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/auth/tokens',
|
|
||||||
headers: auth(),
|
|
||||||
payload: { label: 'ci-extra' },
|
|
||||||
});
|
|
||||||
expect(create.statusCode).toBe(201);
|
|
||||||
|
|
||||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs', headers: auth() });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = res.json() as AuditLogsResponse;
|
|
||||||
const actions = body.entries.map((e) => e.action);
|
|
||||||
expect(actions).toContain('token.create');
|
|
||||||
expect(actions).toContain('secret.generate'); // généré au bootstrap (system)
|
|
||||||
// aucune VALEUR secrète en clair : pas de chaîne hex de 64 caractères (server_secret / clé).
|
|
||||||
// (les ids sont des UUID avec tirets → non concernés ; 'server_secret' est un nom de ressource.)
|
|
||||||
expect(res.body).not.toMatch(/[a-f0-9]{64}/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('journalise les échecs de login (actor anonymous)', async () => {
|
|
||||||
await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_invalid_xxx' } });
|
|
||||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs?limit=200', headers: auth() });
|
|
||||||
const body = res.json() as AuditLogsResponse;
|
|
||||||
expect(body.entries.some((e) => e.action === 'login.failure' && e.actor === 'anonymous')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RGPD', () => {
|
|
||||||
it('exporte les données du token authentifié', async () => {
|
|
||||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/data/export', headers: auth() });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = res.json() as DataExportResponse;
|
|
||||||
expect(Array.isArray(body.tokens)).toBe(true);
|
|
||||||
expect(body.tokens.some((t) => t.current)).toBe(true);
|
|
||||||
expect(Array.isArray(body.pushSubscriptions)).toBe(true);
|
|
||||||
expect(Array.isArray(body.sessions)).toBe(true);
|
|
||||||
expect(body.settings).toHaveProperty('scanRoots');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supprime en deux temps (pending → confirm → done) et révoque le token', async () => {
|
|
||||||
const pending = await bundle.app.inject({ method: 'POST', url: '/api/v1/data/delete-my-data', headers: auth(), payload: {} });
|
|
||||||
const p = pending.json() as DeleteMyDataResponse;
|
|
||||||
expect(p.status).toBe('pending');
|
|
||||||
expect(p.confirm).toBeTruthy();
|
|
||||||
|
|
||||||
// un 2e token existe (créé plus haut) → révoquer le token courant est autorisé.
|
|
||||||
const done = await bundle.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/data/delete-my-data',
|
|
||||||
headers: auth(),
|
|
||||||
payload: { confirm: p.confirm },
|
|
||||||
});
|
|
||||||
const d = done.json() as DeleteMyDataResponse;
|
|
||||||
expect(d.status).toBe('done');
|
|
||||||
expect(d.summary.tokenRevoked).toBe(true);
|
|
||||||
|
|
||||||
// le token courant est désormais révoqué → 401.
|
|
||||||
const after = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
|
|
||||||
expect(after.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+7
-7
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Devon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-ask2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "fix lint errors"[22m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Johan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-ask2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "fix lint errors"[22m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
||||||
@@ -321,13 +321,13 @@
|
|||||||
|
|
||||||
[7A[?2026l]0;⠂ Demander la couleur préférée red ou blue[?2026h[7B[H[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[H[38;2;255;153;51m╭───[6GClaude[13GCode[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮[39m␍
|
[7A[?2026l]0;⠂ Demander la couleur préférée red ou blue[?2026h[7B[H[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[H[38;2;255;153;51m╭───[6GClaude[13GCode[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮[39m␍
|
||||||
[38;2;255;153;51m│[54G[2m│[56G[22m[1mTips[61Gfor[65Ggetting[73Gstarted[120G[22m│[39m␍
|
[38;2;255;153;51m│[54G[2m│[56G[22m[1mTips[61Gfor[65Ggetting[73Gstarted[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m│[19G[39m[1mWelcome[27Gback[32GDevon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[19G[39m[1mWelcome[27Gback[32GJohan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│[39m␍
|
[38;2;255;153;51m│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's[63Gnew[120G[22m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's[63Gnew[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m [26G▘▘[29G▝▝[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62Ga[64Gspurious[73G"sandbox[82Gdependencies[95Gmissing"[104Gstartup[112Gwarnin…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m [26G▘▘[29G▝▝[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62Ga[64Gspurious[73G"sandbox[82Gdependencies[95Gmissing"[104Gstartup[112Gwarnin…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[5G[38;2;153;153;153mOpus[10G4.8[14G(1M[18Gcontext)[27Gwith[32Gxh…[36G·[38GClaude[45GTeam[50G·[54G[2m[38;2;255;153;51m│[56G[39m[22mSub-agents[67Gcan[71Gnow[75Gspawn[81Gtheir[87Gown[91Gsub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[5G[38;2;153;153;153mOpus[10G4.8[14G(1M[18Gcontext)[27Gwith[32Gxh…[36G·[38GClaude[45GTeam[50G·[54G[2m[38;2;255;153;51m│[56G[39m[22mSub-agents[67Gcan[71Gnow[75Gspawn[81Gtheir[87Gown[91Gsub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes[71Gfor[75Gmore[120G[23m[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes[71Gfor[75Gmore[120G[23m[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[19G[38;2;153;153;153m/tmp/spike-s3-ask2[54G[2m[38;2;255;153;51m│[120G[22m│[39m␍
|
[38;2;255;153;51m│[19G[38;2;153;153;153m/tmp/spike-s3-ask2[54G[2m[38;2;255;153;51m│[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[39m␍
|
[38;2;255;153;51m╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[39m␍
|
||||||
␍
|
␍
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[38;2;153;153;153m❯ [39m␍
|
[38;2;153;153;153m❯ [39m␍
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;153;51m✶[3GBunning…[39m␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;153;51m✶[3GBunning…[39m␍
|
||||||
|
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;255;255m● [39mUser answered Claude's questions:␍[1B[38;2;153;153;153m ⎿ · Préférez-vous le rouge ou le bleu ? → Bleu␍[1B[39m[K␍[1B[38;2;255;153;51m✢[39m [38;2;255;153;51mBu[38;2;255;183;101mnni[38;2;255;153;51mng… [38;2;153;153;153m(4s · ↑[20G215 tokens)␍[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;255;255m● [39mUser answered Claude's questions:␍[1B[38;2;153;153;153m ⎿ · Préférez-vous le rouge ou le bleu ? → Bleu␍[1B[39m[K␍[1B[38;2;255;153;51m✢[39m [38;2;255;153;51mBu[38;2;255;183;101mnni[38;2;255;153;51mng… [38;2;153;153;153m(4s · ↑[20G215 tokens)␍[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[38;2;153;153;153m❯ [39m␍
|
[38;2;153;153;153m❯ [39m␍
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4C[6A[38;2;255;153;51mn[8G[38;2;255;183;101mn[39m␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4C[6A[38;2;255;153;51mn[8G[38;2;255;183;101mn[39m␍
|
||||||
|
|
||||||
|
|
||||||
@@ -566,7 +566,7 @@
|
|||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;255;255m●[3G[39mVous avez choisi[20Gle [1mbleu[22m 🔵[K␍[2B[38;2;255;153;51m✽[39m [38;2;255;164;70mBunning…[38;2;255;153;51m [38;2;153;153;153m(running stop hook · 8s · ↓[39m [38;2;153;153;153m218 tokens)[39m[K␍[1B[K␍[2B[38;2;153;153;153m❯ [39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;255;255m●[3G[39mVous avez choisi[20Gle [1mbleu[22m 🔵[K␍[2B[38;2;255;153;51m✽[39m [38;2;255;164;70mBunning…[38;2;255;153;51m [38;2;153;153;153m(running stop hook · 8s · ↓[39m [38;2;153;153;153m218 tokens)[39m[K␍[1B[K␍[2B[38;2;153;153;153m❯ [39m[K␍
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[3A[?25h[?2026l]0;✳ Demander la couleur préférée red ou blue[?2026h[?25l[2D[3B␍[6A[38;2;153;153;153m✻[3GSautéed fo[14G 8s[39m[K␍[3B❯ ␍[2C[2B[38;2;153;153;153m? for shortcuts · ← for agents[39m[K␍
|
[2C[3A[?25h[?2026l]0;✳ Demander la couleur préférée red ou blue[?2026h[?25l[2D[3B␍[6A[38;2;153;153;153m✻[3GSautéed fo[14G 8s[39m[K␍[3B❯ ␍[2C[2B[38;2;153;153;153m? for shortcuts · ← for agents[39m[K␍
|
||||||
[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[4A[?25h[?2026l
|
[2C[4A[?25h[?2026l
|
||||||
+2
-2
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Devon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-deny2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "create a util logging.py that..."␍[1B[22m[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Johan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-deny2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "create a util logging.py that..."␍[1B[22m[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
||||||
@@ -475,7 +475,7 @@
|
|||||||
|
|
||||||
[2C[3A[?25h[?2026l]0;✳ Execute Node.js command in bash[?2026h[?25l[2D[3B␍[6A[38;2;153;153;153m✻[3GChurned for 5s[39m[K␍[3B❯ ␍[2C[2B[38;2;153;153;153m? for shortcuts · ← for agents[39m␍
|
[2C[3A[?25h[?2026l]0;✳ Execute Node.js command in bash[?2026h[?25l[2D[3B␍[6A[38;2;153;153;153m✻[3GChurned for 5s[39m[K␍[3B❯ ␍[2C[2B[38;2;153;153;153m? for shortcuts · ← for agents[39m␍
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[101C[1A[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[101C[1A[K␍
|
||||||
[21G[38;2;255;204;0mYou've[28Gused[33G94%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[21G[38;2;255;204;0mYou've[28Gused[33G94%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[4A[?25h[?2026l[?2026h[?25l[2D[4B[2K[G[1A[K[2C[3A[?25h[?2026l[?1006l[?1003l[?1002l[?1000l[2D[3B(B[>4m[<u[?1004l[?2031l[?2004l[?25h7[r8]0;[2m[22m
|
[2C[4A[?25h[?2026l[?2026h[?25l[2D[4B[2K[G[1A[K[2C[3A[?25h[?2026l[?1006l[?1003l[?1002l[?1000l[2D[3B(B[>4m[<u[?1004l[?2031l[?2004l[?25h7[r8]0;[2m[22m
|
||||||
[2mResume this session with:[22m
|
[2mResume this session with:[22m
|
||||||
[2mclaude --resume 68c4db8a-2120-4ab9-9c03-cd3313675be5[22m
|
[2mclaude --resume 68c4db8a-2120-4ab9-9c03-cd3313675be5[22m
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Devon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-bash2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "edit <filepath> to..."[22m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Johan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-bash2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "edit <filepath> to..."[22m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
||||||
|
|||||||
+9
-9
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Devon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-write2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "edit <filepath> to..."[22m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Johan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-write2[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "edit <filepath> to..."[22m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
||||||
@@ -218,13 +218,13 @@
|
|||||||
[2G[38;2;153;153;153mEsc[6Gto[9Gcancel[16G·[18GTab[22Gto[25Gamend[39m␍
|
[2G[38;2;153;153;153mEsc[6Gto[9Gcancel[16G·[18GTab[22Gto[25Gamend[39m␍
|
||||||
[1C[5A[?2026l]0;⠐ Créer un fichier s3-edit.txt avec hello[?2026h[1D[5B[H[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[H[38;2;255;153;51m╭───[6GClaude[13GCode[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮[39m␍
|
[1C[5A[?2026l]0;⠐ Créer un fichier s3-edit.txt avec hello[?2026h[1D[5B[H[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[H[38;2;255;153;51m╭───[6GClaude[13GCode[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮[39m␍
|
||||||
[38;2;255;153;51m│[54G[2m│[56G[22m[1mTips[61Gfor[65Ggetting[73Gstarted[120G[22m│[39m␍
|
[38;2;255;153;51m│[54G[2m│[56G[22m[1mTips[61Gfor[65Ggetting[73Gstarted[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m│[19G[39m[1mWelcome[27Gback[32GDevon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[19G[39m[1mWelcome[27Gback[32GJohan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│[39m␍
|
[38;2;255;153;51m│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's[63Gnew[120G[22m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's[63Gnew[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m [26G▘▘[29G▝▝[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62Ga[64Gspurious[73G"sandbox[82Gdependencies[95Gmissing"[104Gstartup[112Gwarnin…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m [26G▘▘[29G▝▝[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62Ga[64Gspurious[73G"sandbox[82Gdependencies[95Gmissing"[104Gstartup[112Gwarnin…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[5G[38;2;153;153;153mOpus[10G4.8[14G(1M[18Gcontext)[27Gwith[32Gxh…[36G·[38GClaude[45GTeam[50G·[54G[2m[38;2;255;153;51m│[56G[39m[22mSub-agents[67Gcan[71Gnow[75Gspawn[81Gtheir[87Gown[91Gsub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[5G[38;2;153;153;153mOpus[10G4.8[14G(1M[18Gcontext)[27Gwith[32Gxh…[36G·[38GClaude[45GTeam[50G·[54G[2m[38;2;255;153;51m│[56G[39m[22mSub-agents[67Gcan[71Gnow[75Gspawn[81Gtheir[87Gown[91Gsub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes[71Gfor[75Gmore[120G[23m[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes[71Gfor[75Gmore[120G[23m[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[18G[38;2;153;153;153m/tmp/spike-s3-write2[54G[2m[38;2;255;153;51m│[120G[22m│[39m␍
|
[38;2;255;153;51m│[18G[38;2;153;153;153m/tmp/spike-s3-write2[54G[2m[38;2;255;153;51m│[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[39m␍
|
[38;2;255;153;51m╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[39m␍
|
||||||
␍
|
␍
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[38;2;153;153;153m❯ [39m␍
|
[38;2;153;153;153m❯ [39m␍
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;153;51m✶[3GSeasoning…[39m␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;153;51m✶[3GSeasoning…[39m␍
|
||||||
|
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[8A[38;2;51;153;255m●␍[1B[38;2;153;153;153m ⎿ [39mWrote[12G[1m1[14G[22mlines[20Gto[23G[1ms3-edit.txt␍[1B[22m [3G [38;2;248;248;242m[2m 1 [22mhello[39m[K␍[2B[38;2;255;153;51m✶[39m [38;2;255;153;51mSeason[38;2;255;183;101ming[38;2;255;153;51m… [38;2;153;153;153m(3s · ↑[39m [38;2;153;153;153m147 tokens)[39m[K␍[1B[K␍[2B[38;2;153;153;153m❯ [39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[8A[38;2;51;153;255m●␍[1B[38;2;153;153;153m ⎿ [39mWrote[12G[1m1[14G[22mlines[20Gto[23G[1ms3-edit.txt␍[1B[22m [3G [38;2;248;248;242m[2m 1 [22mhello[39m[K␍[2B[38;2;255;153;51m✶[39m [38;2;255;153;51mSeason[38;2;255;183;101ming[38;2;255;153;51m… [38;2;153;153;153m(3s · ↑[39m [38;2;153;153;153m147 tokens)[39m[K␍[1B[K␍[2B[38;2;153;153;153m❯ [39m[K␍
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[8C[6A[38;2;255;153;51mi[12G[38;2;255;183;101m…[39m␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[8C[6A[38;2;255;153;51mi[12G[38;2;255;183;101m…[39m␍
|
||||||
|
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
|
|
||||||
[2C[3A[?25h[?2026l]0;⠂ Créer un fichier s3-edit.txt avec hello[?2026h[?25l[2D[3B␍[6A[38;2;255;255;255m●[3G[39mFichier [38;2;153;204;255ms3-edit.txt[39m créé avec le[36Gcontenu[44G[38;2;153;204;255mhello[39m.␍[2B[38;2;255;153;51m·[39m [38;2;255;153;51mSeasoning[38;2;255;183;101m…[38;2;255;153;51m [38;2;153;153;153m(5s · ↓[39m [38;2;153;153;153m147 tokens)[39m[K␍[1B[K␍[2B[38;2;153;153;153m❯ [39m[K␍
|
[2C[3A[?25h[?2026l]0;⠂ Créer un fichier s3-edit.txt avec hello[?2026h[?25l[2D[3B␍[6A[38;2;255;255;255m●[3G[39mFichier [38;2;153;204;255ms3-edit.txt[39m créé avec le[36Gcontenu[44G[38;2;153;204;255mhello[39m.␍[2B[38;2;255;153;51m·[39m [38;2;255;153;51mSeasoning[38;2;255;183;101m…[38;2;255;153;51m [38;2;153;153;153m(5s · ↓[39m [38;2;153;153;153m147 tokens)[39m[K␍[1B[K␍[2B[38;2;153;153;153m❯ [39m[K␍
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;153;51m✢[11G[38;2;255;183;101mg[15G[38;2;153;153;153m6[24G9[39m␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[6A[38;2;255;153;51m✢[11G[38;2;255;183;101mg[15G[38;2;153;153;153m6[24G9[39m␍
|
||||||
|
|
||||||
|
|
||||||
@@ -430,16 +430,16 @@
|
|||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l]0;✳ Créer un fichier s3-edit.txt avec hello[?2026h[?25l[2D[3B␍[6A[38;2;153;153;153m✻[3GCooked for 6s[39m[K␍[3B❯ ␍[2C[2B[38;2;153;153;153m? for shortcuts · ← for agents[39m[K␍
|
[2C[3A[?25h[?2026l]0;✳ Créer un fichier s3-edit.txt avec hello[?2026h[?25l[2D[3B␍[6A[38;2;153;153;153m✻[3GCooked for 6s[39m[K␍[3B❯ ␍[2C[2B[38;2;153;153;153m? for shortcuts · ← for agents[39m[K␍
|
||||||
[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(Europe/Paris)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m␍
|
||||||
[2C[4A[?25h[?2026l[?2026h[?25l[2D[4B[H[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[H[38;2;255;153;51m╭───[6GClaude[13GCode[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮[39m␍
|
[2C[4A[?25h[?2026l[?2026h[?25l[2D[4B[H[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[2K[1B[H[38;2;255;153;51m╭───[6GClaude[13GCode[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮[39m␍
|
||||||
[38;2;255;153;51m│[54G[2m│[56G[22m[1mTips[61Gfor[65Ggetting[73Gstarted[120G[22m│[39m␍
|
[38;2;255;153;51m│[54G[2m│[56G[22m[1mTips[61Gfor[65Ggetting[73Gstarted[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m│[19G[39m[1mWelcome[27Gback[32GDevon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[19G[39m[1mWelcome[27Gback[32GJohan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│[39m␍
|
[38;2;255;153;51m│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's[63Gnew[120G[22m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's[63Gnew[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[24G[38;2;215;119;87m [26G▘▘[29G▝▝[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62Ga[64Gspurious[73G"sandbox[82Gdependencies[95Gmissing"[104Gstartup[112Gwarnin…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[24G[38;2;215;119;87m [26G▘▘[29G▝▝[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62Ga[64Gspurious[73G"sandbox[82Gdependencies[95Gmissing"[104Gstartup[112Gwarnin…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[5G[38;2;153;153;153mOpus[10G4.8[14G(1M[18Gcontext)[27Gwith[32Gxh…[36G·[38GClaude[45GTeam[50G·[54G[2m[38;2;255;153;51m│[56G[39m[22mSub-agents[67Gcan[71Gnow[75Gspawn[81Gtheir[87Gown[91Gsub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[5G[38;2;153;153;153mOpus[10G4.8[14G(1M[18Gcontext)[27Gwith[32Gxh…[36G·[38GClaude[45GTeam[50G·[54G[2m[38;2;255;153;51m│[56G[39m[22mSub-agents[67Gcan[71Gnow[75Gspawn[81Gtheir[87Gown[91Gsub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes[71Gfor[75Gmore[120G[23m[38;2;255;153;51m│[39m␍
|
[38;2;255;153;51m│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes[71Gfor[75Gmore[120G[23m[38;2;255;153;51m│[39m␍
|
||||||
[38;2;255;153;51m│[18G[38;2;153;153;153m/tmp/spike-s3-write2[54G[2m[38;2;255;153;51m│[120G[22m│[39m␍
|
[38;2;255;153;51m│[18G[38;2;153;153;153m/tmp/spike-s3-write2[54G[2m[38;2;255;153;51m│[120G[22m│[39m␍
|
||||||
[38;2;255;153;51m╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[39m␍
|
[38;2;255;153;51m╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[39m␍
|
||||||
␍
|
␍
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Devon![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mExample[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-trust[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "create a util logging.py that..."␍[1B[22m[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
[1C[4A[?2026l]0;✳ Claude Code[?2026h[1D[4B[2K[G[1A␍[16A[38;2;255;153;51m╭───[6GClaude Code[18G[38;2;153;153;153mv2.1.173[27G[38;2;255;153;51m─────────────────────────────────────────────────────────────────────────────────────────────╮␍[1B│[39m [2m[38;2;255;153;51m│[39m[22m [38;2;255;153;51m[1mTips for getting started[22m[39m [38;2;255;153;51m│␍[1B│[39m [1mWelcome back Johan![54G[22m[2m[38;2;255;153;51m│[56G[39m[22mRun[60G/init[66Gto[69Gcreate[76Ga[78GCLAUDE.md[88Gfile[93Gwith[98Ginstructions[111Gfor[115GCla…[120G[38;2;255;153;51m│␍[1B│[54G[2m│[56G[22m───────────────────────────────────────────────────────────────[120G│␍[1B│[39m [24G[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[54G[2m[38;2;255;153;51m│[56G[22m[1mWhat's new[120G[22m│␍[1B│[24G[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[54G[2m[38;2;255;153;51m│[56G[39m[22mFixed[62GFable[68G5[70Gmodel[76Gnames[82Gwith[87Ga[89G`[1m]`[96Gsuffix[103Gnot[107Gbeing[113Gnorma…[120G[38;2;255;153;51m│␍[1B│[39m [8G [15G [22G [38;2;215;119;87m ▘▘ ▝▝ [39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m Fixed a spurious "sandbox depen[89Gncies missing"[104Gstartup warnin…[120G[38;2;255;153;51m│␍[1B│[39m [38;2;153;153;153mOpus 4.8 (1M context) with xh… · Claude Team · [39m [2m[38;2;255;153;51m│[39m[22m Sub-ag[63Gnts can now[75Gspawn their own sub-agents[102G(up[106Gto[109G5[111Glevels[118G…[120G[38;2;255;153;51m│␍[1B│[5G[38;2;153;153;153mBeehelp[54G[2m[38;2;255;153;51m│[56G[22m[38;2;153;153;153m[3m/release-notes for more[120G[23m[38;2;255;153;51m│␍[1B│[39m [9G [17G [38;2;153;153;153m/tmp/spike-s3-trust[39m [40G [44G [52G [2m[38;2;255;153;51m│[39m[22m [58G [120G[38;2;255;153;51m│␍[1B╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯␍[1C[1B[39m[K␍[1B[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[1B[39m❯ [2mTry "create a util logging.py that..."␍[1B[22m[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────␍[2C[1B[38;2;153;153;153m? for shortcuts · ← for agents[102G◉ xhigh · /effort␍[1B[39m[K[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[2C[3A[K␍
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B␍[4A [38;2;255;204;0m⚠ 2 setup issues: MCP[38;2;153;153;153m · /doctor[39m[K␍[1B[K␍[1B [38;2;255;153;51m▎[39m Meet [38;2;255;153;51m[1mFable 5[22m[39m, our newest model for complex, long-running work. Try anytime with /model.[K␍[1C[1B[38;2;255;153;51m▎[39m [38;2;153;153;153mIncluded in your[21Gplan limits until Jun 22, then switch to usage credits to continue.[102G[39m[K␍
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object
|
|||||||
describe('munge', () => {
|
describe('munge', () => {
|
||||||
it('reproduit le nom de dossier ~/.claude/projects', () => {
|
it('reproduit le nom de dossier ~/.claude/projects', () => {
|
||||||
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
|
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
|
||||||
expect(munge('/home/user/projects/demo')).toBe('-home-user-projects-demo');
|
expect(munge('/home/johan/WebstormProjects/arboretum')).toBe('-home-johan-WebstormProjects-arboretum');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,16 +38,6 @@ describe('scanForRepos', () => {
|
|||||||
expect(paths).toEqual([join(root, 'a')]);
|
expect(paths).toEqual([join(root, 'a')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('descend dans une racine qui est elle-même un repo (workspace + sous-repos)', async () => {
|
|
||||||
const root = tmpRoot();
|
|
||||||
makeRepo(root); // la racine fournie contient elle-même un .git (cas WebstormProjects)
|
|
||||||
makeRepo(root, 'a');
|
|
||||||
makeRepo(root, 'b');
|
|
||||||
const { paths } = await scanForRepos([root], limits);
|
|
||||||
// la racine ET ses dépôts internes sont découverts (la racine n'arrête pas le scan)
|
|
||||||
expect([...paths].sort()).toEqual([root, join(root, 'a'), join(root, 'b')].sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignore node_modules et les dotdirs', async () => {
|
it('ignore node_modules et les dotdirs', async () => {
|
||||||
const root = tmpRoot();
|
const root = tmpRoot();
|
||||||
makeRepo(root, 'node_modules', 'pkg');
|
makeRepo(root, 'node_modules', 'pkg');
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { SecretBox } from '../src/core/secret-box.js';
|
|
||||||
|
|
||||||
const box = (): SecretBox => new SecretBox(randomBytes(32));
|
|
||||||
|
|
||||||
describe('SecretBox', () => {
|
|
||||||
it('chiffre puis déchiffre (round-trip)', () => {
|
|
||||||
const b = box();
|
|
||||||
const secret = 'deadbeef'.repeat(8);
|
|
||||||
const enc = b.encrypt(secret);
|
|
||||||
expect(enc.startsWith('v1:')).toBe(true);
|
|
||||||
expect(enc).not.toContain(secret); // le clair n'apparaît pas
|
|
||||||
expect(b.decrypt(enc)).toBe(secret);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produit un chiffré différent à chaque fois (IV aléatoire)', () => {
|
|
||||||
const b = box();
|
|
||||||
expect(b.encrypt('x')).not.toBe(b.encrypt('x'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retourne tel quel une valeur en clair (legacy) et la détecte', () => {
|
|
||||||
const b = box();
|
|
||||||
expect(b.isEncrypted('plain-secret')).toBe(false);
|
|
||||||
expect(b.decrypt('plain-secret')).toBe('plain-secret');
|
|
||||||
expect(b.isEncrypted(b.encrypt('x'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('échoue si la clé ne correspond pas (GCM authentifié)', () => {
|
|
||||||
const enc = box().encrypt('secret');
|
|
||||||
expect(() => box().decrypt(enc)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('échoue sur un format chiffré corrompu', () => {
|
|
||||||
expect(() => box().decrypt('v1:zzz')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que
|
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que
|
||||||
// l'allow-list (découverte des dépôts) et ignore toute clé hors allow-list.
|
// l'allow-list et valide l'URL Gitea (http/https only, anti-XSS).
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -65,7 +65,7 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/v1/settings', () => {
|
describe('GET /api/v1/settings', () => {
|
||||||
it('renvoie la config serveur non sensible et aucune racine de scan par défaut', async () => {
|
it('renvoie la config serveur non sensible et giteaUrl null par défaut', async () => {
|
||||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = res.json() as SettingsResponse;
|
const body = res.json() as SettingsResponse;
|
||||||
@@ -74,8 +74,10 @@ describe('GET /api/v1/settings', () => {
|
|||||||
expect(body.server.bind).toBe('127.0.0.1');
|
expect(body.server.bind).toBe('127.0.0.1');
|
||||||
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
|
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
|
||||||
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
|
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
|
||||||
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
|
expect(body.settings.giteaUrl).toBeNull();
|
||||||
expect(body.settings.scanRoots).toEqual([]);
|
// défauts de découverte : home (1 racine) + intervalle 5 min
|
||||||
|
expect(Array.isArray(body.settings.scanRoots)).toBe(true);
|
||||||
|
expect(body.settings.scanRoots).toHaveLength(1);
|
||||||
expect(body.settings.scanIntervalMin).toBe(5);
|
expect(body.settings.scanIntervalMin).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +93,29 @@ describe('GET /api/v1/settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /api/v1/settings — sécurité', () => {
|
describe('PATCH /api/v1/settings', () => {
|
||||||
|
it('enregistre une URL Gitea valide et la renvoie', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect((res.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/');
|
||||||
|
// persisté
|
||||||
|
const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
||||||
|
expect((get.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('efface l’URL avec null ou chaîne vide', async () => {
|
||||||
|
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } });
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: null } });
|
||||||
|
expect((res.json() as SettingsResponse).settings.giteaUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette une URL non http(s) — anti-XSS (400)', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'javascript:alert(1)' } });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
const notUrl = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'pas une url' } });
|
||||||
|
expect(notUrl.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
|
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
|
||||||
const before = getSetting(db, 'server_secret');
|
const before = getSetting(db, 'server_secret');
|
||||||
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
|
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
|
||||||
@@ -100,7 +124,7 @@ describe('PATCH /api/v1/settings — sécurité', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sans authentification → 401', async () => {
|
it('sans authentification → 401', async () => {
|
||||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { scanRoots: ['/home/u/work'] } });
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { giteaUrl: 'https://x.example' } });
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ export interface ServerInfo {
|
|||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
||||||
settings: {
|
settings: {
|
||||||
/** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */
|
giteaUrl: string | null;
|
||||||
|
/** racines absolues scannées pour la découverte auto des repos (défaut : home). */
|
||||||
scanRoots: string[];
|
scanRoots: string[];
|
||||||
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
||||||
scanIntervalMin: number;
|
scanIntervalMin: number;
|
||||||
@@ -207,48 +208,10 @@ export interface SettingsResponse {
|
|||||||
server: ServerInfo;
|
server: ServerInfo;
|
||||||
}
|
}
|
||||||
export interface UpdateSettingsRequest {
|
export interface UpdateSettingsRequest {
|
||||||
|
/** URL de l'instance Gitea (http/https) ; null ou '' pour effacer. */
|
||||||
|
giteaUrl?: string | null;
|
||||||
/** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */
|
/** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */
|
||||||
scanRoots?: string[];
|
scanRoots?: string[];
|
||||||
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
||||||
scanIntervalMin?: number;
|
scanIntervalMin?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Journal d'audit (conformité entreprise) ----
|
|
||||||
export interface AuditLogEntry {
|
|
||||||
id: string;
|
|
||||||
/** horodatage ISO 8601 */
|
|
||||||
ts: string;
|
|
||||||
/** tokenId de l'acteur, 'system' (auto) ou 'anonymous' (avant auth) */
|
|
||||||
actor: string;
|
|
||||||
/** verbe.objet, ex. 'token.create', 'settings.update' */
|
|
||||||
action: string;
|
|
||||||
resourceId: string | null;
|
|
||||||
/** métadonnées non sensibles (jamais de secret) */
|
|
||||||
details: unknown;
|
|
||||||
result: string;
|
|
||||||
}
|
|
||||||
export interface AuditLogsResponse {
|
|
||||||
entries: AuditLogEntry[];
|
|
||||||
/** curseur de pagination (ts à passer en `before`) ; null si fin de liste. */
|
|
||||||
nextBefore: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- RGPD : export / suppression des données liées au token authentifié ----
|
|
||||||
export interface DataExportResponse {
|
|
||||||
exportedAt: string;
|
|
||||||
tokens: Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null; current: boolean }>;
|
|
||||||
pushSubscriptions: Array<{ endpoint: string; userAgent: string | null; createdAt: string; lastOkAt: string | null }>;
|
|
||||||
sessions: Array<{ id: string; cwd: string; command: string; title: string | null; createdAt: string; endedAt: string | null; exitCode: number | null }>;
|
|
||||||
settings: { scanRoots: string[]; scanIntervalMin: number };
|
|
||||||
}
|
|
||||||
export interface DeleteMyDataRequest {
|
|
||||||
/** code de confirmation renvoyé par un premier appel sans `confirm` ; doit être renvoyé pour exécuter. */
|
|
||||||
confirm?: string;
|
|
||||||
}
|
|
||||||
export interface DeleteMyDataResponse {
|
|
||||||
/** 'pending' = confirmation requise (renvoie `confirm`) ; 'done' = suppression effectuée. */
|
|
||||||
status: 'pending' | 'done';
|
|
||||||
confirm?: string;
|
|
||||||
/** récapitulatif de ce qui sera/a été supprimé. */
|
|
||||||
summary: { pushSubscriptions: number; tokenRevoked: boolean };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { computed, type Component } from 'vue';
|
import { computed, type Component } from 'vue';
|
||||||
import { useRoute, type RouteLocationRaw } from 'vue-router';
|
import { useRoute, type RouteLocationRaw } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Boxes, Coffee, GitBranch, LifeBuoy, Settings, TerminalSquare } from '@lucide/vue';
|
import { Boxes, GitBranch, LifeBuoy, Settings, TerminalSquare } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import GiteaIcon from '../components/ui/GiteaIcon.vue';
|
import GiteaIcon from '../components/ui/GiteaIcon.vue';
|
||||||
import { BUYMEACOFFEE_URL, REPO_SOURCE_URL } from '../lib/constants';
|
|
||||||
|
|
||||||
export interface NavEntry {
|
export interface NavEntry {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -23,6 +23,7 @@ export function useNav() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile).
|
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile).
|
||||||
const waitingCount = computed(
|
const waitingCount = computed(
|
||||||
@@ -36,13 +37,17 @@ export function useNav() {
|
|||||||
{ key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 },
|
{ key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Onglets secondaires : réglages, aide, et lien externe « Code source » (en dur, toujours présent).
|
// Onglets secondaires : réglages, aide, et lien externe Gitea (uniquement si l'URL est configurée).
|
||||||
const secondary = computed<NavEntry[]>(() => [
|
const secondary = computed<NavEntry[]>(() => {
|
||||||
{ key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 },
|
const items: NavEntry[] = [
|
||||||
{ key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 },
|
{ key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 },
|
||||||
{ key: 'gitea', href: REPO_SOURCE_URL, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 },
|
{ key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 },
|
||||||
{ key: 'buymeacoffee', href: BUYMEACOFFEE_URL, icon: Coffee, label: t('nav.buymeacoffee'), match: [], badge: 0 },
|
];
|
||||||
]);
|
if (settings.giteaUrl) {
|
||||||
|
items.push({ key: 'gitea', href: settings.giteaUrl, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
const isActive = (match: string[]): boolean => match.includes(String(route.name));
|
const isActive = (match: string[]): boolean => match.includes(String(route.name));
|
||||||
|
|
||||||
|
|||||||
@@ -181,8 +181,7 @@ export default {
|
|||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
help: 'Help',
|
help: 'Help',
|
||||||
more: 'More',
|
more: 'More',
|
||||||
gitea: 'Source code',
|
gitea: 'Open Gitea',
|
||||||
buymeacoffee: 'Buy me a coffee',
|
|
||||||
waiting: 'waiting',
|
waiting: 'waiting',
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
@@ -334,16 +333,14 @@ export default {
|
|||||||
confirmRevoke: 'Confirm revoke',
|
confirmRevoke: 'Confirm revoke',
|
||||||
tokenRevoked: 'Token revoked',
|
tokenRevoked: 'Token revoked',
|
||||||
lastTokenError: 'You cannot revoke the last active token — create another one first.',
|
lastTokenError: 'You cannot revoke the last active token — create another one first.',
|
||||||
|
// Intégrations
|
||||||
|
integrations: 'Integrations',
|
||||||
|
gitea: 'Gitea',
|
||||||
|
giteaUrlLabel: 'Gitea instance URL',
|
||||||
|
giteaUrlPlaceholder: 'https://git.example.com',
|
||||||
|
giteaUrlHint: 'Adds a shortcut icon to the navigation. Leave empty to hide it.',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saved: 'Saved',
|
saved: 'Saved',
|
||||||
// Sécurité & conformité
|
|
||||||
compliance: 'Security & compliance',
|
|
||||||
complianceHint: 'Export or erase the data tied to your token (GDPR), and review the audit log of sensitive operations.',
|
|
||||||
exportData: 'Export my data',
|
|
||||||
refreshAudit: 'Audit log',
|
|
||||||
deleteData: 'Erase my data',
|
|
||||||
deleteConfirm: 'Erase the data tied to your token (push subscriptions) and revoke this token? This cannot be undone.',
|
|
||||||
deleteDone: 'Your data has been erased.',
|
|
||||||
// Découverte des dépôts
|
// Découverte des dépôts
|
||||||
discovery: 'Repository discovery',
|
discovery: 'Repository discovery',
|
||||||
discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.',
|
discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.',
|
||||||
@@ -365,10 +362,6 @@ export default {
|
|||||||
vapidContact: 'VAPID contact',
|
vapidContact: 'VAPID contact',
|
||||||
none: 'none',
|
none: 'none',
|
||||||
flagHint: 'CLI flag: {flag}',
|
flagHint: 'CLI flag: {flag}',
|
||||||
// Support
|
|
||||||
support: 'Support',
|
|
||||||
supportHint: 'Arboretum is a free, self-funded side project. If it saves you time, you can support its development.',
|
|
||||||
supportCta: 'Buy me a coffee',
|
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: 'Help',
|
title: 'Help',
|
||||||
|
|||||||
@@ -184,8 +184,7 @@ const fr: typeof en = {
|
|||||||
settings: 'Réglages',
|
settings: 'Réglages',
|
||||||
help: 'Aide',
|
help: 'Aide',
|
||||||
more: 'Plus',
|
more: 'Plus',
|
||||||
gitea: 'Code source',
|
gitea: 'Ouvrir Gitea',
|
||||||
buymeacoffee: 'Offrez-moi un café',
|
|
||||||
waiting: 'en attente',
|
waiting: 'en attente',
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
@@ -337,16 +336,14 @@ const fr: typeof en = {
|
|||||||
confirmRevoke: 'Confirmer la révocation',
|
confirmRevoke: 'Confirmer la révocation',
|
||||||
tokenRevoked: 'Jeton révoqué',
|
tokenRevoked: 'Jeton révoqué',
|
||||||
lastTokenError: 'Impossible de révoquer le dernier jeton actif — créez-en un autre d’abord.',
|
lastTokenError: 'Impossible de révoquer le dernier jeton actif — créez-en un autre d’abord.',
|
||||||
|
// Intégrations
|
||||||
|
integrations: 'Intégrations',
|
||||||
|
gitea: 'Gitea',
|
||||||
|
giteaUrlLabel: 'URL de l’instance Gitea',
|
||||||
|
giteaUrlPlaceholder: 'https://git.exemple.com',
|
||||||
|
giteaUrlHint: 'Ajoute une icône de raccourci dans la navigation. Laissez vide pour la masquer.',
|
||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
saved: 'Enregistré',
|
saved: 'Enregistré',
|
||||||
// Sécurité & conformité
|
|
||||||
compliance: 'Sécurité & conformité',
|
|
||||||
complianceHint: 'Exportez ou effacez les données liées à votre jeton (RGPD), et consultez le journal d’audit des opérations sensibles.',
|
|
||||||
exportData: 'Exporter mes données',
|
|
||||||
refreshAudit: 'Journal d’audit',
|
|
||||||
deleteData: 'Effacer mes données',
|
|
||||||
deleteConfirm: 'Effacer les données liées à votre jeton (abonnements push) et révoquer ce jeton ? Action irréversible.',
|
|
||||||
deleteDone: 'Vos données ont été effacées.',
|
|
||||||
// Découverte des dépôts
|
// Découverte des dépôts
|
||||||
discovery: 'Découverte des dépôts',
|
discovery: 'Découverte des dépôts',
|
||||||
discoveryHint: 'Arboretum scanne ces dossiers à la recherche de dépôts git et les enregistre automatiquement. Les dépôts masqués sont conservés et jamais ré-ajoutés.',
|
discoveryHint: 'Arboretum scanne ces dossiers à la recherche de dépôts git et les enregistre automatiquement. Les dépôts masqués sont conservés et jamais ré-ajoutés.',
|
||||||
@@ -368,10 +365,6 @@ const fr: typeof en = {
|
|||||||
vapidContact: 'Contact VAPID',
|
vapidContact: 'Contact VAPID',
|
||||||
none: 'aucune',
|
none: 'aucune',
|
||||||
flagHint: 'Flag CLI : {flag}',
|
flagHint: 'Flag CLI : {flag}',
|
||||||
// Soutien
|
|
||||||
support: 'Soutenir le projet',
|
|
||||||
supportHint: 'Arboretum est un projet personnel libre et auto-financé. S’il vous fait gagner du temps, vous pouvez soutenir son développement.',
|
|
||||||
supportCta: 'Offrez-moi un café',
|
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: 'Aide',
|
title: 'Aide',
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
// Constantes globales du front.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lien « Code source » affiché en permanence dans la navigation secondaire.
|
|
||||||
* En dur (pas un réglage) : pointe vers le dépôt source d'Arboretum, identique pour
|
|
||||||
* tous les utilisateurs de la solution.
|
|
||||||
*/
|
|
||||||
export const REPO_SOURCE_URL = 'https://git.lidge.fr/johanleroy/arboretum';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lien « Buy me a coffee » affiché dans la navigation et la page Réglages.
|
|
||||||
* En dur (pas un réglage) : page de don personnelle, identique pour tous.
|
|
||||||
*/
|
|
||||||
export const BUYMEACOFFEE_URL = 'https://buymeacoffee.com/johanleroy';
|
|
||||||
@@ -3,10 +3,11 @@ import { ref } from 'vue';
|
|||||||
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
// Réglages serveur (découverte des dépôts, infos serveur).
|
// Réglages serveur + intégrations. `giteaUrl` alimente l'item de nav Gitea (affiché si défini).
|
||||||
// Les préférences purement client (langue) restent gérées par l'i18n/localStorage.
|
// Les préférences purement client (langue) restent gérées par l'i18n/localStorage.
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const server = ref<ServerInfo | null>(null);
|
const server = ref<ServerInfo | null>(null);
|
||||||
|
const giteaUrl = ref<string | null>(null);
|
||||||
const scanRoots = ref<string[]>([]);
|
const scanRoots = ref<string[]>([]);
|
||||||
const scanIntervalMin = ref(0);
|
const scanIntervalMin = ref(0);
|
||||||
const loaded = ref(false);
|
const loaded = ref(false);
|
||||||
@@ -14,6 +15,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
function apply(res: SettingsResponse): void {
|
function apply(res: SettingsResponse): void {
|
||||||
server.value = res.server;
|
server.value = res.server;
|
||||||
|
giteaUrl.value = res.settings.giteaUrl;
|
||||||
scanRoots.value = res.settings.scanRoots;
|
scanRoots.value = res.settings.scanRoots;
|
||||||
scanIntervalMin.value = res.settings.scanIntervalMin;
|
scanIntervalMin.value = res.settings.scanIntervalMin;
|
||||||
loaded.value = true;
|
loaded.value = true;
|
||||||
@@ -32,5 +34,5 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { server, scanRoots, scanIntervalMin, loaded, saving, fetch, save };
|
return { server, giteaUrl, scanRoots, scanIntervalMin, loaded, saving, fetch, save };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,15 +18,6 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
|
||||||
/* Tailwind v4 ne met plus cursor:pointer sur les boutons → on le rétablit
|
|
||||||
(sauf désactivés, qui gardent le curseur par défaut / not-allowed). */
|
|
||||||
button:not(:disabled),
|
|
||||||
[role='button']:not([aria-disabled='true']) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Champs : rayon unifié + anneau de focus visible (absent auparavant). */
|
/* Champs : rayon unifié + anneau de focus visible (absent auparavant). */
|
||||||
.input {
|
.input {
|
||||||
|
|||||||
@@ -102,6 +102,25 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Intégrations : Gitea -->
|
||||||
|
<section class="card flex flex-col gap-3">
|
||||||
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
|
<Puzzle :size="16" /> {{ t('settings.integrations') }}
|
||||||
|
</h2>
|
||||||
|
<form class="flex flex-col gap-2" @submit.prevent="saveGitea">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-zinc-400">{{ t('settings.giteaUrlLabel') }}</span>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input v-model="giteaInput" type="url" class="input min-w-48 flex-1" :placeholder="t('settings.giteaUrlPlaceholder')" />
|
||||||
|
<BaseButton type="submit" variant="primary" :loading="settings.saving" :disabled="!giteaDirty">
|
||||||
|
{{ t('settings.save') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-zinc-500">{{ t('settings.giteaUrlHint') }}</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Découverte des dépôts -->
|
<!-- Découverte des dépôts -->
|
||||||
<section class="card flex flex-col gap-3">
|
<section class="card flex flex-col gap-3">
|
||||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
@@ -137,26 +156,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sécurité & conformité -->
|
|
||||||
<section class="card flex flex-col gap-3">
|
|
||||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
|
||||||
<Shield :size="16" /> {{ t('settings.compliance') }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.complianceHint') }}</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<BaseButton size="sm" :icon="Download" :loading="exporting" @click="exportData">{{ t('settings.exportData') }}</BaseButton>
|
|
||||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="auditing" @click="loadAudit">{{ t('settings.refreshAudit') }}</BaseButton>
|
|
||||||
<BaseButton variant="ghost" size="sm" :icon="Trash2" @click="deleteMyData">{{ t('settings.deleteData') }}</BaseButton>
|
|
||||||
</div>
|
|
||||||
<ul v-if="audit.length" class="flex flex-col divide-y divide-zinc-800/80 text-xs">
|
|
||||||
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
|
|
||||||
<span class="font-mono text-zinc-300">{{ e.action }}</span>
|
|
||||||
<span class="truncate text-zinc-500">{{ e.actor }}</span>
|
|
||||||
<span class="shrink-0 text-zinc-600">{{ new Date(e.ts).toLocaleString() }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Serveur (lecture seule) -->
|
<!-- Serveur (lecture seule) -->
|
||||||
<section class="card flex flex-col gap-3">
|
<section class="card flex flex-col gap-3">
|
||||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
@@ -178,42 +177,15 @@
|
|||||||
</dl>
|
</dl>
|
||||||
<SkeletonRow v-else />
|
<SkeletonRow v-else />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Support -->
|
|
||||||
<section class="card flex flex-col gap-3">
|
|
||||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
|
||||||
<Coffee :size="16" /> {{ t('settings.support') }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.supportHint') }}</p>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
:href="BUYMEACOFFEE_URL"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
|
||||||
>
|
|
||||||
<Coffee :size="16" /> {{ t('settings.supportCta') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Bell, BellOff, Check, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
|
import { Bell, BellOff, Check, Copy, FolderOpen, KeyRound, Plus, Puzzle, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
|
||||||
import type {
|
import type { CreateTokenResponse, TokenInfo, TokensListResponse } from '@arboretum/shared';
|
||||||
AuditLogEntry,
|
|
||||||
AuditLogsResponse,
|
|
||||||
CreateTokenResponse,
|
|
||||||
DataExportResponse,
|
|
||||||
DeleteMyDataResponse,
|
|
||||||
TokenInfo,
|
|
||||||
TokensListResponse,
|
|
||||||
} from '@arboretum/shared';
|
|
||||||
import { api, ApiError } from '../lib/api';
|
import { api, ApiError } from '../lib/api';
|
||||||
import { BUYMEACOFFEE_URL } from '../lib/constants';
|
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import { usePushStore } from '../stores/push';
|
import { usePushStore } from '../stores/push';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
@@ -300,6 +272,19 @@ async function copy(text: string, target?: 'new'): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Gitea ----
|
||||||
|
const giteaInput = ref('');
|
||||||
|
const giteaDirty = computed(() => giteaInput.value.trim() !== (settings.giteaUrl ?? ''));
|
||||||
|
async function saveGitea(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await settings.save({ giteaUrl: giteaInput.value.trim() || null });
|
||||||
|
giteaInput.value = settings.giteaUrl ?? '';
|
||||||
|
toasts.success(t('settings.saved'));
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Découverte des dépôts ----
|
// ---- Découverte des dépôts ----
|
||||||
const rootsDraft = ref<string[]>([]);
|
const rootsDraft = ref<string[]>([]);
|
||||||
const intervalDraft = ref(0);
|
const intervalDraft = ref(0);
|
||||||
@@ -328,55 +313,9 @@ async function saveDiscovery(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Sécurité & conformité (audit / RGPD) ----
|
|
||||||
const audit = ref<AuditLogEntry[]>([]);
|
|
||||||
const auditing = ref(false);
|
|
||||||
const exporting = ref(false);
|
|
||||||
|
|
||||||
async function loadAudit(): Promise<void> {
|
|
||||||
auditing.value = true;
|
|
||||||
try {
|
|
||||||
audit.value = (await api.get<AuditLogsResponse>('/api/v1/audit-logs?limit=20')).entries;
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e);
|
|
||||||
} finally {
|
|
||||||
auditing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportData(): Promise<void> {
|
|
||||||
exporting.value = true;
|
|
||||||
try {
|
|
||||||
const data = await api.get<DataExportResponse>('/api/v1/data/export');
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'arboretum-data-export.json';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e);
|
|
||||||
} finally {
|
|
||||||
exporting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMyData(): Promise<void> {
|
|
||||||
if (!window.confirm(t('settings.deleteConfirm'))) return;
|
|
||||||
try {
|
|
||||||
const pending = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', {});
|
|
||||||
if (pending.status !== 'pending' || !pending.confirm) return;
|
|
||||||
const done = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', { confirm: pending.confirm });
|
|
||||||
toasts.success(t('settings.deleteDone'));
|
|
||||||
if (done.summary.tokenRevoked) window.location.reload(); // token courant révoqué → re-login
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
||||||
|
giteaInput.value = settings.giteaUrl ?? '';
|
||||||
syncDiscoveryDraft();
|
syncDiscoveryDraft();
|
||||||
void push.refresh();
|
void push.refresh();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ const en: HelpSection[] = [
|
|||||||
blurb: 'Shortcuts to the tools around your repos.',
|
blurb: 'Shortcuts to the tools around your repos.',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Source code',
|
title: 'Gitea',
|
||||||
body: 'The “Source code” icon in the navigation always links to the Arboretum source repository on Gitea.',
|
body: 'Set your Gitea instance URL in Settings → Integrations to add a one-click Gitea icon to the navigation. Leave it empty to hide the icon.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -342,8 +342,8 @@ const fr: HelpSection[] = [
|
|||||||
blurb: 'Des raccourcis vers les outils autour de vos dépôts.',
|
blurb: 'Des raccourcis vers les outils autour de vos dépôts.',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Code source',
|
title: 'Gitea',
|
||||||
body: 'L’icône « Code source » de la navigation pointe toujours vers le dépôt source d’Arboretum sur Gitea.',
|
body: 'Renseignez l’URL de votre instance Gitea dans Réglages → Intégrations pour ajouter une icône Gitea en un clic dans la navigation. Laissez vide pour masquer l’icône.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Spike S4 — Découverte JSONL + registre — VERDICT : ✅ GO
|
# Spike S4 — Découverte JSONL + registre — VERDICT : ✅ GO
|
||||||
|
|
||||||
Exécuté sur un poste de dev (CLI 2.1.170/2.1.173 actifs, transcripts 2.1.139 → 2.1.173). Script : `scan.mjs`.
|
Exécuté le 2026-06-11 sur la machine de Johan (CLI 2.1.170/2.1.173 actifs, transcripts 2.1.139 → 2.1.173). Script : `scan.mjs`, résultats bruts : `result.json`.
|
||||||
|
|
||||||
## Critères Go
|
## Critères Go
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"scan": {
|
||||||
|
"files": 1318,
|
||||||
|
"bytes": 470368918,
|
||||||
|
"ok": 1316,
|
||||||
|
"noCwd": 2,
|
||||||
|
"noSessionId": 0,
|
||||||
|
"empty": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"mb": "449",
|
||||||
|
"scanMs": "1368",
|
||||||
|
"okPct": "99.8 %"
|
||||||
|
},
|
||||||
|
"mungeValidation": {
|
||||||
|
"mungeMatch": 1316,
|
||||||
|
"mungeMismatch": 0,
|
||||||
|
"mismatchSamples": []
|
||||||
|
},
|
||||||
|
"lineTypes": {
|
||||||
|
"user": 1315,
|
||||||
|
"last-prompt": 1313,
|
||||||
|
"attachment": 1308,
|
||||||
|
"assistant": 1204,
|
||||||
|
"queue-operation": 1001,
|
||||||
|
"ai-title": 470,
|
||||||
|
"file-history-snapshot": 431,
|
||||||
|
"system": 398,
|
||||||
|
"permission-mode": 387,
|
||||||
|
"agent-name": 228,
|
||||||
|
"mode": 219,
|
||||||
|
"bridge-session": 6
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"2.1.139": 16,
|
||||||
|
"2.1.140": 86,
|
||||||
|
"2.1.142": 19,
|
||||||
|
"2.1.143": 113,
|
||||||
|
"2.1.144": 58,
|
||||||
|
"2.1.145": 81,
|
||||||
|
"2.1.146": 43,
|
||||||
|
"2.1.150": 137,
|
||||||
|
"2.1.152": 59,
|
||||||
|
"2.1.153": 18,
|
||||||
|
"2.1.156": 80,
|
||||||
|
"2.1.159": 77,
|
||||||
|
"2.1.160": 58,
|
||||||
|
"2.1.161": 102,
|
||||||
|
"2.1.162": 60,
|
||||||
|
"2.1.163": 3,
|
||||||
|
"2.1.165": 20,
|
||||||
|
"2.1.166": 6,
|
||||||
|
"2.1.168": 83,
|
||||||
|
"2.1.169": 74,
|
||||||
|
"2.1.170": 90,
|
||||||
|
"2.1.173": 33
|
||||||
|
},
|
||||||
|
"registry": {
|
||||||
|
"count": 3,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"file": "1061541.json",
|
||||||
|
"pid": 1061541,
|
||||||
|
"sessionId": "026cc229-c1cd-4327-9c53-68406794e870",
|
||||||
|
"cwd": "/home/johan/WebstormProjects",
|
||||||
|
"status": "busy",
|
||||||
|
"waitingFor": null,
|
||||||
|
"kind": "interactive",
|
||||||
|
"version": "2.1.170",
|
||||||
|
"peerProtocol": 1,
|
||||||
|
"updatedAt": 1781192047718,
|
||||||
|
"alive": true,
|
||||||
|
"procStartOk": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "1701999.json",
|
||||||
|
"pid": 1701999,
|
||||||
|
"sessionId": "8733906c-832a-4e74-aab7-7d8b2f5eb9d2",
|
||||||
|
"cwd": "/home/johan/WebstormProjects",
|
||||||
|
"status": "idle",
|
||||||
|
"waitingFor": null,
|
||||||
|
"kind": "interactive",
|
||||||
|
"version": "2.1.173",
|
||||||
|
"peerProtocol": 1,
|
||||||
|
"updatedAt": 1781191339305,
|
||||||
|
"alive": true,
|
||||||
|
"procStartOk": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "597768.json",
|
||||||
|
"pid": 597768,
|
||||||
|
"sessionId": "95f8eaf3-feeb-4fbe-8848-fa7a994c8d7f",
|
||||||
|
"cwd": "/home/johan/WebstormProjects",
|
||||||
|
"status": "busy",
|
||||||
|
"waitingFor": null,
|
||||||
|
"kind": "interactive",
|
||||||
|
"version": "2.1.170",
|
||||||
|
"peerProtocol": 1,
|
||||||
|
"updatedAt": 1781192263383,
|
||||||
|
"alive": true,
|
||||||
|
"procStartOk": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"attachment": {
|
||||||
|
"inWebstormProjects": 573,
|
||||||
|
"outside": 743,
|
||||||
|
"topDirs": {
|
||||||
|
"(racine)": 323,
|
||||||
|
"beehelp_prospect": 133,
|
||||||
|
"techos_web": 31,
|
||||||
|
"beehelp_api": 27,
|
||||||
|
"techos_api": 23,
|
||||||
|
"beehelp_web": 15,
|
||||||
|
"teknoroot-simulator-web": 5,
|
||||||
|
"beehelp_claude_marketplace": 3,
|
||||||
|
"beehelp_lambdas": 2,
|
||||||
|
"teknoroot-simulator-api": 2,
|
||||||
|
"Beehelp_iac": 1,
|
||||||
|
"Techos_V1_outdated": 1,
|
||||||
|
"bee-tiptap-resolver": 1,
|
||||||
|
"beehelp_api-wt-qualiobee-docs": 1,
|
||||||
|
"beehelp_api-wt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failuresSample": [
|
||||||
|
{
|
||||||
|
"fp": "/home/johan/.claude/projects/-home-johan-WebstormProjects-beehelp-api/0811ca37-c488-45aa-8a9e-5ecd6c87dfeb.jsonl",
|
||||||
|
"why": "pas de cwd",
|
||||||
|
"types": [
|
||||||
|
"queue-operation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fp": "/home/johan/.claude/projects/-tmp/59e9a9f8-a5f2-4773-b1f6-72fa8b9514a9.jsonl",
|
||||||
|
"why": "pas de cwd",
|
||||||
|
"types": [
|
||||||
|
"queue-operation"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user