// 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(["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, };