Fix federation flows and add versioned updater

This commit is contained in:
cyph3rasi
2026-03-07 21:55:55 -08:00
parent 5d524a0354
commit 1b0b3d8d52
24 changed files with 1486 additions and 142 deletions
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth/admin';
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version';
import { getHostUpdaterStatus, triggerHostUpdate } from '@/lib/host-updater';
function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
if (!latestVersion) {
return false;
}
return compareBuildVersions(currentVersion, latestVersion) < 0;
}
export async function GET() {
try {
await requireAdmin();
const [latest, updater] = await Promise.all([
getLatestPublishedBuild(),
getHostUpdaterStatus(),
]);
const current = getCurrentBuildInfo();
return NextResponse.json({
current,
latest,
updateAvailable: isUpdateAvailable(current.version, latest?.version),
updater,
});
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('[Admin Update] Status error:', error);
return NextResponse.json({ error: 'Failed to get update status' }, { status: 500 });
}
}
export async function POST() {
try {
await requireAdmin();
const result = await triggerHostUpdate();
return NextResponse.json(result, { status: 202 });
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('[Admin Update] Trigger error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to trigger update' },
{ status: 500 }
);
}
}
+39 -9
View File
@@ -7,26 +7,56 @@ import { verifySwarmRequest } from '@/lib/swarm/signature';
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
import { z } from 'zod';
// Schema for direct signed action (legacy)
const chatReceiveSchema = z.object({
const signedChatActionSchema = z.object({
action: z.string().min(1),
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
ts: z.number(),
nonce: z.string().min(1),
sig: z.string().min(1),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
});
// Backward compatibility for older nodes that sent legacy field names.
const legacyChatActionSchema = z.object({
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
signature: z.string(),
timestamp: z.number().optional(),
signature: z.string().min(1),
timestamp: z.number(),
});
// Schema for federated envelope
const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]);
const federatedEnvelopeSchema = z.object({
userAction: chatReceiveSchema,
userAction: incomingChatActionSchema,
fullSenderHandle: z.string().min(3).max(60),
sourceDomain: z.string().min(1),
ts: z.number(),
});
function normalizeSignedAction(action: z.infer<typeof incomingChatActionSchema>): SignedAction {
if ('sig' in action) {
return action;
}
return {
action: 'chat',
data: action.data,
did: action.did,
handle: action.handle,
ts: action.timestamp,
nonce: `legacy:${action.did}:${action.timestamp}`,
sig: action.signature,
};
}
/**
* POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes.
@@ -67,19 +97,19 @@ export async function POST(request: NextRequest) {
}
// Extract user's signed action and full handle from envelope
signedAction = body.userAction;
signedAction = normalizeSignedAction(body.userAction);
fullSenderHandle = body.fullSenderHandle;
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
} else {
// Legacy format - direct user signed action
const actionValidation = chatReceiveSchema.safeParse(body);
const actionValidation = incomingChatActionSchema.safeParse(body);
if (!actionValidation.success) {
return NextResponse.json(
{ error: 'Invalid action payload', details: actionValidation.error.issues },
{ status: 400 }
);
}
signedAction = body;
signedAction = normalizeSignedAction(actionValidation.data);
}
const { did, handle, data } = signedAction;
-6
View File
@@ -79,12 +79,9 @@ export async function POST(request: Request, context: RouteContext) {
actorDisplayName: user.displayName || user.handle,
actorAvatarUrl: user.avatarUrl || undefined,
actorNodeDomain: nodeDomain,
actorDid: user.did,
actorPublicKey: user.publicKey,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
userSignature: signedAction.sig,
});
if (!result.success) {
@@ -181,12 +178,9 @@ export async function POST(request: Request, context: RouteContext) {
actorDisplayName: user.displayName || user.handle,
actorAvatarUrl: user.avatarUrl || undefined,
actorNodeDomain: nodeDomain,
actorDid: user.did,
actorPublicKey: user.publicKey,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
userSignature: signedAction.sig,
});
if (result.success) {
+1
View File
@@ -63,6 +63,7 @@ export async function GET(request: NextRequest) {
// Local posts may have apId if they've been federated, so we check nodeId instead
let whereCondition = and(
isNull(posts.replyToId), // Not a reply
isNull(posts.swarmReplyToId), // Not a swarm reply
eq(posts.isRemoved, false), // Not removed
isNull(users.nodeId) // Local user (not from another swarm node)
);
+29 -7
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray, lt } from 'drizzle-orm';
import { eq, desc, and, inArray, lt, sql } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
@@ -51,8 +51,8 @@ export async function GET(request: Request, context: RouteContext) {
displayName: profile.displayName || profile.handle,
avatarUrl: profile.avatarUrl,
};
const posts = profileData.posts.map((post: any) => ({
const remotePosts = profileData.posts.map((post: any) => ({
id: post.id,
content: post.content,
createdAt: post.createdAt,
@@ -70,7 +70,7 @@ export async function GET(request: Request, context: RouteContext) {
originalPostId: post.id,
}));
return NextResponse.json({ posts, nextCursor: null });
return NextResponse.json({ posts: remotePosts, nextCursor: null });
}
return NextResponse.json({ posts: [] });
@@ -108,13 +108,35 @@ export async function GET(request: Request, context: RouteContext) {
avatarUrl: profile.avatarUrl,
};
const posts = profileData.posts.map((post: any) => ({
const swarmPostIds = profileData.posts.map((post: any) => `swarm:${remote.domain}:${post.id}`);
let localReplyCounts = new Map<string, number>();
if (swarmPostIds.length > 0) {
const counts = await db.select({
swarmReplyToId: posts.swarmReplyToId,
replyCount: sql<number>`count(*)::int`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, swarmPostIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId);
localReplyCounts = new Map(
counts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, row.replyCount])
);
}
const remotePosts = profileData.posts.map((post: any) => ({
id: post.id,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0),
author,
media: post.media || [],
linkPreviewUrl: post.linkPreviewUrl || null,
@@ -126,7 +148,7 @@ export async function GET(request: Request, context: RouteContext) {
originalPostId: post.id,
}));
return NextResponse.json({ posts, nextCursor: null });
return NextResponse.json({ posts: remotePosts, nextCursor: null });
}
return NextResponse.json({ posts: [] });
+2 -55
View File
@@ -1,59 +1,6 @@
import { NextResponse } from 'next/server';
import { execSync } from 'child_process';
import { getCurrentBuildInfo } from '@/lib/version';
export async function GET() {
try {
// Get the total number of commits
const commitCount = execSync('git rev-list --count HEAD', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // Ignore stderr
}).trim();
// Get the short hash for reference
const commitHash = execSync('git rev-parse --short HEAD', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const fullHash = execSync('git rev-parse HEAD', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Try to get the GitHub repo URL
let githubUrl = null;
try {
const remoteUrl = execSync('git config --get remote.origin.url', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Convert git URL to GitHub web URL
// Handle both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git)
if (remoteUrl.includes('github.com')) {
let cleanUrl = remoteUrl
.replace('git@github.com:', 'https://github.com/')
.replace(/\.git$/, '');
githubUrl = `${cleanUrl}/commit/${fullHash}`;
}
} catch (e) {
// If we can't get the remote URL, that's okay
}
return NextResponse.json({
count: parseInt(commitCount, 10),
hash: commitHash,
fullHash: fullHash,
githubUrl: githubUrl
});
} catch (error) {
// If git is not available or not a git repo, return unknown
return NextResponse.json({
count: null,
hash: 'unknown',
fullHash: null,
githubUrl: null
});
}
return NextResponse.json(getCurrentBuildInfo());
}