invoke.ts76 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | |
| 3 | import { projectRateLimit } from '../middleware/rate-limit.js'; |
| 4 | import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js'; |
| 5 | import { invoke } from '../services/invoke.js'; |
| 6 | import { getQuotaState } from '../services/tier-enforcement.js'; |
| 7 | import type { ProjectAppEnv as AppEnv } from '../types/app-env.js'; |
| 8 | |
| 9 | export const invokeRouter = new Hono<AppEnv>(); |
| 10 | |
| 11 | // Tier-aware burst limit. Runs BEFORE auth so an unauthenticated flood |
| 12 | // can't drive the auth path; the helper returns null when the project |
| 13 | // doesn't exist, which the middleware treats as a pass-through (the |
| 14 | // auth check below 401s anyway). |
| 15 | invokeRouter.use('/v1/projects/:id/functions/:name', projectRateLimit('invoke')); |
| 16 | |
| 17 | // Functions are project-scoped resources, so they share project-auth with |
| 18 | // deployments and api-keys: either a session-bound owner or a matching brk_. |
| 19 | invokeRouter.use('/v1/projects/:id/functions/:name', requireProjectAuth()); |
| 20 | |
| 21 | // Suspension gating happens once at app level (apps/api/src/index.ts) via |
| 22 | // blockIfProjectSuspended mounted on /v1/projects/:id/*. |
| 23 | |
| 24 | // Function invocations require developer minimum (writes through user code). |
| 25 | invokeRouter.post( |
| 26 | '/v1/projects/:id/functions/:name', |
| 27 | requireProjectRole('developer'), |
| 28 | async (c) => { |
| 29 | const projectId = c.req.param('id'); |
| 30 | const functionName = c.req.param('name'); |
| 31 | const user = c.get('user'); |
| 32 | const apiKeyId = c.get('apiKeyId'); |
| 33 | const requestId = c.get('requestId'); |
| 34 | |
| 35 | const raw = await c.req.text(); |
| 36 | let args: unknown = null; |
| 37 | if (raw.length > 0) { |
| 38 | try { |
| 39 | args = JSON.parse(raw); |
| 40 | } catch { |
| 41 | return c.json({ code: 'invalid_json', message: 'request body is not valid json' }, 400); |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // Monthly cap enforcement. Cached (60s ttl in tier-enforcement), |
| 46 | // so this adds at most one DB roundtrip per minute per project. |
| 47 | const quota = await getQuotaState(projectId); |
| 48 | if (quota.exceeded) { |
| 49 | return c.json( |
| 50 | { |
| 51 | code: 'monthly_quota_exceeded', |
| 52 | message: `monthly invocation quota exceeded (${quota.current} / ${quota.limit} on ${quota.tier} tier)`, |
| 53 | current: quota.current, |
| 54 | limit: quota.limit, |
| 55 | tier: quota.tier, |
| 56 | }, |
| 57 | 429, |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | const result = await invoke({ |
| 62 | projectId, |
| 63 | functionName, |
| 64 | args, |
| 65 | requestId, |
| 66 | auth: user |
| 67 | ? { userId: user.id, tokenType: apiKeyId ? 'api_key' : 'session' } |
| 68 | : apiKeyId |
| 69 | ? { userId: `key:${apiKeyId}`, tokenType: 'api_key' } |
| 70 | : null, |
| 71 | }); |
| 72 | |
| 73 | const status = result.ok ? 200 : 500; |
| 74 | return c.json(result, status); |
| 75 | }, |
| 76 | ); |