diff --git a/src/routes/api/matrix-space/members/+server.ts b/src/routes/api/matrix-space/members/+server.ts new file mode 100644 index 0000000..9d6d4db --- /dev/null +++ b/src/routes/api/matrix-space/members/+server.ts @@ -0,0 +1,186 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const db = (supabase: any) => supabase; + +/** + * POST: Invite a user to the org's Matrix Space (and its child rooms). + * + * Body: { org_id, matrix_user_id, homeserver_url, access_token } + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const session = await locals.safeGetSession(); + if (!session.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { org_id, matrix_user_id, homeserver_url, access_token } = body; + + if (!org_id || !matrix_user_id || !homeserver_url || !access_token) { + return json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Get org's Matrix Space ID + const { data: org } = await db(locals.supabase) + .from('organizations') + .select('matrix_space_id') + .eq('id', org_id) + .single(); + + if (!org?.matrix_space_id) { + return json({ error: 'Organization has no Matrix Space' }, { status: 404 }); + } + + const spaceId = org.matrix_space_id; + const errors: string[] = []; + + // Invite to the Space itself + const inviteRes = await matrixInvite(homeserver_url, access_token, spaceId, matrix_user_id); + if (!inviteRes.ok) { + const err = await inviteRes.json().catch(() => ({})); + // M_FORBIDDEN means already joined, which is fine + if (err.errcode !== 'M_FORBIDDEN') { + errors.push(`Space invite: ${err.error || 'failed'}`); + } + } + + // Also invite to all child rooms of the space + try { + const stateRes = await fetch( + `${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`, + { + headers: { 'Authorization': `Bearer ${access_token}` }, + } + ); + + if (stateRes.ok) { + const stateEvents = await stateRes.json(); + const childRoomIds = stateEvents + .filter((e: any) => e.type === 'm.space.child' && e.content?.via) + .map((e: any) => e.state_key); + + for (const childRoomId of childRoomIds) { + const childInvite = await matrixInvite(homeserver_url, access_token, childRoomId, matrix_user_id); + if (!childInvite.ok) { + const err = await childInvite.json().catch(() => ({})); + if (err.errcode !== 'M_FORBIDDEN') { + errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`); + } + } + } + } + } catch (e) { + errors.push('Failed to fetch space children'); + } + + return json({ + success: errors.length === 0, + invited: matrix_user_id, + spaceId, + errors: errors.length > 0 ? errors : undefined, + }); +}; + +/** + * DELETE: Kick a user from the org's Matrix Space (and its child rooms). + * + * Query: ?org_id=...&matrix_user_id=...&homeserver_url=...&access_token=... + */ +export const DELETE: RequestHandler = async ({ url, locals }) => { + const session = await locals.safeGetSession(); + if (!session.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const org_id = url.searchParams.get('org_id'); + const matrix_user_id = url.searchParams.get('matrix_user_id'); + const homeserver_url = url.searchParams.get('homeserver_url'); + const access_token = url.searchParams.get('access_token'); + + if (!org_id || !matrix_user_id || !homeserver_url || !access_token) { + return json({ error: 'Missing required fields' }, { status: 400 }); + } + + const { data: org } = await db(locals.supabase) + .from('organizations') + .select('matrix_space_id') + .eq('id', org_id) + .single(); + + if (!org?.matrix_space_id) { + return json({ error: 'Organization has no Matrix Space' }, { status: 404 }); + } + + const spaceId = org.matrix_space_id; + const errors: string[] = []; + + // Kick from child rooms first, then from the space + try { + const stateRes = await fetch( + `${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`, + { + headers: { 'Authorization': `Bearer ${access_token}` }, + } + ); + + if (stateRes.ok) { + const stateEvents = await stateRes.json(); + const childRoomIds = stateEvents + .filter((e: any) => e.type === 'm.space.child' && e.content?.via) + .map((e: any) => e.state_key); + + for (const childRoomId of childRoomIds) { + const kickRes = await matrixKick(homeserver_url, access_token, childRoomId, matrix_user_id); + if (!kickRes.ok) { + const err = await kickRes.json().catch(() => ({})); + if (err.errcode !== 'M_FORBIDDEN') { + errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`); + } + } + } + } + } catch (e) { + errors.push('Failed to fetch space children'); + } + + // Kick from the space itself + const kickRes = await matrixKick(homeserver_url, access_token, spaceId, matrix_user_id); + if (!kickRes.ok) { + const err = await kickRes.json().catch(() => ({})); + if (err.errcode !== 'M_FORBIDDEN') { + errors.push(`Space kick: ${err.error || 'failed'}`); + } + } + + return json({ + success: errors.length === 0, + kicked: matrix_user_id, + spaceId, + errors: errors.length > 0 ? errors : undefined, + }); +}; + +// Helper: invite a user to a room +async function matrixInvite(homeserver: string, token: string, roomId: string, userId: string) { + return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user_id: userId }), + }); +} + +// Helper: kick a user from a room +async function matrixKick(homeserver: string, token: string, roomId: string, userId: string) { + return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user_id: userId, reason: 'Removed from organization' }), + }); +}