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:
@@ -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* ./
|
||||
|
||||
Generated
+9
-9
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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