orgs.ts460 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { newId } from '@briven/shared'; |
| 5 | |
| 6 | import { userRateLimit } from '../middleware/rate-limit.js'; |
| 7 | import { requireAuth } from '../middleware/session.js'; |
| 8 | import type { AppEnv } from '../types/app-env.js'; |
| 9 | import { audit, hashIp } from '../services/audit.js'; |
| 10 | import { |
| 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'; |
| 23 | import { |
| 24 | acceptOrgInvitation, |
| 25 | createOrgInvitation, |
| 26 | listOrgInvitations, |
| 27 | pendingOrgInvitationsForEmail, |
| 28 | revokeOrgInvitation, |
| 29 | } from '../services/org-invitations.js'; |
| 30 | import { getTierForOrg } from '../services/billing.js'; |
| 31 | import { 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 | |
| 40 | export const orgsRouter = new Hono<AppEnv>(); |
| 41 | |
| 42 | orgsRouter.use('/v1/me/orgs', requireAuth()); |
| 43 | orgsRouter.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 | */ |
| 51 | orgsRouter.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 | |
| 65 | const 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 | */ |
| 81 | orgsRouter.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 | |
| 139 | const renameSchema = z.object({ |
| 140 | name: z.string().min(2).max(200), |
| 141 | }); |
| 142 | |
| 143 | orgsRouter.use('/v1/orgs/:id', requireAuth()); |
| 144 | |
| 145 | orgsRouter.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 | |
| 185 | const 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 | */ |
| 197 | orgsRouter.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 | |
| 232 | orgsRouter.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 | |
| 252 | orgsRouter.use('/v1/orgs/:id/members', requireAuth()); |
| 253 | orgsRouter.use('/v1/orgs/:id/members/*', requireAuth()); |
| 254 | |
| 255 | orgsRouter.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 | |
| 265 | const changeRoleSchema = z.object({ |
| 266 | role: z.enum(orgRole), |
| 267 | }); |
| 268 | |
| 269 | orgsRouter.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 | |
| 300 | orgsRouter.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 | |
| 324 | orgsRouter.use('/v1/orgs/:id/invitations', requireAuth()); |
| 325 | orgsRouter.use('/v1/orgs/:id/invitations/*', requireAuth()); |
| 326 | orgsRouter.use('/v1/me/org-invitations', requireAuth()); |
| 327 | orgsRouter.use('/v1/org-invitations/accept', requireAuth()); |
| 328 | |
| 329 | const inviteSchema = z.object({ |
| 330 | email: z.string().email(), |
| 331 | role: z.enum(orgRole).default('developer'), |
| 332 | callbackURL: z.string().url(), |
| 333 | }); |
| 334 | |
| 335 | orgsRouter.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 | |
| 355 | orgsRouter.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 | |
| 391 | orgsRouter.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 | */ |
| 415 | orgsRouter.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 | */ |
| 425 | const acceptSchema = z.object({ |
| 426 | token: z.string().min(20).max(1024), |
| 427 | }); |
| 428 | |
| 429 | orgsRouter.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 | }); |