anorion 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/anorion.js +1 -1
- package/package.json +1 -1
- package/src/agents/handoff.ts +197 -0
- package/src/agents/registry.ts +1 -0
- package/src/agents/runtime.ts +10 -0
- package/src/auth/api-keys.ts +60 -0
- package/src/auth/jwt.ts +93 -0
- package/src/auth/middleware.ts +155 -0
- package/src/auth/routes.ts +138 -0
- package/src/auth/types.ts +68 -0
- package/src/cli/index.ts +1 -1
- package/src/config/versioning.ts +92 -0
- package/src/gateway/openai-compat.ts +125 -0
- package/src/gateway/routes-messages.ts +191 -0
- package/src/gateway/routes-search.ts +29 -0
- package/src/gateway/routes-upload.ts +100 -0
- package/src/gateway/server.ts +160 -78
- package/src/gateway/ws.ts +154 -23
- package/src/observability/metrics.ts +136 -0
- package/src/observability/routes.ts +76 -0
- package/src/observability/tracer.ts +86 -0
- package/src/providers/adapters/ai-sdk.ts +238 -0
- package/src/providers/index.ts +14 -0
- package/src/providers/registry.ts +88 -0
- package/src/providers/types.ts +96 -0
- package/src/search/engine.ts +99 -0
- package/src/shared/db/index.ts +34 -0
- package/src/shared/db/prepared.ts +77 -0
- package/src/shared/types.ts +9 -0
- package/src/tools/tool-builder.ts +180 -0
- package/src/workflow/checkpointer.ts +123 -0
- package/src/workflow/executor.ts +248 -0
- package/src/workflow/graph.ts +77 -0
- package/src/workflow/index.ts +18 -0
- package/src/workflow/types.ts +76 -0
package/bin/anorion.js
CHANGED
|
@@ -7108,7 +7108,7 @@ function getVersion() {
|
|
|
7108
7108
|
try {
|
|
7109
7109
|
const pkgPath = resolve(dirname(import.meta.url.replace("file://", "")), "../../package.json");
|
|
7110
7110
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
7111
|
-
return pkg.version || "0.
|
|
7111
|
+
return pkg.version || "0.2.0";
|
|
7112
7112
|
} catch {
|
|
7113
7113
|
return "0.1.0";
|
|
7114
7114
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Agent Handoff Protocol
|
|
2
|
+
// Agents declare handoffs in their config. The runtime auto-creates handoff_to_<agent> tools.
|
|
3
|
+
// When the LLM calls a handoff tool, conversation transfers to the target agent.
|
|
4
|
+
|
|
5
|
+
import type { Message, ToolDefinition, ToolContext, ToolResult } from '../shared/types';
|
|
6
|
+
import { toolRegistry } from '../tools/registry';
|
|
7
|
+
import { agentRegistry } from './registry';
|
|
8
|
+
import { sendMessage } from './runtime';
|
|
9
|
+
import { sessionManager } from './session';
|
|
10
|
+
import { logger } from '../shared/logger';
|
|
11
|
+
import { eventBus } from '../shared/events';
|
|
12
|
+
|
|
13
|
+
export interface AgentHandoff {
|
|
14
|
+
/** Target agent ID or name */
|
|
15
|
+
targetAgentId: string;
|
|
16
|
+
/** Description of when to hand off (LLM uses this to decide) */
|
|
17
|
+
description: string;
|
|
18
|
+
/** Filter which messages carry over to the target agent */
|
|
19
|
+
filterMessages?: (messages: Message[]) => Message[];
|
|
20
|
+
/** Callback when handoff occurs */
|
|
21
|
+
onHandoff?: (from: string, to: string, context: HandoffContext) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HandoffContext {
|
|
25
|
+
fromAgentId: string;
|
|
26
|
+
toAgentId: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
messages: Message[];
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Track registered handoff tools so we can clean up
|
|
33
|
+
const registeredHandoffTools = new Map<string, string>(); // toolName -> sourceAgentId
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register handoff tools for an agent based on its declared handoffs.
|
|
37
|
+
*/
|
|
38
|
+
export function registerHandoffTools(
|
|
39
|
+
agentId: string,
|
|
40
|
+
handoffs: AgentHandoff[],
|
|
41
|
+
): void {
|
|
42
|
+
for (const handoff of handoffs) {
|
|
43
|
+
const toolName = `handoff_to_${handoff.targetAgentId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
|
44
|
+
|
|
45
|
+
// Skip if already registered (e.g., multiple agents hand off to same target)
|
|
46
|
+
if (toolRegistry.get(toolName)) continue;
|
|
47
|
+
|
|
48
|
+
const toolDef: ToolDefinition = {
|
|
49
|
+
name: toolName,
|
|
50
|
+
description: `Transfer the conversation to the ${handoff.targetAgentId} agent. Use this when: ${handoff.description}`,
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
reason: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Brief reason for the handoff',
|
|
57
|
+
},
|
|
58
|
+
message: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'Optional message to pass to the target agent',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: [],
|
|
64
|
+
},
|
|
65
|
+
execute: async (
|
|
66
|
+
params: Record<string, unknown>,
|
|
67
|
+
ctx: ToolContext,
|
|
68
|
+
): Promise<ToolResult> => {
|
|
69
|
+
return executeHandoff(handoff, params, ctx);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
toolRegistry.register(toolDef);
|
|
74
|
+
registeredHandoffTools.set(toolName, agentId);
|
|
75
|
+
|
|
76
|
+
logger.info({ agentId, targetAgent: handoff.targetAgentId, toolName }, 'Handoff tool registered');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Bind handoff tool names to the agent
|
|
80
|
+
const existingTools = toolRegistry.getToolNamesForAgent(agentId);
|
|
81
|
+
const handoffToolNames = handoffs.map((h) =>
|
|
82
|
+
`handoff_to_${h.targetAgentId.replace(/[^a-zA-Z0-9_-]/g, '_')}`,
|
|
83
|
+
);
|
|
84
|
+
toolRegistry.bindTools(agentId, [...existingTools, ...handoffToolNames]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Execute a handoff: transfer conversation to target agent.
|
|
89
|
+
*/
|
|
90
|
+
async function executeHandoff(
|
|
91
|
+
handoff: AgentHandoff,
|
|
92
|
+
params: Record<string, unknown>,
|
|
93
|
+
ctx: ToolContext,
|
|
94
|
+
): Promise<ToolResult> {
|
|
95
|
+
const targetAgent = agentRegistry.get(handoff.targetAgentId) ||
|
|
96
|
+
agentRegistry.getByName(handoff.targetAgentId);
|
|
97
|
+
|
|
98
|
+
if (!targetAgent) {
|
|
99
|
+
return {
|
|
100
|
+
content: '',
|
|
101
|
+
error: `Handoff target agent not found: ${handoff.targetAgentId}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const reason = String(params.reason || '');
|
|
106
|
+
const message = String(params.message || '');
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Get conversation history
|
|
110
|
+
const messages = await sessionManager.getMessages(ctx.sessionId, 50);
|
|
111
|
+
|
|
112
|
+
// Apply message filter if provided
|
|
113
|
+
const filteredMessages = handoff.filterMessages
|
|
114
|
+
? handoff.filterMessages(messages)
|
|
115
|
+
: messages;
|
|
116
|
+
|
|
117
|
+
// Build context for target agent
|
|
118
|
+
const contextSummary = filteredMessages.length > 0
|
|
119
|
+
? `[Previous conversation context]\n${filteredMessages
|
|
120
|
+
.map((m) => `${m.role}: ${m.content.slice(0, 200)}`)
|
|
121
|
+
.join('\n')}\n\n`
|
|
122
|
+
: '';
|
|
123
|
+
|
|
124
|
+
const handoffPrompt = reason
|
|
125
|
+
? `${contextSummary}[Handoff from ${ctx.agentId}] Reason: ${reason}${message ? '\nMessage: ' + message : ''}`
|
|
126
|
+
: `${contextSummary}[Handoff from ${ctx.agentId}]${message ? '\n' + message : ''}`;
|
|
127
|
+
|
|
128
|
+
// Fire handoff event
|
|
129
|
+
const handoffCtx: HandoffContext = {
|
|
130
|
+
fromAgentId: ctx.agentId,
|
|
131
|
+
toAgentId: targetAgent.id,
|
|
132
|
+
sessionId: ctx.sessionId,
|
|
133
|
+
messages: filteredMessages,
|
|
134
|
+
reason,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
eventBus.emit('agent:handoff', {
|
|
138
|
+
from: ctx.agentId,
|
|
139
|
+
to: targetAgent.id,
|
|
140
|
+
sessionId: ctx.sessionId,
|
|
141
|
+
reason,
|
|
142
|
+
timestamp: Date.now(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Call onHandoff callback
|
|
146
|
+
if (handoff.onHandoff) {
|
|
147
|
+
await handoff.onHandoff(ctx.agentId, targetAgent.id, handoffCtx);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Send message to target agent in same session
|
|
151
|
+
const result = await sendMessage({
|
|
152
|
+
agentId: targetAgent.id,
|
|
153
|
+
sessionId: ctx.sessionId,
|
|
154
|
+
text: handoffPrompt,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
content: result.content,
|
|
159
|
+
metadata: {
|
|
160
|
+
handoff: true,
|
|
161
|
+
fromAgent: ctx.agentId,
|
|
162
|
+
toAgent: targetAgent.id,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
logger.error(
|
|
167
|
+
{ from: ctx.agentId, to: targetAgent.id, error: (err as Error).message },
|
|
168
|
+
'Handoff failed',
|
|
169
|
+
);
|
|
170
|
+
return {
|
|
171
|
+
content: '',
|
|
172
|
+
error: `Handoff failed: ${(err as Error).message}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get handoff tool names for a given set of handoffs.
|
|
179
|
+
*/
|
|
180
|
+
export function getHandoffToolNames(handoffs: AgentHandoff[]): string[] {
|
|
181
|
+
return handoffs.map((h) =>
|
|
182
|
+
`handoff_to_${h.targetAgentId.replace(/[^a-zA-Z0-9_-]/g, '_')}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Unregister handoff tools for an agent.
|
|
188
|
+
*/
|
|
189
|
+
export function unregisterHandoffTools(agentId: string): void {
|
|
190
|
+
for (const [toolName, sourceId] of registeredHandoffTools) {
|
|
191
|
+
if (sourceId === agentId) {
|
|
192
|
+
registeredHandoffTools.delete(toolName);
|
|
193
|
+
// Note: we don't unregister from toolRegistry as it doesn't support it
|
|
194
|
+
// This is fine for now since tool names are deterministic
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/agents/registry.ts
CHANGED
package/src/agents/runtime.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { memoryManager } from '../memory/store';
|
|
|
23
23
|
import { eventBus } from '../shared/events';
|
|
24
24
|
import { resolveModel } from '../llm/providers';
|
|
25
25
|
import { jsonSchema } from '@ai-sdk/provider-utils';
|
|
26
|
+
import { registerHandoffTools, getHandoffToolNames } from './handoff';
|
|
27
|
+
import type { AgentHandoffConfig } from '../shared/types';
|
|
26
28
|
|
|
27
29
|
// ── Interfaces ──
|
|
28
30
|
|
|
@@ -179,6 +181,14 @@ export async function sendMessage(input: SendMessageInput): Promise<SendMessageR
|
|
|
179
181
|
|
|
180
182
|
agentRegistry.setState(agentId, 'processing');
|
|
181
183
|
|
|
184
|
+
// Register handoff tools if agent has handoffs configured
|
|
185
|
+
if (agent.handoffs && agent.handoffs.length > 0) {
|
|
186
|
+
registerHandoffTools(agentId, agent.handoffs.map((h: AgentHandoffConfig) => ({
|
|
187
|
+
targetAgentId: h.targetAgentId,
|
|
188
|
+
description: h.description,
|
|
189
|
+
})));
|
|
190
|
+
}
|
|
191
|
+
|
|
182
192
|
const abortController = new AbortController();
|
|
183
193
|
const signal = input.abortSignal ?? abortController.signal;
|
|
184
194
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// API Key CRUD — in-memory store with optional SQLite persistence
|
|
2
|
+
|
|
3
|
+
import type { ApiKey, ApiKeyScope } from './types';
|
|
4
|
+
|
|
5
|
+
function hashKey(rawKey: string): string {
|
|
6
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
7
|
+
hasher.update(rawKey);
|
|
8
|
+
return hasher.digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function generateRawKey(): string {
|
|
12
|
+
return `anr_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// In-memory key store
|
|
16
|
+
const memoryKeys = new Map<string, ApiKey>();
|
|
17
|
+
|
|
18
|
+
export function createApiKeyMemory(userId: string, name: string, scopes: ApiKeyScope[]): { key: string; apiKey: ApiKey } {
|
|
19
|
+
const rawKey = generateRawKey();
|
|
20
|
+
const keyHash = hashKey(rawKey);
|
|
21
|
+
const id = crypto.randomUUID();
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
|
|
24
|
+
const apiKey: ApiKey = {
|
|
25
|
+
id,
|
|
26
|
+
userId,
|
|
27
|
+
name,
|
|
28
|
+
keyHash,
|
|
29
|
+
keyPrefix: rawKey.slice(0, 8),
|
|
30
|
+
scopes,
|
|
31
|
+
enabled: true,
|
|
32
|
+
lastUsed: 0,
|
|
33
|
+
createdAt: now,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
memoryKeys.set(keyHash, apiKey);
|
|
37
|
+
return { key: rawKey, apiKey };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function verifyApiKeyMemory(rawKey: string): ApiKey | null {
|
|
41
|
+
const keyHash = hashKey(rawKey);
|
|
42
|
+
const entry = memoryKeys.get(keyHash);
|
|
43
|
+
if (!entry || !entry.enabled) return null;
|
|
44
|
+
entry.lastUsed = Date.now();
|
|
45
|
+
return entry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function listApiKeysMemory(): ApiKey[] {
|
|
49
|
+
return [...memoryKeys.values()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function revokeApiKeyMemory(id: string): boolean {
|
|
53
|
+
for (const [hash, key] of memoryKeys) {
|
|
54
|
+
if (key.id === id) {
|
|
55
|
+
memoryKeys.delete(hash);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
package/src/auth/jwt.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// JWT sign/verify using Web Crypto API — no external dependencies
|
|
2
|
+
|
|
3
|
+
import type { JwtPayload, UserRole, ApiKeyScope } from './types';
|
|
4
|
+
|
|
5
|
+
const JWT_ALG = 'HS256';
|
|
6
|
+
const JWT_ISS = 'anorion';
|
|
7
|
+
const DEFAULT_EXPIRY_S = 3600; // 1 hour
|
|
8
|
+
|
|
9
|
+
function b64url(data: ArrayBuffer | Uint8Array): string {
|
|
10
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
11
|
+
let bin = '';
|
|
12
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
13
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function b64urlDecode(str: string): Uint8Array {
|
|
17
|
+
let s = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
18
|
+
while (s.length % 4) s += '=';
|
|
19
|
+
const bin = atob(s);
|
|
20
|
+
const bytes = new Uint8Array(bin.length);
|
|
21
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
22
|
+
return bytes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textToBytes(text: string): Uint8Array {
|
|
26
|
+
return new TextEncoder().encode(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getKey(secret: string): Promise<CryptoKey> {
|
|
30
|
+
return crypto.subtle.importKey(
|
|
31
|
+
'raw',
|
|
32
|
+
textToBytes(secret),
|
|
33
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
34
|
+
false,
|
|
35
|
+
['sign', 'verify'],
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let _jwtSecret: string | null = null;
|
|
40
|
+
|
|
41
|
+
export function setJwtSecret(secret: string): void {
|
|
42
|
+
_jwtSecret = secret;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getJwtSecret(): string {
|
|
46
|
+
if (!_jwtSecret) {
|
|
47
|
+
_jwtSecret = process.env.JWT_SECRET || 'anorion-dev-secret-change-me';
|
|
48
|
+
}
|
|
49
|
+
return _jwtSecret;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function signJwt(payload: Omit<JwtPayload, 'iat' | 'exp' | 'iss'>, expiresInSeconds = DEFAULT_EXPIRY_S): Promise<string> {
|
|
53
|
+
const header = b64url(textToBytes(JSON.stringify({ alg: JWT_ALG, typ: 'JWT' })));
|
|
54
|
+
|
|
55
|
+
const now = Math.floor(Date.now() / 1000);
|
|
56
|
+
const fullPayload: JwtPayload = {
|
|
57
|
+
...payload,
|
|
58
|
+
iat: now,
|
|
59
|
+
exp: now + expiresInSeconds,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const payloadB64 = b64url(textToBytes(JSON.stringify({ ...fullPayload, iss: JWT_ISS })));
|
|
63
|
+
const signingInput = `${header}.${payloadB64}`;
|
|
64
|
+
|
|
65
|
+
const key = await getKey(getJwtSecret());
|
|
66
|
+
const sig = await crypto.subtle.sign('HMAC', key, textToBytes(signingInput));
|
|
67
|
+
|
|
68
|
+
return `${signingInput}.${b64url(sig)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
|
|
72
|
+
const parts = token.split('.');
|
|
73
|
+
if (parts.length !== 3) return null;
|
|
74
|
+
|
|
75
|
+
const headerB64 = parts[0]!;
|
|
76
|
+
const payloadB64 = parts[1]!;
|
|
77
|
+
const sigB64 = parts[2]!;
|
|
78
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const key = await getKey(getJwtSecret());
|
|
82
|
+
const valid = await crypto.subtle.verify('HMAC', key, b64urlDecode(sigB64), textToBytes(signingInput));
|
|
83
|
+
if (!valid) return null;
|
|
84
|
+
|
|
85
|
+
const payload = JSON.parse(new TextDecoder().decode(b64urlDecode(payloadB64))) as JwtPayload & { iss?: string };
|
|
86
|
+
if (payload.iss !== JWT_ISS) return null;
|
|
87
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
88
|
+
|
|
89
|
+
return payload;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Auth middleware for Hono — JWT bearer, API key, scope validation, rate limiting
|
|
2
|
+
|
|
3
|
+
import type { Context, Next } from 'hono';
|
|
4
|
+
import type { ApiKeyScope, AuthContext } from './types';
|
|
5
|
+
import { verifyJwt } from './jwt';
|
|
6
|
+
import { verifyApiKeyMemory, listApiKeysMemory } from './api-keys';
|
|
7
|
+
|
|
8
|
+
// Extend Hono context variables
|
|
9
|
+
declare module 'hono' {
|
|
10
|
+
interface ContextVariableMap {
|
|
11
|
+
auth: AuthContext;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Public paths that skip auth ──
|
|
16
|
+
const PUBLIC_PATHS = new Set([
|
|
17
|
+
'/api/v1/health',
|
|
18
|
+
'/health',
|
|
19
|
+
'/metrics',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// ── Extract auth context from request ──
|
|
23
|
+
export async function authMiddleware(c: Context, next: Next): Promise<Response | void> {
|
|
24
|
+
// Skip auth for public paths
|
|
25
|
+
if (PUBLIC_PATHS.has(c.req.path)) return next();
|
|
26
|
+
|
|
27
|
+
// Dev mode: if no keys configured, allow all
|
|
28
|
+
const allKeys = listApiKeysMemory();
|
|
29
|
+
const hasRealKeys = allKeys.length > 0;
|
|
30
|
+
if (!hasRealKeys) {
|
|
31
|
+
c.set('auth', {
|
|
32
|
+
userId: 'dev',
|
|
33
|
+
username: 'dev',
|
|
34
|
+
role: 'admin',
|
|
35
|
+
scopes: ['read', 'write', 'admin'],
|
|
36
|
+
});
|
|
37
|
+
return next();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Try API key auth first (X-API-Key header or Authorization: Bearer sk-.../anr_...)
|
|
41
|
+
const apiKeyHeader = c.req.header('X-API-Key');
|
|
42
|
+
const authHeader = c.req.header('Authorization') || '';
|
|
43
|
+
|
|
44
|
+
let authCtx: AuthContext | null = null;
|
|
45
|
+
|
|
46
|
+
if (apiKeyHeader) {
|
|
47
|
+
const key = verifyApiKeyMemory(apiKeyHeader);
|
|
48
|
+
if (key) {
|
|
49
|
+
authCtx = {
|
|
50
|
+
userId: key.userId,
|
|
51
|
+
username: key.name,
|
|
52
|
+
role: 'agent',
|
|
53
|
+
scopes: key.scopes,
|
|
54
|
+
apiKeyId: key.id,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
} else if (authHeader.startsWith('Bearer ')) {
|
|
58
|
+
const token = authHeader.slice(7);
|
|
59
|
+
|
|
60
|
+
// Check if it's an API key (starts with anr_)
|
|
61
|
+
if (token.startsWith('anr_') || token.startsWith('sk-')) {
|
|
62
|
+
const key = verifyApiKeyMemory(token);
|
|
63
|
+
if (key) {
|
|
64
|
+
authCtx = {
|
|
65
|
+
userId: key.userId,
|
|
66
|
+
username: key.name,
|
|
67
|
+
role: 'agent',
|
|
68
|
+
scopes: key.scopes,
|
|
69
|
+
apiKeyId: key.id,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// Try JWT
|
|
74
|
+
const payload = await verifyJwt(token);
|
|
75
|
+
if (payload) {
|
|
76
|
+
authCtx = {
|
|
77
|
+
userId: payload.sub,
|
|
78
|
+
username: payload.username,
|
|
79
|
+
role: payload.role,
|
|
80
|
+
scopes: payload.scopes,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!authCtx) {
|
|
87
|
+
return c.json({ error: 'Unauthorized', message: 'Valid API key or JWT token required' }, 401);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
c.set('auth', authCtx);
|
|
91
|
+
return next();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Scope validation middleware ──
|
|
95
|
+
export function requireScope(...requiredScopes: ApiKeyScope[]) {
|
|
96
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
97
|
+
const auth = c.get('auth');
|
|
98
|
+
if (!auth) return c.json({ error: 'Unauthorized' }, 401);
|
|
99
|
+
|
|
100
|
+
// Admin scope grants all
|
|
101
|
+
if (auth.scopes.includes('admin')) return next();
|
|
102
|
+
|
|
103
|
+
const hasScope = requiredScopes.some((s) => auth.scopes.includes(s));
|
|
104
|
+
if (!hasScope) {
|
|
105
|
+
return c.json({ error: 'Forbidden', message: `Requires one of: ${requiredScopes.join(', ')}` }, 403);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return next();
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Rate limiting per key (in-memory sliding window) ──
|
|
113
|
+
interface RateWindow {
|
|
114
|
+
timestamps: number[];
|
|
115
|
+
tokenCount: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rateWindows = new Map<string, RateWindow>();
|
|
119
|
+
|
|
120
|
+
export function rateLimitPerKey(rpm: number = 60, tpm: number = 100000) {
|
|
121
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
122
|
+
const auth = c.get('auth');
|
|
123
|
+
const keyId = auth?.apiKeyId || c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
|
|
124
|
+
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const windowMs = 60_000; // 1 minute sliding window
|
|
127
|
+
|
|
128
|
+
let bucket = rateWindows.get(keyId);
|
|
129
|
+
if (!bucket) {
|
|
130
|
+
bucket = { timestamps: [], tokenCount: 0 };
|
|
131
|
+
rateWindows.set(keyId, bucket);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Slide window
|
|
135
|
+
bucket.timestamps = bucket.timestamps.filter((t) => now - t < windowMs);
|
|
136
|
+
|
|
137
|
+
if (bucket.timestamps.length >= rpm) {
|
|
138
|
+
const oldest = bucket.timestamps[0]!;
|
|
139
|
+
const retryAfter = Math.ceil((oldest + windowMs - now) / 1000);
|
|
140
|
+
return c.json({ error: 'Rate limit exceeded', retryAfter }, 429, { 'Retry-After': String(retryAfter) });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
bucket.timestamps.push(now);
|
|
144
|
+
return next();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Periodic cleanup of old buckets (every 5 min)
|
|
149
|
+
setInterval(() => {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const [key, bucket] of rateWindows) {
|
|
152
|
+
bucket.timestamps = bucket.timestamps.filter((t) => now - t < 120_000);
|
|
153
|
+
if (bucket.timestamps.length === 0) rateWindows.delete(key);
|
|
154
|
+
}
|
|
155
|
+
}, 300_000);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Auth routes — login, register, key management, health
|
|
2
|
+
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { signJwt } from './jwt';
|
|
5
|
+
import { createApiKeyMemory, listApiKeysMemory, revokeApiKeyMemory, verifyApiKeyMemory } from './api-keys';
|
|
6
|
+
import { authMiddleware, requireScope, rateLimitPerKey } from './middleware';
|
|
7
|
+
import type { AuthContext, CreateKeyRequest, LoginRequest, RegisterRequest } from './types';
|
|
8
|
+
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
|
|
11
|
+
// ── Public health check ──
|
|
12
|
+
app.get('/api/v1/health', (c) => {
|
|
13
|
+
return c.json({
|
|
14
|
+
status: 'ok',
|
|
15
|
+
uptime: process.uptime(),
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
version: '0.1.0',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ── Detailed health (requires auth) ──
|
|
22
|
+
app.get('/api/v1/health/detailed', authMiddleware, (c) => {
|
|
23
|
+
const mem = process.memoryUsage();
|
|
24
|
+
return c.json({
|
|
25
|
+
status: 'ok',
|
|
26
|
+
uptime: process.uptime(),
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
memory: {
|
|
29
|
+
rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MB`,
|
|
30
|
+
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)} MB`,
|
|
31
|
+
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MB`,
|
|
32
|
+
},
|
|
33
|
+
nodeVersion: process.version,
|
|
34
|
+
pid: process.pid,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── Login: exchange API key for JWT ──
|
|
39
|
+
app.post('/api/v1/auth/login', rateLimitPerKey(10), async (c) => {
|
|
40
|
+
const body = await c.req.json() as LoginRequest;
|
|
41
|
+
if (!body.apiKey) return c.json({ error: 'apiKey is required' }, 400);
|
|
42
|
+
|
|
43
|
+
const key = verifyApiKeyMemory(body.apiKey);
|
|
44
|
+
if (!key) return c.json({ error: 'Invalid API key' }, 401);
|
|
45
|
+
|
|
46
|
+
const token = await signJwt({
|
|
47
|
+
sub: key.userId || key.id,
|
|
48
|
+
username: key.name,
|
|
49
|
+
role: 'agent',
|
|
50
|
+
scopes: key.scopes,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return c.json({
|
|
54
|
+
token,
|
|
55
|
+
expiresIn: 3600,
|
|
56
|
+
scopes: key.scopes,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── Register: create a user (admin only) ──
|
|
61
|
+
app.post('/api/v1/auth/register', authMiddleware, requireScope('admin'), async (c) => {
|
|
62
|
+
const body = await c.req.json() as RegisterRequest;
|
|
63
|
+
if (!body.username) return c.json({ error: 'username is required' }, 400);
|
|
64
|
+
|
|
65
|
+
// In this simplified version, registration creates an API key for the user
|
|
66
|
+
const role = body.role || 'viewer';
|
|
67
|
+
const scopes = role === 'admin' ? ['read', 'write', 'admin'] as const
|
|
68
|
+
: role === 'operator' ? ['read', 'write'] as const
|
|
69
|
+
: ['read'] as const;
|
|
70
|
+
|
|
71
|
+
const result = createApiKeyMemory('', body.username, [...scopes]);
|
|
72
|
+
|
|
73
|
+
return c.json({
|
|
74
|
+
user: {
|
|
75
|
+
username: body.username,
|
|
76
|
+
role,
|
|
77
|
+
},
|
|
78
|
+
apiKey: result.key,
|
|
79
|
+
apiKeyId: result.apiKey.id,
|
|
80
|
+
}, 201);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── Get current auth info ──
|
|
84
|
+
app.get('/api/v1/auth/me', authMiddleware, (c) => {
|
|
85
|
+
const auth = c.get('auth') as AuthContext;
|
|
86
|
+
return c.json({
|
|
87
|
+
userId: auth.userId,
|
|
88
|
+
username: auth.username,
|
|
89
|
+
role: auth.role,
|
|
90
|
+
scopes: auth.scopes,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── API Key management ──
|
|
95
|
+
|
|
96
|
+
// Create API key
|
|
97
|
+
app.post('/api/v1/keys', authMiddleware, requireScope('admin'), async (c) => {
|
|
98
|
+
const body = await c.req.json() as CreateKeyRequest;
|
|
99
|
+
if (!body.name) return c.json({ error: 'name is required' }, 400);
|
|
100
|
+
if (!body.scopes || body.scopes.length === 0) return c.json({ error: 'scopes are required' }, 400);
|
|
101
|
+
|
|
102
|
+
const auth = c.get('auth') as AuthContext;
|
|
103
|
+
const result = createApiKeyMemory(auth.userId, body.name, body.scopes);
|
|
104
|
+
|
|
105
|
+
return c.json({
|
|
106
|
+
key: result.key,
|
|
107
|
+
apiKey: {
|
|
108
|
+
id: result.apiKey.id,
|
|
109
|
+
name: result.apiKey.name,
|
|
110
|
+
scopes: result.apiKey.scopes,
|
|
111
|
+
createdAt: result.apiKey.createdAt,
|
|
112
|
+
},
|
|
113
|
+
}, 201);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// List API keys
|
|
117
|
+
app.get('/api/v1/keys', authMiddleware, requireScope('read'), (c) => {
|
|
118
|
+
const keys = listApiKeysMemory().map((k) => ({
|
|
119
|
+
id: k.id,
|
|
120
|
+
name: k.name,
|
|
121
|
+
keyPrefix: k.keyPrefix,
|
|
122
|
+
scopes: k.scopes,
|
|
123
|
+
enabled: k.enabled,
|
|
124
|
+
lastUsed: k.lastUsed,
|
|
125
|
+
createdAt: k.createdAt,
|
|
126
|
+
}));
|
|
127
|
+
return c.json({ keys });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Revoke API key
|
|
131
|
+
app.delete('/api/v1/keys/:id', authMiddleware, requireScope('admin'), (c) => {
|
|
132
|
+
const id = c.req.param('id')!;
|
|
133
|
+
const ok = revokeApiKeyMemory(id);
|
|
134
|
+
if (!ok) return c.json({ error: 'Key not found' }, 404);
|
|
135
|
+
return c.json({ ok: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export default app;
|