projects.ts360 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { requireProjectAuth } from '../middleware/project-auth.js';
5import { requireAuth } from '../middleware/session.js';
6import { requireRecentMfa } from '../middleware/step-up.js';
7import type { AppEnv } from '../types/app-env.js';
8import { assertProjectRole } from '../services/access.js';
9import { audit, hashIp, listAuditForProject } from '../services/audit.js';
10import {
11 getFunctionStats,
12 getHourlyInvocations,
13 listFunctionLogs,
14 listFunctionNames,
15} from '../services/function-logs.js';
16import { getDefaultOrgForUser, isOrgMember, listOrgsForUser } from '../services/orgs.js';
17import { env } from '../env.js';
18import {
19 createProject,
20 getProjectForUser,
21 getProjectInfo,
22 listProjectsForUser,
23 moveProjectToOrg,
24 softDeleteProjectForUser,
25 updateProjectForUser,
26} from '../services/projects.js';
27import { ensureDefaultProjectStorage } from '../services/storage-keys.js';
28
29const createSchema = z.object({
30 name: z.string().min(1).max(80),
31 slug: z.string().min(1).max(32).optional(),
32 region: z.string().min(2).max(32).optional(),
33 // Optional — when present, the project lands in this org. When
34 // omitted, defaults to the user's personal org. We validate
35 // membership before honouring the value (defense against an
36 // attacker poking at other people's orgs).
37 orgId: z.string().min(1).optional(),
38});
39
40const updateSchema = z.object({
41 name: z.string().min(1).max(80).optional(),
42 slug: z.string().min(1).max(32).optional(),
43});
44
45function getIpHash(c: Context<AppEnv>): string | null {
46 const fwd = c.req.raw.headers.get('x-forwarded-for');
47 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
48 return hashIp(ip);
49}
50
51export const projectsRouter = new Hono<AppEnv>();
52
53// The `/info` route below accepts either a session OR a project-scoped
54// API key, via requireProjectAuth. It MUST be registered before the
55// broader `/v1/projects/*` requireAuth middleware so the stricter
56// session-only middleware doesn't short-circuit Bearer-authed requests
57// to /info. Hono runs middleware in registration order.
58projectsRouter.use('/v1/projects/:id/info', requireProjectAuth());
59projectsRouter.get('/v1/projects/:id/info', async (c) => {
60 // Lightweight "is this credential real?" endpoint — used by
61 // `briven login` to verify the user's key before storing it.
62 // Auth middleware has already validated the credential; we only
63 // return a minimal info blob to confirm the project exists.
64 const projectId = c.req.param('id');
65 const info = await getProjectInfo(projectId);
66 return c.json({ project: info });
67});
68
69// CLI- and SDK-facing routes accept a project-scoped API key (Bearer brk_…)
70// in addition to a session / CLI JWT, via requireProjectAuth. Like /info
71// above, they MUST be registered before the broad `/v1/projects/*`
72// requireAuth so the session-only guard doesn't reject the API key first.
73// The handlers themselves live in deploymentsRouter / invokeRouter; the
74// requireProjectAuth here authenticates and sets apiKeyId, which the broad
75// requireAuth below then honours (see session.ts). Without these carve-outs
76// `briven deploy` and SDK function calls authenticated by API key 401 with
77// "invalid cli token".
78projectsRouter.use('/v1/projects/:id/schema/current', requireProjectAuth());
79projectsRouter.use('/v1/projects/:id/deployments', requireProjectAuth());
80projectsRouter.use('/v1/projects/:id/deployments/*', requireProjectAuth());
81projectsRouter.use('/v1/projects/:id/functions/:name', requireProjectAuth());
82
83// Project-scoped DATA routes (sprint S2.7). These live in their own routers
84// (dbRouter, projectEnvRouter, logsRouter, usageRouter, exportRouter,
85// studioRouter, aiRouter) and each already declares requireProjectAuth()
86// + its own role check. But because the broad `/v1/projects/*` requireAuth()
87// below is registered first (Hono runs matching middleware in registration
88// order across mounted routers), a `brk_` API key was rejected by the
89// session-only guard before those routers ran — so SDK/CLI calls 401'd on
90// /env, /db/*, /logs/*, /usage, /export, /studio/*, /ai/*. Carving them out
91// here authenticates the key first (session OR brk_); the broad requireAuth
92// then honours the already-authenticated request (see session.ts). The per-
93// route role checks (admin/developer) still run in the owning routers.
94projectsRouter.use('/v1/projects/:id/env', requireProjectAuth());
95projectsRouter.use('/v1/projects/:id/env/*', requireProjectAuth());
96projectsRouter.use('/v1/projects/:id/db/*', requireProjectAuth());
97projectsRouter.use('/v1/projects/:id/logs/*', requireProjectAuth());
98projectsRouter.use('/v1/projects/:id/usage', requireProjectAuth());
99projectsRouter.use('/v1/projects/:id/realtime-stats', requireProjectAuth());
100projectsRouter.use('/v1/projects/:id/export', requireProjectAuth());
101projectsRouter.use('/v1/projects/:id/studio/*', requireProjectAuth());
102projectsRouter.use('/v1/projects/:id/ai/*', requireProjectAuth());
103// Storage keys (MinIO bucket + S3 service accounts) — CLI `briven storage setup`
104projectsRouter.use('/v1/projects/:id/storage-keys', requireProjectAuth());
105projectsRouter.use('/v1/projects/:id/storage-keys/*', requireProjectAuth());
106// File uploads / list (session or brk_ after same carve-out pattern)
107projectsRouter.use('/v1/projects/:id/files', requireProjectAuth());
108projectsRouter.use('/v1/projects/:id/files/*', requireProjectAuth());
109// Auth admin (enable, config patch, mint pk_briven_auth_, allowed domains).
110// Same carve-out: without it, Bearer brk_ hits session requireAuth first and
111// 401s as "invalid cli token" (Konnos agent handoff 2026-07-21). Handlers live
112// in authServiceRouter; role gates stay there.
113projectsRouter.use('/v1/projects/:id/auth', requireProjectAuth());
114projectsRouter.use('/v1/projects/:id/auth/*', requireProjectAuth());
115
116projectsRouter.use('/v1/projects', requireAuth());
117projectsRouter.use('/v1/projects/*', requireAuth());
118
119projectsRouter.get('/v1/projects', async (c) => {
120 const user = c.get('user')!;
121 const [rows, orgs] = await Promise.all([
122 listProjectsForUser(user.id),
123 listOrgsForUser(user.id),
124 ]);
125 const orgsById = new Map(orgs.map((o) => [o.id, o]));
126 // Enrich each project with its org name/personal flag so the
127 // dashboard can show "p · personal" / "p · acme inc" without a
128 // second round-trip.
129 const enriched = rows.map((p) => {
130 const org = orgsById.get(p.orgId);
131 return {
132 ...p,
133 orgName: org?.name ?? null,
134 orgPersonal: org?.personal ?? null,
135 };
136 });
137 return c.json({ projects: enriched });
138});
139
140projectsRouter.post('/v1/projects', async (c) => {
141 const user = c.get('user')!;
142 const body = await c.req.json().catch(() => null);
143 const parsed = createSchema.safeParse(body);
144 if (!parsed.success) {
145 return c.json(
146 {
147 code: 'validation_failed',
148 message: 'invalid request body',
149 issues: parsed.error.issues,
150 },
151 400,
152 );
153 }
154
155 // Resolve target org: when the caller passes an explicit orgId,
156 // validate they belong to it; otherwise fall back to their personal
157 // org (the legacy single-org behaviour).
158 let targetOrgId: string;
159 if (parsed.data.orgId) {
160 const isMember = await isOrgMember(user.id, parsed.data.orgId);
161 if (!isMember) {
162 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
163 }
164 targetOrgId = parsed.data.orgId;
165 } else {
166 const org = await getDefaultOrgForUser(user.id);
167 targetOrgId = org.id;
168 }
169 const project = await createProject({
170 name: parsed.data.name,
171 orgId: targetOrgId,
172 createdByUserId: user.id,
173 slug: parsed.data.slug,
174 region: parsed.data.region,
175 });
176 await audit({
177 actorId: user.id,
178 projectId: project.id,
179 action: 'project.create',
180 ipHash: getIpHash(c),
181 userAgent: c.req.header('user-agent') ?? null,
182 metadata: { slug: project.slug },
183 });
184
185 // Standard setup: every new project gets its own S3 bucket + a default
186 // storage key (secret returned once). Never fails project create.
187 const storage = await ensureDefaultProjectStorage({
188 projectId: project.id,
189 createdBy: user.id,
190 publicEndpoint: env.BRIVEN_MINIO_PUBLIC_ENDPOINT ?? env.BRIVEN_MINIO_ENDPOINT ?? '',
191 keyName: 'default',
192 });
193 if (storage) {
194 await audit({
195 actorId: user.id,
196 projectId: project.id,
197 action: 'storage_key.create',
198 ipHash: getIpHash(c),
199 userAgent: c.req.header('user-agent') ?? null,
200 metadata: {
201 keyId: storage.record.id,
202 name: storage.record.name,
203 bucket: storage.bucket,
204 auto: true,
205 },
206 });
207 }
208
209 return c.json({ project, storage: storage ?? null }, 201);
210});
211
212projectsRouter.get('/v1/projects/:id', async (c) => {
213 const user = c.get('user')!;
214 const project = await getProjectForUser(c.req.param('id'), user.id);
215 return c.json({ project });
216});
217
218projectsRouter.patch('/v1/projects/:id', async (c) => {
219 const user = c.get('user')!;
220 const body = await c.req.json().catch(() => null);
221 const parsed = updateSchema.safeParse(body);
222 if (!parsed.success) {
223 return c.json(
224 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
225 400,
226 );
227 }
228 await assertProjectRole(c.req.param('id'), user.id, 'admin');
229 const project = await updateProjectForUser(c.req.param('id'), user.id, parsed.data);
230 await audit({
231 actorId: user.id,
232 projectId: project.id,
233 action: 'project.update',
234 ipHash: getIpHash(c),
235 userAgent: c.req.header('user-agent') ?? null,
236 metadata: parsed.data as Record<string, unknown>,
237 });
238 return c.json({ project });
239});
240
241const moveSchema = z.object({
242 orgId: z.string().min(1),
243});
244
245projectsRouter.post('/v1/projects/:id/move', async (c) => {
246 const user = c.get('user')!;
247 await assertProjectRole(c.req.param('id'), user.id, 'admin');
248 const body = await c.req.json().catch(() => null);
249 const parsed = moveSchema.safeParse(body);
250 if (!parsed.success) {
251 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
252 }
253 // The user must also belong to the target org — otherwise they could
254 // "park" a project inside a team they have no business in.
255 if (!(await isOrgMember(user.id, parsed.data.orgId))) {
256 return c.json(
257 { code: 'forbidden', message: 'you are not a member of the target org' },
258 403,
259 );
260 }
261 const project = await moveProjectToOrg({
262 projectId: c.req.param('id'),
263 userId: user.id,
264 targetOrgId: parsed.data.orgId,
265 });
266 await audit({
267 actorId: user.id,
268 projectId: project.id,
269 action: 'project.move',
270 ipHash: getIpHash(c),
271 userAgent: c.req.header('user-agent') ?? null,
272 metadata: { newOrgId: parsed.data.orgId },
273 });
274 return c.json({ project });
275});
276
277// Project deletion requires step-up per CLAUDE.md §5.4 — a soft-delete
278// kicks off the 30-day hard-delete grace window and is the kind of
279// destructive action a stolen session shouldn't be able to perform
280// without a fresh password prompt.
281projectsRouter.delete('/v1/projects/:id', requireRecentMfa(10), async (c) => {
282 const user = c.get('user')!;
283 await assertProjectRole(c.req.param('id'), user.id, 'admin');
284 const project = await softDeleteProjectForUser(c.req.param('id'), user.id);
285 await audit({
286 actorId: user.id,
287 projectId: project.id,
288 action: 'project.delete',
289 ipHash: getIpHash(c),
290 userAgent: c.req.header('user-agent') ?? null,
291 });
292 return c.json({ project });
293});
294
295projectsRouter.get('/v1/projects/:id/function-logs', async (c) => {
296 const user = c.get('user')!;
297 const project = await getProjectForUser(c.req.param('id'), user.id);
298 const functionName = c.req.query('function');
299 const statusParam = c.req.query('status');
300 const beforeParam = c.req.query('before');
301 const limitParam = c.req.query('limit');
302
303 const status = statusParam === 'ok' || statusParam === 'err' ? statusParam : undefined;
304 let before: Date | undefined;
305 if (beforeParam) {
306 const d = new Date(beforeParam);
307 if (!Number.isNaN(d.getTime())) before = d;
308 }
309 const limit = limitParam ? Number(limitParam) : undefined;
310
311 const logs = await listFunctionLogs(project.id, {
312 functionName: functionName && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(functionName)
313 ? functionName
314 : undefined,
315 status,
316 before,
317 limit: Number.isFinite(limit) ? limit : undefined,
318 });
319 return c.json({ logs });
320});
321
322projectsRouter.get('/v1/projects/:id/function-names', async (c) => {
323 const user = c.get('user')!;
324 const project = await getProjectForUser(c.req.param('id'), user.id);
325 const names = await listFunctionNames(project.id);
326 return c.json({ names });
327});
328
329projectsRouter.get('/v1/projects/:id/hourly-invocations', async (c) => {
330 const user = c.get('user')!;
331 const project = await getProjectForUser(c.req.param('id'), user.id);
332 const hours = await getHourlyInvocations(project.id);
333 return c.json({ hours });
334});
335
336projectsRouter.get('/v1/projects/:id/function-stats', async (c) => {
337 const user = c.get('user')!;
338 const project = await getProjectForUser(c.req.param('id'), user.id);
339 const fn = c.req.query('function');
340 if (!fn || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(fn)) {
341 return c.json({ code: 'validation_failed', message: 'expected ?function=name' }, 400);
342 }
343 const hoursParam = Number(c.req.query('hours') ?? '24');
344 const hours = Number.isFinite(hoursParam) ? Math.min(Math.max(hoursParam, 1), 24 * 30) : 24;
345 const stats = await getFunctionStats(project.id, fn, hours);
346 return c.json({ ...stats, sinceHours: hours });
347});
348
349projectsRouter.get('/v1/projects/:id/activity', async (c) => {
350 const user = c.get('user')!;
351 const project = await getProjectForUser(c.req.param('id'), user.id);
352 // Optional ?prefix=studio. filters audit rows by action prefix so the
353 // activity page can drill into a single subsystem (studio, deploy, key).
354 const prefix = c.req.query('prefix');
355 const rows = await listAuditForProject(project.id, {
356 limit: 100,
357 actionPrefix: prefix && /^[a-z._]{1,32}$/.test(prefix) ? prefix : undefined,
358 });
359 return c.json({ activity: rows });
360});