orgs.ts460 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { newId } from '@briven/shared';
5
6import { userRateLimit } from '../middleware/rate-limit.js';
7import { requireAuth } from '../middleware/session.js';
8import type { AppEnv } from '../types/app-env.js';
9import { audit, hashIp } from '../services/audit.js';
10import {
11 canUserCreateAnotherOrg,
12 changeOrgMemberRole,
13 createOrg,
14 deleteOrg,
15 getDefaultOrgForUser,
16 isOrgMember,
17 listOrgMembers,
18 listOrgsForUser,
19 promotePersonalOrgToTeam,
20 removeOrgMember,
21 renameOrg,
22} from '../services/orgs.js';
23import {
24 acceptOrgInvitation,
25 createOrgInvitation,
26 listOrgInvitations,
27 pendingOrgInvitationsForEmail,
28 revokeOrgInvitation,
29} from '../services/org-invitations.js';
30import { getTierForOrg } from '../services/billing.js';
31import { orgRole } from '../db/schema.js';
32
33/**
34 * Multi-org surface — team creation + list. Personal orgs are auto-
35 * created by the user.create.after Better Auth hook and never appear
36 * in the "new team" form; team orgs are explicit and listed here so
37 * the dashboard's /dashboard/teams can render them.
38 */
39
40export const orgsRouter = new Hono<AppEnv>();
41
42orgsRouter.use('/v1/me/orgs', requireAuth());
43orgsRouter.use('/v1/orgs', requireAuth());
44
45/**
46 * List every org the signed-in user is a member of — both the personal
47 * org and any team orgs they've created or been added to. Each row
48 * carries a `personal` flag so the UI can group "personal" separately
49 * from team orgs.
50 */
51orgsRouter.get('/v1/me/orgs', async (c) => {
52 const user = c.get('user')!;
53 const orgs = await listOrgsForUser(user.id);
54 return c.json({
55 orgs: orgs.map((o) => ({
56 id: o.id,
57 slug: o.slug,
58 name: o.name,
59 personal: o.personal,
60 createdAt: o.createdAt,
61 })),
62 });
63});
64
65const createSchema = z.object({
66 name: z.string().min(2).max(200),
67 slug: z
68 .string()
69 .min(2)
70 .max(40)
71 .regex(/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/u, 'slug must be kebab-case, 2-40 chars')
72 .optional(),
73});
74
75/**
76 * Create a new team org. The creator becomes its owner. Free tier caps
77 * teams per user at 1 (one personal + one team); Pro at 5; Team at
78 * unlimited — but enforcement of those caps is a follow-up. Today the
79 * platform just creates the row.
80 */
81orgsRouter.post('/v1/orgs', async (c) => {
82 const user = c.get('user')!;
83 const body = await c.req.json().catch(() => null);
84 const parsed = createSchema.safeParse(body);
85 if (!parsed.success) {
86 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
87 }
88
89 // Tier-cap check — free users can't create team orgs (limit=1 owned;
90 // their personal org already takes that slot). Pro users get 3 owned
91 // orgs total. Team-tier is unlimited. Tier is resolved from the user's
92 // personal org since teams don't carry their own subscription yet.
93 const personal = await getDefaultOrgForUser(user.id);
94 const tier = await getTierForOrg(personal.id);
95 const gate = await canUserCreateAnotherOrg(user.id, tier);
96 if (!gate.allowed) {
97 return c.json(
98 {
99 code: 'team_limit_reached',
100 message: gate.reason,
101 upgradeURL: '/dashboard/billing/upgrade',
102 },
103 402,
104 );
105 }
106 const slug =
107 parsed.data.slug ??
108 `${parsed.data.name
109 .toLowerCase()
110 .replace(/[^a-z0-9]+/g, '-')
111 .replace(/(^-|-$)/g, '')
112 .slice(0, 32)}-${newId('org').slice(-6).toLowerCase()}`;
113 const org = await createOrg({
114 createdBy: user.id,
115 name: parsed.data.name,
116 slug,
117 personal: false,
118 role: 'owner',
119 });
120 await audit({
121 actorId: user.id,
122 projectId: null,
123 action: 'org.created',
124 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
125 userAgent: c.req.header('user-agent') ?? null,
126 metadata: { orgId: org.id, slug: org.slug, name: org.name },
127 });
128 return c.json({
129 org: {
130 id: org.id,
131 slug: org.slug,
132 name: org.name,
133 personal: org.personal,
134 createdAt: org.createdAt,
135 },
136 });
137});
138
139const renameSchema = z.object({
140 name: z.string().min(2).max(200),
141});
142
143orgsRouter.use('/v1/orgs/:id', requireAuth());
144
145orgsRouter.patch('/v1/orgs/:id', async (c) => {
146 const user = c.get('user')!;
147 const orgId = c.req.param('id');
148 const body = await c.req.json().catch(() => null);
149 const parsed = renameSchema.safeParse(body);
150 if (!parsed.success) {
151 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
152 }
153 const updated = await renameOrg({
154 orgId,
155 userId: user.id,
156 name: parsed.data.name,
157 });
158 if (!updated) {
159 return c.json(
160 {
161 code: 'not_found_or_not_writable',
162 message: 'org not found, not a member, or it is a personal org',
163 },
164 404,
165 );
166 }
167 await audit({
168 actorId: user.id,
169 projectId: null,
170 action: 'org.renamed',
171 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
172 userAgent: c.req.header('user-agent') ?? null,
173 metadata: { orgId, newName: updated.name },
174 });
175 return c.json({
176 org: {
177 id: updated.id,
178 slug: updated.slug,
179 name: updated.name,
180 personal: updated.personal,
181 },
182 });
183});
184
185const promoteSchema = z.object({
186 name: z.string().min(1).max(120),
187});
188
189/**
190 * Promote a personal org to a real team. Personal orgs are
191 * single-member by default; graduating one unlocks the rest of the
192 * team affordances (rename via the standard patch route, invite
193 * members, ...). The caller must be an owner of the org being
194 * promoted — the service enforces that, the route just shapes the
195 * request + records audit.
196 */
197orgsRouter.post('/v1/orgs/:id/promote', async (c) => {
198 const user = c.get('user')!;
199 const orgId = c.req.param('id');
200 const body = await c.req.json().catch(() => null);
201 const parsed = promoteSchema.safeParse(body);
202 if (!parsed.success) {
203 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
204 }
205 const result = await promotePersonalOrgToTeam({
206 orgId,
207 userId: user.id,
208 name: parsed.data.name,
209 });
210 if (!result.ok) {
211 const status = result.reason === 'org_not_found' ? 404 : 400;
212 return c.json({ code: 'cannot_promote', reason: result.reason }, status);
213 }
214 await audit({
215 actorId: user.id,
216 projectId: null,
217 action: 'org.promoted',
218 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
219 userAgent: c.req.header('user-agent') ?? null,
220 metadata: { orgId, newName: result.org.name },
221 });
222 return c.json({
223 org: {
224 id: result.org.id,
225 slug: result.org.slug,
226 name: result.org.name,
227 personal: result.org.personal,
228 },
229 });
230});
231
232orgsRouter.delete('/v1/orgs/:id', async (c) => {
233 const user = c.get('user')!;
234 const orgId = c.req.param('id');
235 const result = await deleteOrg({ orgId, userId: user.id });
236 if (!result.ok) {
237 return c.json({ code: 'cannot_delete_org', message: result.reason }, 400);
238 }
239 await audit({
240 actorId: user.id,
241 projectId: null,
242 action: 'org.deleted',
243 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
244 userAgent: c.req.header('user-agent') ?? null,
245 metadata: { orgId },
246 });
247 return c.json({ deleted: orgId });
248});
249
250/* ─── org members ─────────────────────────────────────────────────── */
251
252orgsRouter.use('/v1/orgs/:id/members', requireAuth());
253orgsRouter.use('/v1/orgs/:id/members/*', requireAuth());
254
255orgsRouter.get('/v1/orgs/:id/members', async (c) => {
256 const user = c.get('user')!;
257 const orgId = c.req.param('id');
258 if (!(await isOrgMember(user.id, orgId))) {
259 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
260 }
261 const members = await listOrgMembers(orgId);
262 return c.json({ members });
263});
264
265const changeRoleSchema = z.object({
266 role: z.enum(orgRole),
267});
268
269orgsRouter.patch('/v1/orgs/:id/members/:userId', userRateLimit('org-mutate', 30), async (c) => {
270 const actor = c.get('user')!;
271 const orgId = c.req.param('id');
272 const targetUserId = c.req.param('userId');
273 if (!(await isOrgMember(actor.id, orgId))) {
274 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
275 }
276 const body = await c.req.json().catch(() => null);
277 const parsed = changeRoleSchema.safeParse(body);
278 if (!parsed.success) {
279 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
280 }
281 const result = await changeOrgMemberRole({
282 orgId,
283 userId: targetUserId,
284 newRole: parsed.data.role,
285 });
286 if (!result.ok) {
287 return c.json({ code: 'cannot_change_role', message: result.reason }, 400);
288 }
289 await audit({
290 actorId: actor.id,
291 projectId: null,
292 action: 'org.member.role_changed',
293 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
294 userAgent: c.req.header('user-agent') ?? null,
295 metadata: { orgId, userId: targetUserId, newRole: parsed.data.role },
296 });
297 return c.json({ ok: true, role: parsed.data.role });
298});
299
300orgsRouter.delete('/v1/orgs/:id/members/:userId', userRateLimit('org-mutate', 30), async (c) => {
301 const actor = c.get('user')!;
302 const orgId = c.req.param('id');
303 const targetUserId = c.req.param('userId');
304 if (!(await isOrgMember(actor.id, orgId))) {
305 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
306 }
307 const result = await removeOrgMember({ orgId, userId: targetUserId });
308 if (!result.removed) {
309 return c.json({ code: 'cannot_remove_member', message: result.reason }, 400);
310 }
311 await audit({
312 actorId: actor.id,
313 projectId: null,
314 action: 'org.member.removed',
315 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
316 userAgent: c.req.header('user-agent') ?? null,
317 metadata: { orgId, removedUserId: targetUserId },
318 });
319 return c.json({ removed: targetUserId });
320});
321
322/* ─── org invitations ─────────────────────────────────────────────── */
323
324orgsRouter.use('/v1/orgs/:id/invitations', requireAuth());
325orgsRouter.use('/v1/orgs/:id/invitations/*', requireAuth());
326orgsRouter.use('/v1/me/org-invitations', requireAuth());
327orgsRouter.use('/v1/org-invitations/accept', requireAuth());
328
329const inviteSchema = z.object({
330 email: z.string().email(),
331 role: z.enum(orgRole).default('developer'),
332 callbackURL: z.string().url(),
333});
334
335orgsRouter.get('/v1/orgs/:id/invitations', async (c) => {
336 const user = c.get('user')!;
337 const orgId = c.req.param('id');
338 if (!(await isOrgMember(user.id, orgId))) {
339 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
340 }
341 const rows = await listOrgInvitations(orgId);
342 return c.json({
343 invitations: rows.map((r) => ({
344 id: r.id,
345 email: r.email,
346 role: r.role,
347 expiresAt: r.expiresAt,
348 acceptedAt: r.acceptedAt,
349 revokedAt: r.revokedAt,
350 createdAt: r.createdAt,
351 })),
352 });
353});
354
355orgsRouter.post('/v1/orgs/:id/invitations', userRateLimit('org-mutate', 30), async (c) => {
356 const user = c.get('user')!;
357 const orgId = c.req.param('id');
358 if (!(await isOrgMember(user.id, orgId))) {
359 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
360 }
361 const body = await c.req.json().catch(() => null);
362 const parsed = inviteSchema.safeParse(body);
363 if (!parsed.success) {
364 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
365 }
366 const inv = await createOrgInvitation({
367 orgId,
368 email: parsed.data.email,
369 role: parsed.data.role,
370 invitedBy: user.id,
371 callbackURL: parsed.data.callbackURL,
372 });
373 await audit({
374 actorId: user.id,
375 projectId: null,
376 action: 'org.invitation.created',
377 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
378 userAgent: c.req.header('user-agent') ?? null,
379 metadata: { orgId, email: parsed.data.email, role: parsed.data.role },
380 });
381 return c.json({
382 invitation: {
383 id: inv.id,
384 email: inv.email,
385 role: inv.role,
386 expiresAt: inv.expiresAt,
387 },
388 });
389});
390
391orgsRouter.delete('/v1/orgs/:id/invitations/:invId', userRateLimit('org-mutate', 30), async (c) => {
392 const user = c.get('user')!;
393 const orgId = c.req.param('id');
394 const invId = c.req.param('invId');
395 if (!(await isOrgMember(user.id, orgId))) {
396 return c.json({ code: 'forbidden', message: 'not a member of that org' }, 403);
397 }
398 await revokeOrgInvitation(orgId, invId);
399 await audit({
400 actorId: user.id,
401 projectId: null,
402 action: 'org.invitation.revoked',
403 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
404 userAgent: c.req.header('user-agent') ?? null,
405 metadata: { orgId, invitationId: invId },
406 });
407 return c.json({ revoked: invId });
408});
409
410/**
411 * Pending team invitations for the signed-in user. Drives the
412 * dashboard's "you have a pending team invite" banner — parallel to
413 * /v1/me/invitations which already does the project-level equivalent.
414 */
415orgsRouter.get('/v1/me/org-invitations', async (c) => {
416 const user = c.get('user')!;
417 const rows = await pendingOrgInvitationsForEmail(user.email);
418 return c.json({ invitations: rows });
419});
420
421/**
422 * Accept an org invitation. Token comes from the email link OR (when
423 * the user is already signed in) from a dashboard-initiated accept.
424 */
425const acceptSchema = z.object({
426 token: z.string().min(20).max(1024),
427});
428
429orgsRouter.post('/v1/org-invitations/accept', async (c) => {
430 const user = c.get('user')!;
431 const body = await c.req.json().catch(() => null);
432 const parsed = acceptSchema.safeParse(body);
433 if (!parsed.success) {
434 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
435 }
436 try {
437 const accepted = await acceptOrgInvitation({
438 token: parsed.data.token,
439 userId: user.id,
440 userEmail: user.email,
441 });
442 await audit({
443 actorId: user.id,
444 projectId: null,
445 action: 'org.invitation.accepted',
446 ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null),
447 userAgent: c.req.header('user-agent') ?? null,
448 metadata: { orgId: accepted.orgId, role: accepted.role },
449 });
450 return c.json({ accepted: true, orgId: accepted.orgId, role: accepted.role });
451 } catch (err) {
452 return c.json(
453 {
454 code: 'invitation_invalid',
455 message: err instanceof Error ? err.message : 'invitation invalid',
456 },
457 400,
458 );
459 }
460});