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:
@@ -6,6 +6,16 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
## [0.9.0] — 2026-06-23 — fin Phase 1 (cerveau lecture seule)
|
||||||
### Added
|
### Added
|
||||||
- `src/gatekeeper/assets.ts` : interfaces du cycle "need review" posées pour la
|
- `src/gatekeeper/assets.ts` : interfaces du cycle "need review" posées pour la
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest).
|
# 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
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json tsconfig.build.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22.14-bookworm-slim AS runtime
|
FROM node:24.13-bookworm-slim AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|||||||
Generated
+9
-9
@@ -14,13 +14,13 @@
|
|||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.13.1",
|
"@types/node": "^24.13.2",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"vitest": "4.1.9"
|
"vitest": "4.1.9"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
@@ -1048,13 +1048,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.1",
|
"version": "24.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||||
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
|
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
@@ -3305,9 +3305,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.20.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "CHLOVA — cerveau LLM en boucle tool-calling (Phase 1 : lecture seule)",
|
"description": "CHLOVA — cerveau LLM en boucle tool-calling (Phase 1 : lecture seule)",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.13.1",
|
"@types/node": "24.13.2",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"vitest": "4.1.9"
|
"vitest": "4.1.9"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user