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>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user