import { Hono } from 'hono' import { randomUUID, createHash } from 'node:crypto' import { getDb } from '../../db/sqlite' import { sessionAuth } from '../../auth/middleware' import { SYSTEM_USER_ID } from '../../auth/constants' import { storeClaimEnvironment, storeBindEnvSessionsToUser, storeGetEnvironmentOwnership, } from '../../store' const app = new Hono() // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const SLUG_RE = /^[a-z0-9-]{3,32}$/ function isValidSlug(slug: string): boolean { return SLUG_RE.test(slug) } interface UserInfo { userId: string isAdmin: boolean role: string } /** * Resolve the current user's info from context. * System admin (via API key bypass) gets userId=SYSTEM_USER_ID. * Also checks DB role for users who logged in as admin. */ function getUserInfo(c: import('hono').Context): UserInfo { const userId = c.get('userId') as string const isAdminCtx = c.get('isAdmin') as boolean // API key bypass — system admin identity (no real user row). if (userId === SYSTEM_USER_ID) { return { userId: SYSTEM_USER_ID, isAdmin: true, role: 'admin' } } // Cookie-authenticated admin — preserve real userId so team ownership // and membership queries resolve correctly. Previously this branch // overwrote userId with SYSTEM_USER_ID, causing admin-created teams // to be attributed to the first admin row in the DB rather than the // actual creator, and breaking GET /teams for the logged-in admin. if (isAdminCtx) { return { userId, isAdmin: true, role: 'admin' } } // Look up user role from DB const db = getDb() const row = db .query('SELECT role FROM users WHERE id = $id') .get({ $id: userId }) as { role: string } | null const role = row?.role || 'guest' return { userId, isAdmin: role === 'admin', role, } } /** * Get the user's role within a specific team. * Returns null if user is not a member. * System admins get 'owner' implicitly. */ function getTeamRole(teamId: string, user: UserInfo): string | null { if (user.isAdmin) return 'owner' const db = getDb() const row = db .query( 'SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: user.userId }) as { role: string } | null return row?.role || null } function getTeamOr404(c: import('hono').Context, teamId: string) { const db = getDb() const team = db .query('SELECT * FROM teams WHERE id = $id AND deleted_at IS NULL') .get({ $id: teamId }) as Record | null if (!team) { return { error: c.json({ error: 'Team not found' }, 404) } } return { team } } // --------------------------------------------------------------------------- // Auth middleware wrapper // --------------------------------------------------------------------------- async function withAuth(c: import('hono').Context, next: import('hono').Next) { const result = await sessionAuth(c, next) if (result) return result } // --------------------------------------------------------------------------- // POST /teams — Create team // --------------------------------------------------------------------------- app.post('/teams', withAuth, async c => { const user = getUserInfo(c) // Guest cannot create teams if (!user.isAdmin && user.role === 'guest') { return c.json({ error: 'Guests cannot create teams' }, 403) } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { name, slug, description } = body as { name?: string slug?: string description?: string } if (!name || typeof name !== 'string') { return c.json({ error: 'Name is required' }, 400) } if (!slug || typeof slug !== 'string') { return c.json({ error: 'Slug is required' }, 400) } if (!isValidSlug(slug)) { return c.json( { error: 'Invalid slug: must be 3-32 lowercase letters, digits, or hyphens', }, 400, ) } const db = getDb() // Check slug uniqueness const existing = db .query('SELECT id FROM teams WHERE slug = $slug AND deleted_at IS NULL') .get({ $slug: slug }) if (existing) { return c.json({ error: 'Slug already taken' }, 409) } const teamId = `team_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() // For system admin (API key), use the first admin user as created_by. // For a logged-in admin user, use their own userId. let createdBy = user.userId if (user.userId === SYSTEM_USER_ID) { const adminUser = db .query( "SELECT id FROM users WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1", ) .get() as { id: string } | null if (!adminUser) { return c.json({ error: 'No admin user available to create team' }, 400) } createdBy = adminUser.id } db.query( `INSERT INTO teams (id, name, slug, description, created_by, created_at, updated_at) VALUES ($id, $name, $slug, $desc, $by, $now, $now)`, ).run({ $id: teamId, $name: name, $slug: slug, $desc: description || null, $by: createdBy, $now: now, }) // Creator becomes owner db.query( `INSERT INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, 'owner', $now, $addedBy)`, ).run({ $teamId: teamId, $userId: createdBy, $addedBy: createdBy, $now: now }) return c.json( { id: teamId, name, slug, description: description || null, created_by: createdBy, created_at: now, updated_at: now, }, 201, ) }) // --------------------------------------------------------------------------- // GET /teams — List teams // --------------------------------------------------------------------------- app.get('/teams', withAuth, async c => { const user = getUserInfo(c) const db = getDb() if (user.isAdmin) { // Admin sees all non-deleted teams const teams = db .query('SELECT * FROM teams WHERE deleted_at IS NULL ORDER BY name') .all() return c.json(teams, 200) } if (user.role === 'guest') { return c.json([], 200) } // Member sees only teams they belong to const teams = db .query( `SELECT t.* FROM teams t JOIN team_members tm ON tm.team_id = t.id WHERE tm.user_id = $userId AND t.deleted_at IS NULL ORDER BY t.name`, ) .all({ $userId: user.userId }) return c.json(teams, 200) }) // --------------------------------------------------------------------------- // GET /teams/:id — Get team details // --------------------------------------------------------------------------- app.get('/teams/by-slug/:slug', withAuth, async c => { const slug = c.req.param('slug')! const user = getUserInfo(c) const db = getDb() const team = db .query('SELECT * FROM teams WHERE slug = $slug AND deleted_at IS NULL') .get({ $slug: slug }) as Record | null if (!team) { return c.json({ error: 'Team not found' }, 404) } const teamRole = getTeamRole(team.id as string, user) if (!teamRole) { return c.json({ error: 'Not a team member' }, 403) } return c.json(team, 200) }) app.get('/teams/:id', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) if (!teamRole) { return c.json({ error: 'Not a team member' }, 403) } return c.json(result.team, 200) }) // --------------------------------------------------------------------------- // PATCH /teams/:id — Update team // --------------------------------------------------------------------------- app.patch('/teams/:id', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) // Only owner (or system admin) can modify team info if (!user.isAdmin && teamRole !== 'owner') { return c.json({ error: 'Only team owner can modify team info' }, 403) } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { name, slug, description } = body as { name?: string slug?: string description?: string } const db = getDb() // If changing slug, validate and check uniqueness if (slug !== undefined) { if (!isValidSlug(slug)) { return c.json({ error: 'Invalid slug format' }, 400) } const existingSlug = db .query( 'SELECT id FROM teams WHERE slug = $slug AND id != $id AND deleted_at IS NULL', ) .get({ $slug: slug, $id: teamId }) if (existingSlug) { return c.json({ error: 'Slug already taken' }, 409) } } const now = new Date().toISOString() const updates: string[] = ['updated_at = $now'] const params: Record = { $id: teamId, $now: now, } if (name !== undefined) { updates.push('name = $name') params.$name = name } if (slug !== undefined) { updates.push('slug = $slug') params.$slug = slug } if (description !== undefined) { updates.push('description = $desc') params.$desc = description } db.query(`UPDATE teams SET ${updates.join(', ')} WHERE id = $id`).run(params) const updated = db .query('SELECT * FROM teams WHERE id = $id') .get({ $id: teamId }) return c.json(updated, 200) }) // --------------------------------------------------------------------------- // DELETE /teams/:id — Soft delete team // --------------------------------------------------------------------------- app.delete('/teams/:id', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) // Only owner (or system admin) can delete if (!user.isAdmin && teamRole !== 'owner') { return c.json({ error: 'Only team owner can delete team' }, 403) } const db = getDb() // Check for un-transferred environments const envCount = db .query( 'SELECT COUNT(*) as count FROM team_environments WHERE team_id = $teamId', ) .get({ $teamId: teamId }) as { count: number } if (envCount.count > 0) { return c.json( { error: 'Team has environments that must be transferred first', }, 400, ) } const now = new Date().toISOString() db.query('UPDATE teams SET deleted_at = $now WHERE id = $id').run({ $now: now, $id: teamId, }) // Remove team ownership from sessions (orphan them) db.query( "DELETE FROM session_owners WHERE owner_type = 'team' AND owner_id = $teamId", ).run({ $teamId: teamId }) return c.json({ ok: true }, 200) }) // --------------------------------------------------------------------------- // GET /teams/:id/members — List members // --------------------------------------------------------------------------- app.get('/teams/:id/members', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) if (!teamRole) { return c.json({ error: 'Not a team member' }, 403) } const db = getDb() const members = db .query( `SELECT tm.*, u.username FROM team_members tm LEFT JOIN users u ON u.id = tm.user_id WHERE tm.team_id = $teamId`, ) .all({ $teamId: teamId }) return c.json(members, 200) }) // --------------------------------------------------------------------------- // POST /teams/:id/members — Add member // --------------------------------------------------------------------------- app.post('/teams/:id/members', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) // Only owner/admin (or system admin) can add members if (!user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin') { return c.json({ error: 'Only team owner/admin can add members' }, 403) } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { userId, role } = body as { userId?: string role?: string } if (!userId) { return c.json({ error: 'userId is required' }, 400) } const validRoles = ['owner', 'admin', 'member'] const memberRole = role || 'member' if (!validRoles.includes(memberRole)) { return c.json({ error: 'Invalid role' }, 400) } const db = getDb() // Check user exists const targetUser = db .query('SELECT id FROM users WHERE id = $id') .get({ $id: userId }) if (!targetUser) { return c.json({ error: 'User not found' }, 404) } // Check not already a member const existing = db .query( 'SELECT user_id FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: userId }) if (existing) { return c.json({ error: 'User is already a member' }, 409) } const now = new Date().toISOString() const addedBy = user.isAdmin ? (result.team.created_by as string) : user.userId db.query( `INSERT INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, $role, $now, $addedBy)`, ).run({ $teamId: teamId, $userId: userId, $role: memberRole, $now: now, $addedBy: addedBy, }) return c.json({ ok: true, role: memberRole }, 201) }) // --------------------------------------------------------------------------- // PATCH /teams/:id/members/:userId — Change member role // --------------------------------------------------------------------------- app.patch('/teams/:id/members/:userId', withAuth, async c => { const teamId = c.req.param('id')! const targetUserId = c.req.param('userId')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) // Must be at least admin to change roles if (!user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin') { return c.json({ error: 'Insufficient permissions' }, 403) } const db = getDb() // Team admin cannot change owner/admin roles if (!user.isAdmin && teamRole === 'admin') { const targetMember = db .query( 'SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: targetUserId }) as { role: string } | null if ( targetMember && (targetMember.role === 'owner' || targetMember.role === 'admin') ) { return c.json( { error: 'Team admin cannot change owner/admin roles' }, 403, ) } } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { role } = body as { role?: string } if (!role) { return c.json({ error: 'role is required' }, 400) } const validRoles = ['owner', 'admin', 'member'] if (!validRoles.includes(role)) { return c.json({ error: 'Invalid role' }, 400) } // Check target is actually a member const currentMember = db .query( 'SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: targetUserId }) as { role: string } | null if (!currentMember) { return c.json({ error: 'User is not a member' }, 404) } // If changing from owner to something else, check there are other owners if (currentMember.role === 'owner' && role !== 'owner') { const ownerCount = db .query( `SELECT COUNT(*) as count FROM team_members WHERE team_id = $teamId AND role = 'owner'`, ) .get({ $teamId: teamId }) as { count: number } if (ownerCount.count <= 1) { return c.json({ error: 'Cannot demote the last owner' }, 400) } } db.query( `UPDATE team_members SET role = $role WHERE team_id = $teamId AND user_id = $userId`, ).run({ $role: role, $teamId: teamId, $userId: targetUserId }) return c.json({ ok: true, role }, 200) }) // --------------------------------------------------------------------------- // DELETE /teams/:id/members/:userId — Remove member // --------------------------------------------------------------------------- app.delete('/teams/:id/members/:userId', withAuth, async c => { const teamId = c.req.param('id')! const targetUserId = c.req.param('userId')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) const db = getDb() // Check target is a member const targetMember = db .query( 'SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: targetUserId }) as { role: string } | null if (!targetMember) { return c.json({ error: 'User is not a member' }, 404) } // Members can only remove themselves if (!user.isAdmin && teamRole === 'member') { if (targetUserId !== user.userId) { return c.json({ error: 'Members can only remove themselves' }, 403) } } // Owner/admin can remove anyone (except constraints below) if ( !user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin' && targetUserId !== user.userId ) { return c.json({ error: 'Insufficient permissions' }, 403) } // Cannot remove the last owner if (targetMember.role === 'owner') { const ownerCount = db .query( `SELECT COUNT(*) as count FROM team_members WHERE team_id = $teamId AND role = 'owner'`, ) .get({ $teamId: teamId }) as { count: number } if (ownerCount.count <= 1) { return c.json({ error: 'Cannot remove the last owner' }, 400) } } db.query( 'DELETE FROM team_members WHERE team_id = $teamId AND user_id = $userId', ).run({ $teamId: teamId, $userId: targetUserId }) return c.json({ ok: true }, 200) }) // --------------------------------------------------------------------------- // POST /teams/:id/invitations — Create invitation // --------------------------------------------------------------------------- app.post('/teams/:id/invitations', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) // Only owner/admin can create invitations if (!user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin') { return c.json( { error: 'Only team owner/admin can create invitations' }, 403, ) } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { role, expiresIn, maxUses } = body as { role?: string expiresIn?: number maxUses?: number } const inviteRole = role || 'member' const validRoles = ['admin', 'member'] if (!validRoles.includes(inviteRole)) { return c.json({ error: 'Invalid role' }, 400) } const token = `inv_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(token).digest('hex') const now = new Date() const expiresAt = new Date( now.getTime() + (expiresIn || 86400) * 1000, ).toISOString() const maxUsesValue = maxUses || 1 const db = getDb() const createdBy = user.isAdmin ? (result.team.created_by as string) : user.userId db.query( `INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by) VALUES ($hash, $teamId, $role, $exp, $maxUses, 0, $by)`, ).run({ $hash: tokenHash, $teamId: teamId, $role: inviteRole, $exp: expiresAt, $maxUses: maxUsesValue, $by: createdBy, }) return c.json( { token, inviteToken: token, role: inviteRole, expiresAt, maxUses: maxUsesValue, teamId, }, 201, ) }) // --------------------------------------------------------------------------- // DELETE /teams/:id/invitations/:token — Revoke invitation // --------------------------------------------------------------------------- app.delete('/teams/:id/invitations/:token', withAuth, async c => { const teamId = c.req.param('id')! const token = c.req.param('token')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) // Only owner/admin can revoke invitations if (!user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin') { return c.json( { error: 'Only team owner/admin can revoke invitations' }, 403, ) } const db = getDb() const tokenHash = createHash('sha256').update(token).digest('hex') // Verify invitation belongs to this team const invitation = db .query( 'SELECT * FROM invitations WHERE token_hash = $hash AND team_id = $teamId', ) .get({ $hash: tokenHash, $teamId: teamId }) if (!invitation) { return c.json({ error: 'Invitation not found' }, 404) } // Delete the invitation (revoke) db.query('DELETE FROM invitations WHERE token_hash = $hash').run({ $hash: tokenHash, }) return c.json({ ok: true }, 200) }) // --------------------------------------------------------------------------- // GET /teams/:id/environments — List team environments // --------------------------------------------------------------------------- app.get('/teams/:id/environments', withAuth, async c => { const teamId = c.req.param('id')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) if (!teamRole) { return c.json({ error: 'Not a team member' }, 403) } const db = getDb() const envs = db .query( `SELECT te.environment_id as id, te.environment_id FROM team_environments te WHERE te.team_id = $teamId`, ) .all({ $teamId: teamId }) return c.json(envs, 200) }) // --------------------------------------------------------------------------- // POST /environments/:id/transfer — Transfer environment to another team // --------------------------------------------------------------------------- app.post('/environments/:id/transfer', withAuth, async c => { const envId = c.req.param('id')! const user = getUserInfo(c) let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { toTeamId } = body as { toTeamId?: string } if (!toTeamId) { return c.json({ error: 'toTeamId is required' }, 400) } const db = getDb() // Check target team exists const targetTeam = db .query('SELECT * FROM teams WHERE id = $id AND deleted_at IS NULL') .get({ $id: toTeamId }) if (!targetTeam) { return c.json({ error: 'Target team not found' }, 404) } // Find source team const sourceRow = db .query( 'SELECT team_id FROM team_environments WHERE environment_id = $envId', ) .get({ $envId: envId }) as { team_id: string } | null if (sourceRow) { const teamRole = getTeamRole(sourceRow.team_id, user) // Only owner/admin (or system admin) can transfer if (!user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin') { return c.json( { error: 'Only team owner/admin can transfer environments', }, 403, ) } } // Personal environment (no source team): any user who is admin/owner of // the target team can claim it. This lets the Web UI "link personal env // to this team" flow work without requiring admin role. // Check caller is owner/admin of the target team const targetRole = getTeamRole(toTeamId, user) if ( !user.isAdmin && (!targetRole || (targetRole !== 'owner' && targetRole !== 'admin')) ) { return c.json({ error: 'Not authorized in target team' }, 403) } // Remove from source team db.query('DELETE FROM team_environments WHERE environment_id = $envId').run({ $envId: envId, }) // Add to target team db.query( `INSERT OR REPLACE INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`, ).run({ $teamId: toTeamId, $envId: envId }) // Transfer session ownership for sessions in this environment // Update session_owners: remove old team owners, add new team owner if (sourceRow) { db.query( `DELETE FROM session_owners WHERE owner_type = 'team' AND owner_id = $oldTeamId AND session_id IN ( SELECT id FROM sessions WHERE environment_id = $envId )`, ).run({ $oldTeamId: sourceRow.team_id, $envId: envId }) } // Add new team as owner for sessions in this environment db.query( `INSERT OR IGNORE INTO session_owners (session_id, owner_type, owner_id) SELECT id, 'team', $teamId FROM sessions WHERE environment_id = $envId`, ).run({ $teamId: toTeamId, $envId: envId }) return c.json({ ok: true }, 200) }) // --------------------------------------------------------------------------- // DELETE /teams/:id/environments/:envId — Unlink environment from team // Makes the environment personal again (no team association). Only team // owner/admin (or system admin) can unlink. Sessions created under the // team scope for this env lose their team ownership. // --------------------------------------------------------------------------- app.delete('/teams/:id/environments/:envId', withAuth, async c => { const teamId = c.req.param('id')! const envId = c.req.param('envId')! const user = getUserInfo(c) const result = getTeamOr404(c, teamId) if ('error' in result) return result.error const teamRole = getTeamRole(teamId, user) if (!user.isAdmin && teamRole !== 'owner' && teamRole !== 'admin') { return c.json( { error: 'Only team owner/admin can unlink environments' }, 403, ) } const db = getDb() db.query( 'DELETE FROM team_environments WHERE team_id = $teamId AND environment_id = $envId', ).run({ $teamId: teamId, $envId: envId }) // Drop team ownership for sessions in this env so they revert to personal. db.query( `DELETE FROM session_owners WHERE owner_type = 'team' AND owner_id = $teamId AND session_id IN ( SELECT id FROM sessions WHERE environment_id = $envId )`, ).run({ $teamId: teamId, $envId: envId }) return c.json({ ok: true }, 200) }) // --------------------------------------------------------------------------- // POST /web/environments/claim — Claim an unowned environment. // Worker registers env without --user-id and without --team-id → env gets a // claim_token, worker prints /code/claim/. First logged-in user to // POST here with the matching token becomes the env's owner. // --------------------------------------------------------------------------- app.post('/environments/claim', withAuth, async c => { const user = getUserInfo(c) let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const claimToken = body.claim_token if (typeof claimToken !== 'string' || !claimToken.startsWith('clm_')) { return c.json({ error: 'Invalid claim_token' }, 400) } const db = getDb() const row = db .query( 'SELECT id, owner_user_id, claim_expires_at FROM environments WHERE claim_token = $ct', ) .get({ $ct: claimToken }) as { id: string owner_user_id: string | null claim_expires_at: string | null } | null if (!row) { return c.json({ error: 'Claim token not found' }, 404) } if (row.owner_user_id !== null) { return c.json({ error: 'Environment already claimed' }, 409) } if ( row.claim_expires_at && new Date(row.claim_expires_at).getTime() < Date.now() ) { return c.json({ error: 'Claim token expired' }, 410) } const ok = storeClaimEnvironment(row.id, user.userId) if (!ok) { return c.json({ error: 'Environment not found or already claimed' }, 409) } // Backfill session_owners for sessions already auto-created under this // env (e.g. ACP sessions created at env register time when the env had // no owner). Without this, the claimer would own the env but its // sessions would remain orphan — invisible to non-admin users. storeBindEnvSessionsToUser(row.id, user.userId) return c.json({ ok: true, environment_id: row.id }, 200) }) // --------------------------------------------------------------------------- // POST /web/sessions/:id/transfer — Transfer session between team/personal. // Body: { toTeamId: string | null }. toTeamId = null strips team owner // (revert to personal); toTeamId = sets team owner (team members gain // visibility). Caller must be the session's user owner or admin. // --------------------------------------------------------------------------- app.post('/sessions/:id/transfer', withAuth, async c => { const sessionId = c.req.param('id')! const user = getUserInfo(c) let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const toTeamId = body.toTeamId if (toTeamId !== null && typeof toTeamId !== 'string') { return c.json({ error: 'toTeamId must be string or null' }, 400) } const db = getDb() // Caller must be the session's user owner, or team admin of the session's // current team owner, or system admin. const ownerRow = db .query( `SELECT owner_id FROM session_owners WHERE session_id = $sid AND owner_type = 'user'`, ) .get({ $sid: sessionId }) as { owner_id: string } | null const teamOwnerRow = db .query( `SELECT owner_id FROM session_owners WHERE session_id = $sid AND owner_type = 'team'`, ) .get({ $sid: sessionId }) as { owner_id: string } | null const isUserOwner = ownerRow?.owner_id === user.userId let isTeamAdmin = false if (teamOwnerRow) { const role = getTeamRole(teamOwnerRow.owner_id, user) isTeamAdmin = role === 'owner' || role === 'admin' } if (!user.isAdmin && !isUserOwner && !isTeamAdmin) { return c.json({ error: 'Not authorized to transfer this session' }, 403) } // Validate target team if set. if (toTeamId !== null) { const targetTeam = db .query('SELECT id FROM teams WHERE id = $id AND deleted_at IS NULL') .get({ $id: toTeamId }) if (!targetTeam) { return c.json({ error: 'Target team not found' }, 404) } // Caller must be admin/owner of target team too (prevent dumping sessions // into teams you can't manage). const targetRole = getTeamRole(toTeamId, user) if ( !user.isAdmin && (!targetRole || (targetRole !== 'owner' && targetRole !== 'admin')) ) { return c.json({ error: 'Not authorized in target team' }, 403) } } // Strip existing team owner, then set new one (if any). db.query( `DELETE FROM session_owners WHERE session_id = $sid AND owner_type = 'team'`, ).run({ $sid: sessionId }) if (toTeamId !== null) { db.query( `INSERT OR IGNORE INTO session_owners (session_id, owner_type, owner_id) VALUES ($sid, 'team', $tid)`, ).run({ $sid: sessionId, $tid: toTeamId }) } // Update sessions.visibility to match. db.query('UPDATE sessions SET visibility = $v WHERE id = $sid').run({ $sid: sessionId, $v: toTeamId !== null ? 'team' : 'private', }) return c.json({ ok: true }, 200) }) // --------------------------------------------------------------------------- // GET /users/search — Search users by username or user_id (for inviting existing users) // Returns up to 20 matches. Only authenticated users can call this. // --------------------------------------------------------------------------- app.get('/users/search', withAuth, async c => { const q = (c.req.query('q') || '').trim() if (!q || q.length < 2) { return c.json([], 200) } const db = getDb() const pattern = `%${q}%` const rows = db .query( `SELECT id, username, role FROM users WHERE username LIKE $pattern OR id LIKE $pattern ORDER BY username ASC LIMIT 20`, ) .all({ $pattern: pattern }) as Array<{ id: string username: string role: string }> return c.json(rows, 200) }) export default app