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 */
|
||||
|
||||
165
packages/dashboard/src/__tests__/routes-agent-keys.test.ts
Normal file
165
packages/dashboard/src/__tests__/routes-agent-keys.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetAgent = vi.fn();
|
||||
const mockCreateApiKey = vi.fn();
|
||||
const mockListApiKeys = vi.fn();
|
||||
const mockRevokeApiKey = vi.fn();
|
||||
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockInit;
|
||||
getAgent = mockGetAgent;
|
||||
createApiKey = mockCreateApiKey;
|
||||
listApiKeys = mockListApiKeys;
|
||||
revokeApiKey = mockRevokeApiKey;
|
||||
listAgents = mockListAgents;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1119-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1119-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Agent API key routes", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/keys", () => {
|
||||
it("creates a new key and returns { key, token }", async () => {
|
||||
mockGetAgent.mockResolvedValue({ id: "agent-001", name: "Agent", role: "executor", state: "idle" });
|
||||
mockCreateApiKey.mockResolvedValue({
|
||||
key: {
|
||||
id: "key-a1b2c3d4",
|
||||
agentId: "agent-001",
|
||||
tokenHash: "a".repeat(64),
|
||||
label: "CI",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
token: "b".repeat(64),
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/keys",
|
||||
JSON.stringify({ label: "CI" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect((response.body as any).key.id).toBe("key-a1b2c3d4");
|
||||
expect((response.body as any).token).toHaveLength(64);
|
||||
expect(mockGetAgent).toHaveBeenCalledWith("agent-001");
|
||||
expect(mockCreateApiKey).toHaveBeenCalledWith("agent-001", { label: "CI" });
|
||||
});
|
||||
|
||||
it("returns 404 when agent does not exist", async () => {
|
||||
mockGetAgent.mockResolvedValue(null);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-404/keys",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toBe("Agent not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/keys", () => {
|
||||
it("lists all keys for the agent", async () => {
|
||||
mockListApiKeys.mockResolvedValue([
|
||||
{
|
||||
id: "key-a1b2c3d4",
|
||||
agentId: "agent-001",
|
||||
tokenHash: "a".repeat(64),
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/keys");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect((response.body as any[])).toHaveLength(1);
|
||||
expect(mockListApiKeys).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
|
||||
it("returns 404 when agent is not found", async () => {
|
||||
mockListApiKeys.mockRejectedValue(new Error("Agent agent-404 not found"));
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-404/keys");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/agents/:id/keys/:keyId", () => {
|
||||
it("revokes an API key and returns the revoked key", async () => {
|
||||
mockRevokeApiKey.mockResolvedValue({
|
||||
id: "key-a1b2c3d4",
|
||||
agentId: "agent-001",
|
||||
tokenHash: "a".repeat(64),
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
revokedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const response = await request(app, "DELETE", "/api/agents/agent-001/keys/key-a1b2c3d4");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).id).toBe("key-a1b2c3d4");
|
||||
expect((response.body as any).revokedAt).toBeDefined();
|
||||
expect(mockRevokeApiKey).toHaveBeenCalledWith("agent-001", "key-a1b2c3d4");
|
||||
});
|
||||
|
||||
it("returns 404 when key is not found", async () => {
|
||||
mockRevokeApiKey.mockRejectedValue(new Error("API key key-missing not found for agent agent-001"));
|
||||
|
||||
const response = await request(app, "DELETE", "/api/agents/agent-001/keys/key-missing");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toContain("not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7207,6 +7207,85 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/keys
|
||||
* Create a new API key for an agent.
|
||||
* Body: { label?: string }
|
||||
*/
|
||||
router.post("/agents/:id/keys", async (req, res) => {
|
||||
try {
|
||||
const { label } = req.body ?? {};
|
||||
if (label !== undefined && typeof label !== "string") {
|
||||
res.status(400).json({ error: "label must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.getAgent(req.params.id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentStore.createApiKey(req.params.id, { label });
|
||||
res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/keys
|
||||
* List all API keys for an agent.
|
||||
*/
|
||||
router.get("/agents/:id/keys", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const keys = await agentStore.listApiKeys(req.params.id);
|
||||
res.json(keys);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id/keys/:keyId
|
||||
* Revoke an API key for an agent.
|
||||
*/
|
||||
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const revoked = await agentStore.revokeApiKey(req.params.id, req.params.keyId);
|
||||
res.json(revoked);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/tasks
|
||||
* List tasks explicitly assigned to the given agent.
|
||||
|
||||
Reference in New Issue
Block a user