小墨 bc12a4bb18 feat(engine): API Key read access for events, objects, projections (#36)
- 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
2026-04-13 09:51:09 +00:00

591 lines
19 KiB
TypeScript

/**
* OGraph Gateway + Engine (unified Worker)
*
* Route classification:
* - PUBLIC: GET /health — no auth required
* - EXTERNAL: POST /events — API key (Bearer) or API_TOKEN
* - ADMIN: Everything else — API_TOKEN only
* - INTERNAL: Reaction handler execution — engine internal only
*/
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { bearerAuth } from './auth'
import UI_HTML from './ui.html'
import {
createObjectDef,
listObjectDefs,
getObjectDef,
createObject,
getObject,
listObjects,
createEventDef,
listEventDefs,
getEventDef,
createEvent,
getEvent,
findEventsByRef,
createProjectionDef,
listProjectionDefs,
getProjectionDef,
getProjection,
createReaction,
listReactions,
getReaction,
deleteReaction,
listReactionLogs,
createApiKey,
listApiKeys,
deleteApiKey,
validateApiKey,
getSchema,
ValidationError,
} from './engine'
import type {
CreateObjectDefRequest,
CreateObjectRequest,
CreateEventDefRequest,
CreateEventRequest,
CreateProjectionDefRequest,
CreateReactionRequest,
CreateApiKeyRequest,
ReactionPayload,
} from './types'
import { ErrorCode } from './types'
type Bindings = {
DB: D1Database
API_TOKEN: string
}
type Variables = {
apiKeyId: number | null
apiKeyName: string | null
}
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
function apiError(c: any, status: number, code: string, message: string) {
return c.json({ error: { code, message } }, status)
}
app.use('*', cors())
app.use('*', async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
const path = new URL(c.req.url).pathname
if (path === '/health' || path.startsWith('/ui')) return
try {
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const cutoff = Date.now() - SEVEN_DAYS_MS
c.executionCtx.waitUntil(
Promise.all([
c.env.DB.prepare(
'INSERT INTO request_logs (method, path, api_key_id, api_key_name, status_code, error, duration_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.bind(
c.req.method,
path,
c.get('apiKeyId') || null,
c.get('apiKeyName') || null,
c.res.status,
c.res.status >= 400
? await c.res
.clone()
.text()
.catch(() => null)
: null,
duration,
Date.now(),
)
.run(),
// Cleanup logs older than 7 days
c.env.DB.prepare('DELETE FROM request_logs WHERE created_at < ?').bind(cutoff).run(),
c.env.DB.prepare('DELETE FROM reaction_logs WHERE created_at < ?').bind(cutoff).run(),
]),
)
} catch {
// executionCtx not available in test, skip
}
})
// ============================================
// UI (no auth, served before auth middleware)
// ============================================
app.get('/favicon.ico', (c) => {
return new Response(null, { status: 204 })
})
app.get('/ui', (c) => {
return c.html(UI_HTML)
})
app.get('/ui/*', (c) => {
return c.html(UI_HTML)
})
// ============================================
// Health
// ============================================
app.get('/health', (c) => {
return c.json({ status: 'ok', version: '2.4.0' })
})
// ============================================
// Schema (self-describing, no auth)
// ============================================
app.get('/schema', async (c) => {
const schema = await getSchema(c.env.DB)
return c.json(schema)
})
// 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()
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')
})
// ============================================
// Object Defs
// ============================================
app.post('/object-defs', async (c) => {
try {
const body = await c.req.json<CreateObjectDefRequest>()
if (!body.name) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name')
const result = await createObjectDef(c.env.DB, body.name)
return c.json(result, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/object-defs/:name', async (c) => {
const name = c.req.param('name')
const def = await getObjectDef(c.env.DB, name)
if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Object def '${name}' not found`)
return c.json(def)
})
app.get('/object-defs', async (c) => {
const defs = await listObjectDefs(c.env.DB)
return c.json({ object_defs: defs, total: defs.length })
})
// ============================================
// Objects
// ============================================
app.post('/objects', async (c) => {
try {
const body = await c.req.json<CreateObjectRequest>()
if (!body.type) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing type')
const obj = await createObject(c.env.DB, body.type)
return c.json(obj, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/objects/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id')
const obj = await getObject(c.env.DB, id)
if (!obj) return apiError(c, 404, ErrorCode.NOT_FOUND, 'Not found')
return c.json(obj)
})
app.get('/objects', async (c) => {
const type = c.req.query('type')
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await listObjects(c.env.DB, type, limit, offset)
return c.json(result)
})
// ============================================
// Event Defs
// ============================================
app.post('/event-defs', async (c) => {
try {
const body = await c.req.json<CreateEventDefRequest>()
if (!body.name || !body.schema) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name or schema')
const result = await createEventDef(c.env.DB, body.name, body.schema)
return c.json(result, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/event-defs/:name', async (c) => {
const name = c.req.param('name')
const def = await getEventDef(c.env.DB, name)
if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Event def '${name}' not found`)
return c.json(def)
})
app.get('/event-defs', async (c) => {
const defs = await listEventDefs(c.env.DB)
return c.json({ event_defs: defs, total: defs.length })
})
// ============================================
// Events
// ============================================
app.post('/events', async (c) => {
// Dual auth: check if this request already passed API_TOKEN auth.
// If not (i.e., Bearer token is not the API_TOKEN), validate as API key.
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')
}
let body: CreateEventRequest
try {
body = await c.req.json<CreateEventRequest>()
} catch {
return apiError(c, 400, ErrorCode.INVALID_JSON, 'Invalid JSON body')
}
if (!body.type || !body.payload) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing type or payload')
// If the token is not API_TOKEN, treat it as an API key
if (bearerToken !== c.env.API_TOKEN) {
const result = await validateApiKey(c.env.DB, bearerToken, body.type)
if (!result.valid) {
if (result.error === 'event_not_allowed') {
return apiError(c, 403, ErrorCode.FORBIDDEN, 'Event type not allowed for this API key')
}
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Invalid API key')
}
if (result.apiKey) {
c.set('apiKeyId', result.apiKey.id)
c.set('apiKeyName', result.apiKey.name)
}
}
try {
const { event, reactions_fired, reaction_results } = await createEvent(c.env.DB, body.type, body.payload)
// Fire-and-forget webhook POSTs for reactions (if any)
if (reaction_results.length > 0) {
try {
c.executionCtx.waitUntil(fireReactionWebhooks(c.env.DB, reaction_results))
} catch {
// executionCtx not available in test environment, skip webhook firing
}
}
return c.json({ event, reactions_fired, reaction_results }, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') {
return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message)
}
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/events/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id')
const event = await getEvent(c.env.DB, id)
if (!event) return apiError(c, 404, ErrorCode.NOT_FOUND, 'Not found')
return c.json(event)
})
app.get('/events', async (c) => {
const refParam = c.req.query('ref')
const refId = refParam ? parseInt(refParam, 10) : undefined
const afterParam = c.req.query('after')
const afterId = afterParam ? parseInt(afterParam, 10) : undefined
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await findEventsByRef(c.env.DB, refId, limit, offset, afterId)
return c.json(result)
})
// ============================================
// Projection Defs
// ============================================
app.post('/projection-defs', async (c) => {
try {
const body = await c.req.json<CreateProjectionDefRequest>()
if (!body.name || !body.value_schema || body.initial_value === undefined) {
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name, value_schema, or initial_value')
}
if (!body.sources || !Array.isArray(body.sources) || body.sources.length === 0) {
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing or empty sources array')
}
const result = await createProjectionDef(
c.env.DB,
body.name,
body.sources,
body.params,
body.value_schema,
body.initial_value,
)
return c.json(result, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/projection-defs/:name', async (c) => {
const name = c.req.param('name')
const def = await getProjectionDef(c.env.DB, name)
if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Projection def '${name}' not found`)
return c.json(def)
})
app.get('/projection-defs', async (c) => {
const defs = await listProjectionDefs(c.env.DB)
return c.json({ projection_defs: defs, total: defs.length })
})
// ============================================
// Projections
// ============================================
app.get('/projections/:name', async (c) => {
try {
const name = c.req.param('name')
const rawParams = c.req.queries()
const params: Record<string, any> = {}
for (const [key, values] of Object.entries(rawParams)) {
params[key] = values[0] // take first value
}
const result = await getProjection(c.env.DB, name, params)
const response: Record<string, any> = { value: result.value }
if (result.status === 'errored') {
response._status = 'errored'
response._error = result.error
}
return c.json(response)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
// ============================================
// Reactions
// ============================================
app.post('/reactions', async (c) => {
try {
const body = await c.req.json<CreateReactionRequest>()
const action = body.action || 'webhook'
if (!body.projection_def || !body.params) {
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing projection_def or params')
}
if (action === 'webhook' && !body.webhook_url) {
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'webhook_url is required when action is webhook')
}
if (action === 'emit_event' && !body.emit_event_type) {
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'emit_event_type is required when action is emit_event')
}
if (action === 'handler' && !body.handler_code) {
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'handler_code is required when action is handler')
}
const reaction = await createReaction(c.env.DB, body.projection_def, body.params, {
action,
webhook_url: body.webhook_url,
emit_event_type: body.emit_event_type,
emit_payload_template: body.emit_payload_template,
handler_code: body.handler_code,
handler_timeout_ms: body.handler_timeout_ms,
})
return c.json(reaction, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/reactions/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id')
const reaction = await getReaction(c.env.DB, id)
if (!reaction) return apiError(c, 404, ErrorCode.NOT_FOUND, `Reaction ${id} not found`)
return c.json(reaction)
})
app.get('/reactions', async (c) => {
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await listReactions(c.env.DB, limit, offset)
return c.json(result)
})
app.delete('/reactions/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
await deleteReaction(c.env.DB, id)
return c.json({ deleted: id })
})
// ============================================
// Reaction Logs
// ============================================
app.get('/reaction-logs', async (c) => {
const limit = parseInt(c.req.query('limit') || '50', 10)
const offset = parseInt(c.req.query('offset') || '0', 10)
const reactionId = c.req.query('reaction_id') ? parseInt(c.req.query('reaction_id')!, 10) : undefined
const result = await listReactionLogs(c.env.DB, limit, offset, reactionId)
return c.json(result)
})
// ============================================
// Request Logs
// ============================================
app.get('/request-logs', async (c) => {
const db = c.env.DB
const limit = parseInt(c.req.query('limit') || '50', 10)
const offset = parseInt(c.req.query('offset') || '0', 10)
const apiKeyId = c.req.query('api_key_id') ? parseInt(c.req.query('api_key_id')!, 10) : undefined
let query = 'SELECT * FROM request_logs'
const binds: any[] = []
if (apiKeyId !== undefined) {
query += ' WHERE api_key_id = ?'
binds.push(apiKeyId)
}
query += ' ORDER BY id DESC LIMIT ? OFFSET ?'
binds.push(limit, offset)
let countQuery = 'SELECT COUNT(*) as total FROM request_logs'
if (apiKeyId !== undefined) {
countQuery += ' WHERE api_key_id = ?'
}
const [rows, countRow] = await Promise.all([
db
.prepare(query)
.bind(...binds)
.all(),
db
.prepare(countQuery)
.bind(...(apiKeyId !== undefined ? [apiKeyId] : []))
.first<{ total: number }>(),
])
return c.json({ request_logs: rows.results, total: countRow?.total || 0 })
})
// ============================================
// API Keys
// ============================================
app.post('/api-keys', async (c) => {
try {
const body = await c.req.json<CreateApiKeyRequest>()
if (!body.name) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name')
// Role is always 'ingest' — admin/readonly not implemented
const result = await createApiKey(c.env.DB, body.name, body.allowed_events, body.rate_limit)
return c.json(result, 201)
} catch (err: any) {
if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/api-keys', async (c) => {
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await listApiKeys(c.env.DB, limit, offset)
return c.json(result)
})
app.delete('/api-keys/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
await deleteApiKey(c.env.DB, id)
return c.json({ deleted: id })
})
// ============================================
// Helper: Fire Reaction Webhooks
// ============================================
async function fireReactionWebhooks(db: D1Database, payloads: ReactionPayload[]): Promise<void> {
for (const payload of payloads) {
try {
// Look up webhook URL for this reaction
const reaction = await db
.prepare('SELECT webhook_url FROM reactions WHERE id = ?')
.bind(payload.reaction_id)
.first<{ webhook_url: string }>()
if (!reaction) continue
await fetch(reaction.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} catch {
// Ignore webhook errors
}
}
}
export default app