Files
gilles 08919752e3 feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK).
- tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/
  hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002.
- tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif,
  dual-read + backfill). Migration 0003. Fix séquence journal de migration.
- tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate),
  SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH),
  SJ-3 (reboot vérifié boot_id).
- WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics,
  scaffold app_rust, ajustements frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00

119 lines
3.5 KiB
TypeScript

// server/services/apiClients.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { ApiClientScope, ApiClientView, CreatedApiClient } from "@shared/types.js";
import { db, schema } from "../db/client.js";
import { env } from "../env.js";
import {
generateApiToken,
hashApiToken,
tokenPrefix,
verifyApiToken,
} from "../crypto/apiTokens.js";
type ApiClientRow = typeof schema.apiClients.$inferSelect;
const ALLOWED_SCOPES = new Set<ApiClientScope>(["read", "operate", "admin", "debug"]);
export interface CreateApiClientInput {
name: string;
scopes: ApiClientScope[];
}
function normalizeScopes(scopes: ApiClientScope[]): ApiClientScope[] {
const unique = [...new Set(scopes)];
if (unique.length === 0) return ["read"];
for (const scope of unique) {
if (!ALLOWED_SCOPES.has(scope)) throw new Error(`Scope API inconnu: ${scope}`);
}
return unique;
}
function scopesFromJson(json: string): ApiClientScope[] {
const parsed = JSON.parse(json) as ApiClientScope[];
return normalizeScopes(parsed);
}
function toView(row: ApiClientRow): ApiClientView {
return {
id: row.id,
name: row.name,
tokenPrefix: row.tokenPrefix,
scopes: scopesFromJson(row.scopesJson),
createdAt: row.createdAt,
lastUsedAt: row.lastUsedAt,
revokedAt: row.revokedAt,
};
}
export function createApiClient(input: CreateApiClientInput, now = new Date()): CreatedApiClient {
const name = input.name.trim();
if (!name) throw new Error("Le nom du client API est obligatoire");
const scopes = normalizeScopes(input.scopes);
const token = generateApiToken();
const pepper = env.requireMasterKey();
const row: ApiClientRow = {
id: randomUUID(),
name,
tokenPrefix: tokenPrefix(token),
tokenHash: hashApiToken(token, pepper),
scopesJson: JSON.stringify(scopes),
createdAt: now.toISOString(),
lastUsedAt: null,
revokedAt: null,
};
db.insert(schema.apiClients).values(row).run();
return { client: toView(row), token };
}
export function listApiClients(): ApiClientView[] {
return db.select().from(schema.apiClients).all().map(toView);
}
export function revokeApiClient(id: string, now = new Date()): ApiClientView | null {
const existing = db.select().from(schema.apiClients).where(eq(schema.apiClients.id, id)).get();
if (!existing) return null;
const revokedAt = now.toISOString();
db.update(schema.apiClients).set({ revokedAt }).where(eq(schema.apiClients.id, id)).run();
return toView({ ...existing, revokedAt });
}
export function authenticateApiToken(token: string, now = new Date()): ApiClientView | null {
const pepper = env.requireMasterKey();
const tokenHash = hashApiToken(token, pepper);
const row = db
.select()
.from(schema.apiClients)
.where(eq(schema.apiClients.tokenHash, tokenHash))
.get();
if (!row || row.revokedAt) return null;
if (!verifyApiToken(token, row.tokenHash, pepper)) return null;
const lastUsedAt = now.toISOString();
db.update(schema.apiClients)
.set({ lastUsedAt })
.where(eq(schema.apiClients.id, row.id))
.run();
return toView({ ...row, lastUsedAt });
}
export function hasApiScope(scopes: ApiClientScope[], required: ApiClientScope): boolean {
if (scopes.includes("admin")) return true;
if (required === "read") return scopes.length > 0;
if (required === "operate") return scopes.includes("operate");
if (required === "debug") return scopes.includes("debug");
return false;
}
export const apiClientInternals = {
normalizeScopes,
scopesFromJson,
toView,
hasApiScope,
};