feat: Matrix Space membership sync API (invite/kick members from org space + child rooms)
This commit is contained in:
186
src/routes/api/matrix-space/members/+server.ts
Normal file
186
src/routes/api/matrix-space/members/+server.ts
Normal file
@@ -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' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user