feat: AssetRepository SQLite + cycle need-review persistant (v0.10.0)

Table assets sur node:sqlite (Node 24, zéro dep native) : CRUD,
listByStatus, incrementExec, setRiskTier anti-escalade, expireProvisional
(cron PROVISOIRE→BLOQUÉ). 6 tests. Bump Node 24 (sqlite stable), Dockerfile
24.13 + copie tsconfig.build.json. 0 vuln.

Palier de risque : reversible (persistance d'état, aucune mutation d'infra).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:24:35 +02:00
parent 1cce8c9db6
commit 56e948c976
6 changed files with 238 additions and 15 deletions
+10
View File
@@ -6,6 +6,16 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased]
## [0.10.0] — 2026-06-23 — début Phase 2 (écriture + need-review)
### Added
- `src/gatekeeper/repository.ts` : `AssetRepository` SQLite (`node:sqlite`, zéro
dépendance native) — schema table assets, CRUD, `listByStatus`, `incrementExec`,
`setRiskTier` (anti-escalade), `expireProvisional` (cron PROVISOIRE→BLOQUÉ).
- Tests repository (6) : persistance, anti-escalade, expiration du sursis.
### Changed
- Node 24 (sqlite natif stable) : `engines`, base Dockerfile `node:24.13`,
`@types/node` 24, copie `tsconfig.build.json` dans l'image. `npm audit` 0 vuln.
## [0.9.0] — 2026-06-23 — fin Phase 1 (cerveau lecture seule)
### Added
- `src/gatekeeper/assets.ts` : interfaces du cycle "need review" posées pour la
+4 -4
View File
@@ -1,15 +1,15 @@
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest).
# TODO épingler le digest (node:22.14-bookworm-slim@sha256:...) avant déploiement réel.
# TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel.
FROM node:22.14-bookworm-slim AS build
FROM node:24.13-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build
FROM node:22.14-bookworm-slim AS runtime
FROM node:24.13-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json* ./
+9 -9
View File
@@ -14,13 +14,13 @@
"zod": "3.24.1"
},
"devDependencies": {
"@types/node": "22.13.1",
"@types/node": "^24.13.2",
"tsx": "4.22.4",
"typescript": "5.7.3",
"vitest": "4.1.9"
},
"engines": {
"node": ">=22"
"node": ">=24"
}
},
"node_modules/@emnapi/core": {
@@ -1048,13 +1048,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@vitest/expect": {
@@ -3305,9 +3305,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
+2 -2
View File
@@ -5,7 +5,7 @@
"type": "module",
"description": "CHLOVA — cerveau LLM en boucle tool-calling (Phase 1 : lecture seule)",
"engines": {
"node": ">=22"
"node": ">=24"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
@@ -22,7 +22,7 @@
"zod": "3.24.1"
},
"devDependencies": {
"@types/node": "22.13.1",
"@types/node": "24.13.2",
"tsx": "4.22.4",
"typescript": "5.7.3",
"vitest": "4.1.9"
+156
View File
@@ -0,0 +1,156 @@
import { DatabaseSync } from "node:sqlite";
import type { RiskTier } from "../audit/log.js";
import {
type Asset,
type AssetStatus,
type AssetType,
assertNoEscalation,
} from "./assets.js";
/**
* Persistance de la table d'assets (cycle "need review", Phase 2).
*
* Backend SQLite via `node:sqlite` (intégré à Node 24, zéro dépendance native).
* La table fait foi de l'état du cycle de vie ; le commit/doc d'un asset vivent
* dans Git (champs commit_link / doc_link). Voir docs/risk-tiers.md.
*/
interface Row {
id: string;
type: string;
version: string;
risk_tier: string;
status: string;
created_at: number;
expires_at: number | null;
exec_count: number;
commit_link: string | null;
doc_link: string | null;
}
function rowToAsset(r: Row): Asset {
return {
id: r.id,
type: r.type as AssetType,
version: r.version,
riskTier: r.risk_tier as RiskTier,
status: r.status as AssetStatus,
createdAt: r.created_at,
expiresAt: r.expires_at,
execCount: r.exec_count,
commitLink: r.commit_link,
docLink: r.doc_link,
};
}
export class AssetRepository {
private readonly db: DatabaseSync;
constructor(dbPath: string) {
this.db = new DatabaseSync(dbPath);
this.db.exec("PRAGMA journal_mode = WAL;");
this.migrate();
}
private migrate(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
version TEXT NOT NULL,
risk_tier TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
exec_count INTEGER NOT NULL DEFAULT 0,
commit_link TEXT,
doc_link TEXT
);
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
`);
}
create(asset: Asset): void {
this.db
.prepare(
`INSERT INTO assets
(id, type, version, risk_tier, status, created_at, expires_at, exec_count, commit_link, doc_link)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
asset.id,
asset.type,
asset.version,
asset.riskTier,
asset.status,
asset.createdAt,
asset.expiresAt,
asset.execCount,
asset.commitLink,
asset.docLink,
);
}
get(id: string): Asset | null {
const row = this.db.prepare("SELECT * FROM assets WHERE id = ?").get(id) as
| Row
| undefined;
return row ? rowToAsset(row) : null;
}
list(): Asset[] {
const rows = this.db
.prepare("SELECT * FROM assets ORDER BY created_at DESC")
.all() as unknown as Row[];
return rows.map(rowToAsset);
}
listByStatus(status: AssetStatus): Asset[] {
const rows = this.db
.prepare("SELECT * FROM assets WHERE status = ? ORDER BY created_at DESC")
.all(status) as unknown as Row[];
return rows.map(rowToAsset);
}
updateStatus(id: string, status: AssetStatus): void {
this.db.prepare("UPDATE assets SET status = ? WHERE id = ?").run(status, id);
}
/** Change le palier de risque en refusant toute escalade (privileged→reversible). */
setRiskTier(id: string, next: RiskTier): void {
const current = this.get(id);
if (!current) throw new Error(`asset inconnu: ${id}`);
assertNoEscalation(current.riskTier, next);
this.db.prepare("UPDATE assets SET risk_tier = ? WHERE id = ?").run(next, id);
}
incrementExec(id: string): number {
this.db
.prepare("UPDATE assets SET exec_count = exec_count + 1 WHERE id = ?")
.run(id);
return this.get(id)?.execCount ?? 0;
}
/**
* Cron : passe en BLOQUÉ les assets PROVISOIRE dont le sursis a expiré.
* Retourne les ids basculés. (Les privilégiés sont déjà BLOQUÉS, sans sursis.)
*/
expireProvisional(now = Date.now()): string[] {
const due = this.db
.prepare(
"SELECT id FROM assets WHERE status = 'provisoire' AND expires_at IS NOT NULL AND expires_at <= ?",
)
.all(now) as unknown as { id: string }[];
if (due.length === 0) return [];
this.db
.prepare(
"UPDATE assets SET status = 'bloqué' WHERE status = 'provisoire' AND expires_at IS NOT NULL AND expires_at <= ?",
)
.run(now);
return due.map((r) => r.id);
}
close(): void {
this.db.close();
}
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { AssetRepository } from "../src/gatekeeper/repository.js";
import { createAsset, EscalationError, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js";
let repo: AssetRepository;
beforeEach(() => {
repo = new AssetRepository(":memory:");
});
afterEach(() => {
repo.close();
});
describe("AssetRepository", () => {
it("crée et relit un asset", () => {
const a = createAsset({ id: "a", type: "tool", version: "1.0.0", riskTier: "reversible", now: 1000 });
repo.create(a);
expect(repo.get("a")).toEqual(a);
});
it("liste par statut", () => {
repo.create(createAsset({ id: "r", type: "tool", version: "1.0.0", riskTier: "reversible" }));
repo.create(createAsset({ id: "p", type: "stack-portainer", version: "1.0.0", riskTier: "privileged" }));
expect(repo.listByStatus("provisoire").map((x) => x.id)).toEqual(["r"]);
expect(repo.listByStatus("bloqué").map((x) => x.id)).toEqual(["p"]);
});
it("incrémente le compteur d'exécution", () => {
repo.create(createAsset({ id: "a", type: "tool", version: "1.0.0", riskTier: "reversible" }));
expect(repo.incrementExec("a")).toBe(1);
expect(repo.incrementExec("a")).toBe(2);
});
it("setRiskTier refuse l'escalade privileged→reversible", () => {
repo.create(createAsset({ id: "p", type: "stack-portainer", version: "1.0.0", riskTier: "privileged" }));
expect(() => repo.setRiskTier("p", "reversible")).toThrow(EscalationError);
// le palier n'a pas changé
expect(repo.get("p")?.riskTier).toBe("privileged");
});
it("expireProvisional bascule les provisoires échus en bloqué", () => {
const t0 = 1_000_000;
repo.create(createAsset({ id: "old", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 }));
repo.create(createAsset({ id: "fresh", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 }));
// bascule à un instant juste après l'expiration de "old" mais on garde "fresh" frais
const switched = repo.expireProvisional(t0 + PROVISIONAL_TTL_MS + 1);
expect(switched).toEqual(expect.arrayContaining(["old", "fresh"]));
expect(repo.get("old")?.status).toBe("bloqué");
});
it("n'expire rien avant l'échéance", () => {
const t0 = 1_000_000;
repo.create(createAsset({ id: "a", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 }));
expect(repo.expireProvisional(t0 + 1)).toEqual([]);
expect(repo.get("a")?.status).toBe("provisoire");
});
});