import { Hono } from 'hono' import { randomUUID } from 'node:crypto' import { getDb } from '../../db/sqlite' import { sessionAuth } from '../../auth/middleware' import { SYSTEM_USER_ID } from '../../auth/constants' const app = new Hono() interface SessionOwnerRow { owner_type: string owner_id: string } /** * Check if a user is a member of a given team. */ function isTeamMember( userId: string, teamId: string, db: ReturnType, ): boolean { const row = db .query( 'SELECT user_id FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: userId }) return !!row } /** * Helper: check if the current user is the session owner. * Returns the userId on success, or a Response on failure. */ function checkSessionOwnership( c: import('hono').Context, sessionId: string, ): { userId: string } | Response { const userId = c.get('userId') as string | undefined const isAdmin = c.get('isAdmin') as boolean | undefined if (!userId) { return c.json( { error: { type: 'unauthorized', message: 'Not authenticated' }, }, 401, ) } const db = getDb() // Check session exists const session = db .query('SELECT id FROM sessions WHERE id = $id') .get({ $id: sessionId }) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } // System admin bypasses ownership check if (isAdmin || userId === SYSTEM_USER_ID) { return { userId } } // Check ownership: user owns the session directly const ownerRow = db .query( `SELECT owner_type, owner_id FROM session_owners WHERE session_id = $sid AND owner_type = 'user' AND owner_id = $uid`, ) .get({ $sid: sessionId, $uid: userId }) as SessionOwnerRow | null if (ownerRow) { return { userId } } // Check ownership: user's team owns the session AND user is team owner/admin. // 普通 team member 不能管理 share(与 session delete 权限一致)。 const teamOwner = db .query( `SELECT so.owner_id FROM session_owners so JOIN team_members tm ON tm.team_id = so.owner_id WHERE so.session_id = $sid AND so.owner_type = 'team' AND tm.user_id = $uid AND tm.role IN ('owner', 'admin')`, ) .get({ $sid: sessionId, $uid: userId }) if (teamOwner) { return { userId } } return c.json( { error: { type: 'forbidden', message: 'Not session owner' } }, 403, ) } /** * POST /sessions/:id/shares — Create a share for a session. */ app.post( '/sessions/:id/shares', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, async c => { const sessionId = c.req.param('id') const ownership = checkSessionOwnership(c, sessionId) if (ownership instanceof Response) return ownership const { userId } = ownership let body: Record try { body = await c.req.json() } catch { return c.json( { error: { type: 'bad_request', message: 'Invalid JSON' } }, 400, ) } const grantedTo = body.grantedTo as | { userId?: string; teamId?: string } | undefined const permission = body.permission as string | undefined const expiresAt = (body.expiresAt || body.expires_at) as string | undefined // Validate permission if (!permission) { return c.json( { error: { type: 'bad_request', message: 'permission is required', }, }, 400, ) } if (permission !== 'read' && permission !== 'write') { return c.json( { error: { type: 'bad_request', message: 'permission must be "read" or "write"', }, }, 400, ) } // Validate grantedTo if (!grantedTo) { return c.json( { error: { type: 'bad_request', message: 'grantedTo is required', }, }, 400, ) } const db = getDb() // Validate target user/team exists if (grantedTo.userId) { const user = db .query('SELECT id FROM users WHERE id = $id') .get({ $id: grantedTo.userId }) if (!user) { return c.json( { error: { type: 'not_found', message: 'User not found' }, }, 404, ) } } else if (grantedTo.teamId) { const team = db .query('SELECT id FROM teams WHERE id = $id AND deleted_at IS NULL') .get({ $id: grantedTo.teamId }) if (!team) { return c.json( { error: { type: 'not_found', message: 'Team not found' }, }, 404, ) } } // Generate share token (same as id) const shareId = `shr_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() db.query( `INSERT INTO session_shares (id, session_id, granted_to_user, granted_to_team, permission, expires_at, granted_by, granted_at) VALUES ($id, $sid, $gtu, $gtt, $perm, $exp, $by, $now)`, ).run({ $id: shareId, $sid: sessionId, $gtu: grantedTo.userId || null, $gtt: grantedTo.teamId || null, $perm: permission, $exp: expiresAt || null, $by: userId, $now: now, }) // Return { share, shareToken } to match frontend CreateShareResponse type. const share = { id: shareId, session_id: sessionId, granted_to_user: grantedTo.userId || null, granted_to_team: grantedTo.teamId || null, permission, expires_at: expiresAt || null, created_at: now, created_by: userId, } return c.json({ share, shareToken: shareId }, 201) }, ) /** * GET /sessions/:id/shares — List all shares for a session. */ app.get( '/sessions/:id/shares', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, async c => { const sessionId = c.req.param('id') const ownership = checkSessionOwnership(c, sessionId) if (ownership instanceof Response) return ownership const db = getDb() const shares = db .query( `SELECT id, session_id, granted_to_user, granted_to_team, permission, expires_at, granted_by AS created_by, granted_at AS created_at FROM session_shares WHERE session_id = $sid`, ) .all({ $sid: sessionId }) return c.json(shares, 200) }, ) /** * DELETE /sessions/:id/shares/:shareId — Revoke a share. */ app.delete( '/sessions/:id/shares/:shareId', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, async c => { const sessionId = c.req.param('id') const shareId = c.req.param('shareId') const ownership = checkSessionOwnership(c, sessionId) if (ownership instanceof Response) return ownership const db = getDb() // Check share exists and belongs to this session const share = db .query( `SELECT id FROM session_shares WHERE id = $id AND session_id = $sid`, ) .get({ $id: shareId, $sid: sessionId }) if (!share) { return c.json( { error: { type: 'not_found', message: 'Share not found' } }, 404, ) } // Delete the share db.query('DELETE FROM session_shares WHERE id = $id').run({ $id: shareId, }) return c.json({ ok: true }, 200) }, ) /** * GET /sessions/s/:shareToken — Resolve a share token to session info. */ app.get( '/sessions/s/:shareToken', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, async c => { const shareToken = c.req.param('shareToken') // Validate format: must start with shr_ if (!shareToken || !shareToken.startsWith('shr_')) { return c.json( { error: { type: 'not_found', message: 'Invalid share token' }, }, 404, ) } const db = getDb() // Look up the share const share = db .query( `SELECT id, session_id, granted_to_user, granted_to_team, expires_at FROM session_shares WHERE id = $id`, ) .get({ $id: shareToken }) as { id: string session_id: string granted_to_user: string | null granted_to_team: string | null expires_at: string | null } | null if (!share) { return c.json( { error: { type: 'not_found', message: 'Share not found' } }, 404, ) } // Check expiry if (share.expires_at && new Date(share.expires_at).getTime() < Date.now()) { return c.json( { error: { type: 'unauthorized', message: 'Share expired' } }, 401, ) } // Grantee check: caller must be the granted_to_user or a member of granted_to_team const callerUserId = c.get('userId') as string | undefined if (!callerUserId) { return c.json( { error: { type: 'unauthorized', message: 'Not authenticated' } }, 401, ) } const isAdmin = c.get('isAdmin') as boolean | undefined const isGrantee = isAdmin || share.granted_to_user === callerUserId || (share.granted_to_team !== null && isTeamMember(callerUserId, share.granted_to_team, db)) if (!isGrantee) { return c.json( { error: { type: 'forbidden', message: 'Not authorized for this share', }, }, 403, ) } // Check session still exists — return only public-safe fields const session = db .query( `SELECT id, title, status, source, created_at, updated_at FROM sessions WHERE id = $id`, ) .get({ $id: share.session_id }) as Record | null if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' }, }, 404, ) } return c.json(session, 200) }, ) export default app