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:
@@ -14,8 +14,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentStore } from "./agent-store.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, existsSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentCapability, AgentState } from "./types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
@@ -859,6 +860,147 @@ describe("AgentStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── API Keys ──────────────────────────────────────────────────────
|
||||
|
||||
describe("API Keys", () => {
|
||||
it("createApiKey returns key metadata and one-time plaintext token", async () => {
|
||||
const agent = await store.createAgent({ name: "KeyAgent", role: "executor" });
|
||||
|
||||
const result = await store.createApiKey(agent.id);
|
||||
|
||||
expect(result.key.id).toMatch(/^key-[a-f0-9]{8}$/);
|
||||
expect(result.key.agentId).toBe(agent.id);
|
||||
expect(result.key.tokenHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(new Date(result.key.createdAt).getTime()).not.toBeNaN();
|
||||
expect(result.key.revokedAt).toBeUndefined();
|
||||
|
||||
const expectedHash = createHash("sha256").update(result.token).digest("hex");
|
||||
expect(result.key.tokenHash).toBe(expectedHash);
|
||||
|
||||
const keyPath = join(rootDir, "agents", `${agent.id}-keys.jsonl`);
|
||||
expect(existsSync(keyPath)).toBe(true);
|
||||
const persisted = readFileSync(keyPath, "utf-8");
|
||||
expect(persisted).not.toContain(result.token);
|
||||
});
|
||||
|
||||
it("createApiKey with label persists the label", async () => {
|
||||
const agent = await store.createAgent({ name: "LabeledKeyAgent", role: "executor" });
|
||||
|
||||
const { key } = await store.createApiKey(agent.id, { label: "CI Key" });
|
||||
const keys = await store.listApiKeys(agent.id);
|
||||
|
||||
expect(key.label).toBe("CI Key");
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0].label).toBe("CI Key");
|
||||
});
|
||||
|
||||
it("createApiKey omits empty labels", async () => {
|
||||
const agent = await store.createAgent({ name: "NoLabelKeyAgent", role: "executor" });
|
||||
|
||||
const { key } = await store.createApiKey(agent.id, { label: " " });
|
||||
expect(key.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("createApiKey throws when agent is not found", async () => {
|
||||
await expect(store.createApiKey("agent-missing")).rejects.toThrow(
|
||||
"Agent agent-missing not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("listApiKeys returns keys for one agent and empty array for an agent with no keys", async () => {
|
||||
const withKeys = await store.createAgent({ name: "WithKeys", role: "executor" });
|
||||
const noKeys = await store.createAgent({ name: "NoKeys", role: "executor" });
|
||||
const other = await store.createAgent({ name: "Other", role: "reviewer" });
|
||||
|
||||
const first = await store.createApiKey(withKeys.id);
|
||||
const second = await store.createApiKey(withKeys.id);
|
||||
await store.createApiKey(other.id);
|
||||
|
||||
const withKeysList = await store.listApiKeys(withKeys.id);
|
||||
expect(withKeysList).toHaveLength(2);
|
||||
expect(withKeysList.map((key) => key.id)).toEqual([first.key.id, second.key.id]);
|
||||
|
||||
const noKeysList = await store.listApiKeys(noKeys.id);
|
||||
expect(noKeysList).toEqual([]);
|
||||
});
|
||||
|
||||
it("listApiKeys throws when agent is not found", async () => {
|
||||
await expect(store.listApiKeys("agent-missing")).rejects.toThrow(
|
||||
"Agent agent-missing not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("revokeApiKey sets revokedAt and revoked key remains in list", async () => {
|
||||
const agent = await store.createAgent({ name: "RevokeKeyAgent", role: "executor" });
|
||||
const { key } = await store.createApiKey(agent.id);
|
||||
|
||||
const revoked = await store.revokeApiKey(agent.id, key.id);
|
||||
expect(revoked.id).toBe(key.id);
|
||||
expect(revoked.revokedAt).toBeDefined();
|
||||
|
||||
const keys = await store.listApiKeys(agent.id);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0].id).toBe(key.id);
|
||||
expect(keys[0].revokedAt).toBe(revoked.revokedAt);
|
||||
});
|
||||
|
||||
it("revokeApiKey already revoked is a no-op", async () => {
|
||||
const agent = await store.createAgent({ name: "RevokeTwiceAgent", role: "executor" });
|
||||
const { key } = await store.createApiKey(agent.id);
|
||||
|
||||
const firstRevocation = await store.revokeApiKey(agent.id, key.id);
|
||||
const secondRevocation = await store.revokeApiKey(agent.id, key.id);
|
||||
|
||||
expect(firstRevocation.revokedAt).toBeDefined();
|
||||
expect(secondRevocation.revokedAt).toBe(firstRevocation.revokedAt);
|
||||
});
|
||||
|
||||
it("revokeApiKey throws when key is not found", async () => {
|
||||
const agent = await store.createAgent({ name: "MissingKeyAgent", role: "executor" });
|
||||
|
||||
await expect(store.revokeApiKey(agent.id, "key-missing")).rejects.toThrow(
|
||||
`API key key-missing not found for agent ${agent.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it("revokeApiKey throws when agent is not found", async () => {
|
||||
await expect(store.revokeApiKey("agent-missing", "key-1234")).rejects.toThrow(
|
||||
"Agent agent-missing not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("multiple keys can be listed and revoking one does not affect others", async () => {
|
||||
const agent = await store.createAgent({ name: "MultiKeyAgent", role: "executor" });
|
||||
|
||||
const key1 = await store.createApiKey(agent.id, { label: "key-1" });
|
||||
const key2 = await store.createApiKey(agent.id, { label: "key-2" });
|
||||
const key3 = await store.createApiKey(agent.id, { label: "key-3" });
|
||||
|
||||
const revoked = await store.revokeApiKey(agent.id, key2.key.id);
|
||||
|
||||
const keys = await store.listApiKeys(agent.id);
|
||||
expect(keys).toHaveLength(3);
|
||||
const byId = new Map(keys.map((key) => [key.id, key]));
|
||||
expect(byId.get(key1.key.id)?.revokedAt).toBeUndefined();
|
||||
expect(byId.get(key2.key.id)?.revokedAt).toBe(revoked.revokedAt);
|
||||
expect(byId.get(key3.key.id)?.revokedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("API keys survive store reinitialization", async () => {
|
||||
const agent = await store.createAgent({ name: "KeyPersistence", role: "executor" });
|
||||
const { key } = await store.createApiKey(agent.id, { label: "persist" });
|
||||
|
||||
const store2 = new AgentStore({ rootDir });
|
||||
await store2.init();
|
||||
|
||||
const keys = await store2.listApiKeys(agent.id);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0].id).toBe(key.id);
|
||||
expect(keys[0].label).toBe("persist");
|
||||
});
|
||||
});
|
||||
|
||||
// ── concurrency (withLock) ────────────────────────────────────────
|
||||
|
||||
describe("concurrency", () => {
|
||||
@@ -900,6 +1042,20 @@ describe("AgentStore", () => {
|
||||
expect(new Date(event.timestamp).getTime()).not.toBeNaN();
|
||||
}
|
||||
});
|
||||
|
||||
it("concurrent createApiKey calls don't corrupt the JSONL file", async () => {
|
||||
const agent = await store.createAgent({ name: "ConcKeys", role: "executor" });
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 10 }, () => store.createApiKey(agent.id))
|
||||
);
|
||||
|
||||
const keys = await store.listApiKeys(agent.id);
|
||||
expect(keys).toHaveLength(10);
|
||||
|
||||
const ids = new Set(results.map(({ key }) => key.id));
|
||||
expect(ids.size).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── filesystem persistence ────────────────────────────────────────
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
|
||||
@@ -1601,6 +1601,30 @@ export interface AgentUpdateInput {
|
||||
instructionsText?: string;
|
||||
}
|
||||
|
||||
/** An API key associated with an agent for bearer token authentication. */
|
||||
export interface AgentApiKey {
|
||||
/** Unique key identifier (e.g., "key-a1b2c3d4") */
|
||||
id: string;
|
||||
/** The agent this key belongs to */
|
||||
agentId: string;
|
||||
/** SHA-256 hash of the plaintext token (hex-encoded, 64 chars) */
|
||||
tokenHash: string;
|
||||
/** Optional human-readable label for the key */
|
||||
label?: string;
|
||||
/** ISO-8601 timestamp when the key was created */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp when the key was revoked, null if active */
|
||||
revokedAt?: string;
|
||||
}
|
||||
|
||||
/** Result returned when creating a new API key — includes the plaintext token exactly once. */
|
||||
export interface AgentApiKeyCreateResult {
|
||||
/** The persisted key metadata (不含 plaintext token) */
|
||||
key: AgentApiKey;
|
||||
/** The plaintext token — shown only at creation, never stored */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** Per-task session persistence for an agent */
|
||||
export interface AgentTaskSession {
|
||||
/** Agent ID */
|
||||
|
||||
Reference in New Issue
Block a user