From bc12a4bb1865993eb96a1e7af4c85ddabd9f76c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Mon, 13 Apr 2026 09:51:00 +0000 Subject: [PATCH] feat(engine): API Key read access for events, objects, projections (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth middleware now accepts API Key for GET on data endpoints (/events, /objects, /projections, /event-defs, /projection-defs, /object-defs) - Admin write ops (POST defs, reactions, api-keys) still require API_TOKEN - Valid API Key + admin op → 403 Forbidden - Invalid token → 401 Unauthorized - Add 5 tests for API Key read access (#37) closes #36 --- packages/engine/src/index.test.ts | 64 +++++++++++++++++++++++++++++++ packages/engine/src/index.ts | 40 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index 0ea684a..1ce4885 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -2250,6 +2250,70 @@ describe('API Key Management', () => { }) }) +// ============================================ +// API Key Read Access (#36) +// ============================================ + +describe('API Key Read Access', () => { + let apiKey: string + let agentId: number + + beforeEach(async () => { + // Need object-def 'agent' first + await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN }) + const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN }) + agentId = (await agentRes.json()).id + + const keyRes = await app.fetch( + req('POST', '/api-keys', { name: 'read-test', allowed_events: ['task_created'] }), + { DB: db, API_TOKEN }, + ) + apiKey = (await keyRes.json()).key + }) + + it('GET /events with API Key → 200', async () => { + const res = await app.fetch( + req('GET', '/events?ref=' + agentId, undefined, apiKey), + { DB: db, API_TOKEN }, + ) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.events).toBeDefined() + }) + + it('GET /objects/:id with API Key → 200', async () => { + const res = await app.fetch( + req('GET', `/objects/${agentId}`, undefined, apiKey), + { DB: db, API_TOKEN }, + ) + expect(res.status).toBe(200) + }) + + it('GET /event-defs with API Key → 200', async () => { + const res = await app.fetch( + req('GET', '/event-defs', undefined, apiKey), + { DB: db, API_TOKEN }, + ) + expect(res.status).toBe(200) + }) + + it('POST /event-defs with API Key → 403', async () => { + const res = await app.fetch( + req('POST', '/event-defs', { name: 'blocked_type', schema: { properties: { x: { type: 'string' } } } }, apiKey), + { DB: db, API_TOKEN }, + ) + expect(res.status).toBe(403) + }) + + it('GET /api-keys with API Key → 403', async () => { + const res = await app.fetch( + req('GET', '/api-keys', undefined, apiKey), + { DB: db, API_TOKEN }, + ) + expect(res.status).toBe(403) + }) +}) + // ============================================ // Request Logs (#221) // ============================================ diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fca7371..b173c58 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -145,11 +145,47 @@ app.get('/schema', async (c) => { return c.json(schema) }) -// Auth middleware for all routes except health, schema, ui, and POST /events (which has its own dual auth) +// Auth middleware: API_TOKEN for admin ops, API Key allowed for reads + POST /events +// Read-only paths that API Keys can access +const API_KEY_READABLE_PREFIXES = ['/events', '/objects', '/projections', '/event-defs', '/projection-defs', '/object-defs'] + app.use('*', async (c, next) => { if (c.req.path === '/health' || c.req.path === '/schema' || c.req.path === '/favicon.ico' || c.req.path.startsWith('/ui')) return next() if (c.req.method === 'POST' && c.req.path === '/events') return next() - return bearerAuth(c.env.API_TOKEN)(c, next) + + const authHeader = c.req.header('Authorization') + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!bearerToken) { + return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Missing or invalid Authorization header') + } + + // Admin token: full access + if (bearerToken === c.env.API_TOKEN) return next() + + // API Key: read-only access to data endpoints + if (c.req.method === 'GET') { + const isReadable = API_KEY_READABLE_PREFIXES.some(p => c.req.path.startsWith(p)) + if (isReadable) { + const result = await validateApiKey(c.env.DB, bearerToken) + if (result.valid) { + if (result.apiKey) { + c.set('apiKeyId', result.apiKey.id) + c.set('apiKeyName', result.apiKey.name) + } + return next() + } + } + } + + // Check if it's a valid API Key trying admin ops → 403 + const keyCheck = await validateApiKey(c.env.DB, bearerToken) + if (keyCheck.valid) { + return apiError(c, 403, ErrorCode.FORBIDDEN, 'API key cannot perform admin operations') + } + + // Invalid token entirely → 401 + return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Invalid or unauthorized token') }) // ============================================