feat(FN-1119): add agent API key management APIs
- Add AgentApiKey and AgentApiKeyCreateResult types and export them from @fusion/core - Implement AgentStore API key create/list/revoke methods with SHA-256 token hashing and JSONL persistence - Add comprehensive AgentStore coverage for CRUD behavior, revocation semantics, persistence, and concurrent key creation - Add dashboard routes and API tests for POST/GET/DELETE agent key endpoints with 404/validation handling - Add a minor @gsxdsm/fusion changeset for the new agent API key feature
This commit is contained in:
@@ -3,16 +3,18 @@
|
||||
*
|
||||
* Agents are stored at `.fusion/agents/{agentId}.json` with their metadata.
|
||||
* Heartbeat events are appended to `.fusion/agents/{agentId}-heartbeats.jsonl`.
|
||||
* API keys are stored in `.fusion/agents/{agentId}-keys.jsonl` (hash-only).
|
||||
*
|
||||
* File Structure:
|
||||
* - agents/{agentId}.json: Agent metadata (id, name, role, state, taskId, timestamps, metadata)
|
||||
* - agents/{agentId}-heartbeats.jsonl: Append-only heartbeat events
|
||||
* - agents/{agentId}-keys.jsonl: API key records with SHA-256 token hashes
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -20,6 +22,8 @@ import type {
|
||||
AgentCapability,
|
||||
AgentCreateInput,
|
||||
AgentUpdateInput,
|
||||
AgentApiKey,
|
||||
AgentApiKeyCreateResult,
|
||||
AgentHeartbeatEvent,
|
||||
AgentHeartbeatRun,
|
||||
AgentDetail,
|
||||
@@ -364,6 +368,83 @@ export class AgentStore extends EventEmitter {
|
||||
return agents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API key for an agent.
|
||||
* Persists only the SHA-256 token hash; plaintext token is returned once.
|
||||
*/
|
||||
async createApiKey(agentId: string, options?: { label?: string }): Promise<AgentApiKeyCreateResult> {
|
||||
return this.withLock(agentId, async () => {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const createdAt = new Date().toISOString();
|
||||
const label = options?.label?.trim();
|
||||
|
||||
const key: AgentApiKey = {
|
||||
id: `key-${randomUUID().slice(0, 8)}`,
|
||||
agentId,
|
||||
tokenHash,
|
||||
createdAt,
|
||||
...(label ? { label } : {}),
|
||||
};
|
||||
|
||||
const keyPath = this.getApiKeysPath(agentId);
|
||||
await writeFile(keyPath, `${JSON.stringify(key)}\n`, { flag: "a" });
|
||||
|
||||
return { key, token };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys for an agent, including revoked keys.
|
||||
*/
|
||||
async listApiKeys(agentId: string): Promise<AgentApiKey[]> {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
return this.readApiKeys(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key for an agent.
|
||||
* Revoking an already-revoked key is a no-op.
|
||||
*/
|
||||
async revokeApiKey(agentId: string, keyId: string): Promise<AgentApiKey> {
|
||||
return this.withLock(agentId, async () => {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const keys = await this.readApiKeys(agentId);
|
||||
const keyIndex = keys.findIndex((key) => key.id === keyId);
|
||||
if (keyIndex === -1) {
|
||||
throw new Error(`API key ${keyId} not found for agent ${agentId}`);
|
||||
}
|
||||
|
||||
const existing = keys[keyIndex];
|
||||
if (existing.revokedAt) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const revoked: AgentApiKey = {
|
||||
...existing,
|
||||
revokedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
keys[keyIndex] = revoked;
|
||||
await this.writeApiKeys(agentId, keys);
|
||||
|
||||
return revoked;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent and its heartbeat history.
|
||||
* @param agentId - The agent ID
|
||||
@@ -737,6 +818,43 @@ export class AgentStore extends EventEmitter {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private getApiKeysPath(agentId: string): string {
|
||||
return join(this.agentsDir, `${agentId}-keys.jsonl`);
|
||||
}
|
||||
|
||||
private async readApiKeys(agentId: string): Promise<AgentApiKey[]> {
|
||||
const keyPath = this.getApiKeysPath(agentId);
|
||||
if (!existsSync(keyPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await readFile(keyPath, "utf-8");
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys: AgentApiKey[] = [];
|
||||
const lines = content.split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const key = JSON.parse(line) as AgentApiKey;
|
||||
if (key.agentId === agentId) {
|
||||
keys.push(key);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private async writeApiKeys(agentId: string, keys: AgentApiKey[]): Promise<void> {
|
||||
const keyPath = this.getApiKeysPath(agentId);
|
||||
const content = keys.map((key) => JSON.stringify(key)).join("\n");
|
||||
await writeFile(keyPath, content ? `${content}\n` : "");
|
||||
}
|
||||
|
||||
private async readAgentFile(agentId: string): Promise<AgentData> {
|
||||
const path = join(this.agentsDir, `${agentId}.json`);
|
||||
const content = await readFile(path, "utf-8");
|
||||
|
||||
Reference in New Issue
Block a user