feat(server): add GET & PUT federation registry API endpoints
Adds persistent federation registry storage endpoints under /api/users/@me/federation-registry. GET returns all registry entries with the stored updatedAt timestamp. PUT replaces the full registry via LWW (409 on stale write) in an atomic transaction.
This commit is contained in:
@@ -467,6 +467,107 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send(sanitized);
|
return reply.code(200).send(sanitized);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/users/@me/federation-registry — retrieve persistent federation registry
|
||||||
|
app.get('/api/users/@me/federation-registry', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const entries = db.select().from(schema.userFederationRegistry)
|
||||||
|
.where(eq(schema.userFederationRegistry.userId, userId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const user = db.select({ federationRegistryUpdatedAt: schema.users.federationRegistryUpdatedAt })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
registry: entries.map((e) => ({
|
||||||
|
origin: e.origin,
|
||||||
|
label: e.label,
|
||||||
|
username: e.username,
|
||||||
|
remoteUserId: e.remoteUserId,
|
||||||
|
status: e.status,
|
||||||
|
addedAt: e.addedAt,
|
||||||
|
lastConnectedAt: e.lastConnectedAt,
|
||||||
|
disconnectedAt: e.disconnectedAt,
|
||||||
|
errorMessage: e.errorMessage,
|
||||||
|
})),
|
||||||
|
updatedAt: user?.federationRegistryUpdatedAt ?? 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/users/@me/federation-registry — replace federation registry (LWW)
|
||||||
|
app.put<{ Body: { registry: Array<{ origin: string; label?: string; username?: string; remoteUserId?: string; status: string; addedAt: number; lastConnectedAt?: number | null; disconnectedAt?: number | null; errorMessage?: string | null }>; updatedAt: number } }>(
|
||||||
|
'/api/users/@me/federation-registry', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { registry, updatedAt } = request.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(registry)) {
|
||||||
|
return reply.code(400).send({ error: 'registry must be an array', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (typeof updatedAt !== 'number' || updatedAt <= 0) {
|
||||||
|
return reply.code(400).send({ error: 'updatedAt must be a positive number', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStatuses = ['connected', 'disconnected', 'unreachable', 'auth_expired'];
|
||||||
|
for (const entry of registry) {
|
||||||
|
if (!entry || typeof entry.origin !== 'string' || !entry.origin) {
|
||||||
|
return reply.code(400).send({ error: 'Each entry must have a string origin', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!validStatuses.includes(entry.status)) {
|
||||||
|
return reply.code(400).send({ error: `Invalid status "${entry.status}" — must be one of: ${validStatuses.join(', ')}`, statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (typeof entry.addedAt !== 'number') {
|
||||||
|
return reply.code(400).send({ error: 'Each entry must have a numeric addedAt', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// LWW guard: reject if incoming timestamp is not newer than stored
|
||||||
|
const user = db.select({ federationRegistryUpdatedAt: schema.users.federationRegistryUpdatedAt })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const storedUpdatedAt = user?.federationRegistryUpdatedAt ?? 0;
|
||||||
|
if (updatedAt <= storedUpdatedAt) {
|
||||||
|
return reply.code(409).send({ error: 'Conflict: incoming registry is not newer than stored version', statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic replace: delete existing, insert all incoming, update timestamp
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.delete(schema.userFederationRegistry)
|
||||||
|
.where(eq(schema.userFederationRegistry.userId, userId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (registry.length > 0) {
|
||||||
|
tx.insert(schema.userFederationRegistry).values(
|
||||||
|
registry.map((e) => ({
|
||||||
|
userId,
|
||||||
|
origin: e.origin,
|
||||||
|
label: e.label ?? '',
|
||||||
|
username: e.username ?? '',
|
||||||
|
remoteUserId: e.remoteUserId ?? '',
|
||||||
|
status: e.status,
|
||||||
|
addedAt: e.addedAt,
|
||||||
|
lastConnectedAt: e.lastConnectedAt ?? null,
|
||||||
|
disconnectedAt: e.disconnectedAt ?? null,
|
||||||
|
errorMessage: e.errorMessage ?? null,
|
||||||
|
}))
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(schema.users)
|
||||||
|
.set({ federationRegistryUpdatedAt: updatedAt })
|
||||||
|
.where(eq(schema.users.id, userId))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send({ ok: true, updatedAt });
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
||||||
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
|
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
|
||||||
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user