08919752e3
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>
119 lines
3.5 KiB
TypeScript
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,
|
|
};
|