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 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.1.0";
7111
+ return pkg.version || "0.2.0";
7112
7112
  } catch {
7113
7113
  return "0.1.0";
7114
7114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anorion",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The open-source agent gateway — connect any LLM to any channel",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
+ }
@@ -41,6 +41,7 @@ class AgentRegistry {
41
41
  timeoutMs: parsed.timeoutMs || 120000,
42
42
  tags: parsed.tags,
43
43
  metadata: parsed.metadata,
44
+ handoffs: parsed.handoffs,
44
45
  });
45
46
  } catch (err) {
46
47
  logger.error({ file, error: (err as Error).message }, 'Failed to load agent YAML');
@@ -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
+ }
@@ -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;