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
+169 -26
View File
@@ -38,6 +38,22 @@ export default function AdminPage() {
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(null);
const [updateStatus, setUpdateStatus] = useState<{
current: { version: string; commit: string | null; buildDate: string | null };
latest: { version: string; commit: string | null; buildDate: string | null } | null;
updateAvailable: boolean;
updater: {
available: boolean;
status: string;
message?: string;
lastStartedAt?: string | null;
lastFinishedAt?: string | null;
lastExitCode?: number | null;
lastError?: string | null;
};
} | null>(null);
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
const [triggeringUpdate, setTriggeringUpdate] = useState(false);
useEffect(() => {
fetch('/api/admin/me')
@@ -71,12 +87,42 @@ export default function AdminPage() {
}
};
const loadUpdateStatus = async () => {
setLoadingUpdateStatus(true);
try {
const res = await fetch('/api/admin/update', { cache: 'no-store' });
if (!res.ok) {
throw new Error('Failed to load update status');
}
const data = await res.json();
setUpdateStatus(data);
} catch {
setUpdateStatus(null);
} finally {
setLoadingUpdateStatus(false);
}
};
useEffect(() => {
if (isAdmin) {
loadNodeSettings();
loadUpdateStatus();
}
}, [isAdmin]);
useEffect(() => {
if (!isAdmin) {
return;
}
const interval = window.setInterval(() => {
loadUpdateStatus();
}, 30000);
return () => window.clearInterval(interval);
}, [isAdmin]);
const handleSaveSettings = async (override?: typeof nodeSettings) => {
const payload = override ?? nodeSettings;
setSavingSettings(true);
@@ -182,6 +228,25 @@ export default function AdminPage() {
setBannerPromptError('');
};
const handleTriggerUpdate = async () => {
setTriggeringUpdate(true);
try {
const res = await fetch('/api/admin/update', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Failed to start update');
}
showToast(data.message || 'Update started. Synapsis will restart shortly.', 'success');
await loadUpdateStatus();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Failed to start update', 'error');
} finally {
setTriggeringUpdate(false);
}
};
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
@@ -276,6 +341,13 @@ export default function AdminPage() {
);
}
const formatTimestamp = (value?: string | null) => {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
return (
<>
<header style={{
@@ -438,33 +510,19 @@ export default function AdminPage() {
</div>
{nodeSettings.bannerUrl && (
<div style={{ marginTop: '12px' }}>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
Sidebar preview
</div>
<div
className="card"
<img
src={nodeSettings.bannerUrl}
alt="Banner preview"
style={{
padding: 0,
overflow: 'hidden',
maxWidth: '360px',
width: '100%',
maxWidth: '520px',
maxHeight: '220px',
borderRadius: '12px',
border: '1px solid var(--border)',
objectFit: 'cover',
display: 'block',
}}
>
<div
style={{
height: '140px',
background: `url(${nodeSettings.bannerUrl}) center/cover no-repeat`,
borderBottom: '1px solid var(--border)',
}}
/>
<div style={{ padding: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>
Welcome to {nodeSettings.name || 'Your Node'}
</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
{nodeSettings.description || 'A brief tagline for your node.'}
</p>
</div>
</div>
/>
</div>
)}
</div>
@@ -604,7 +662,92 @@ export default function AdminPage() {
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
{savingSettings ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
<div style={{
padding: '16px',
background: 'var(--background-secondary)',
borderRadius: '8px',
border: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px', flexWrap: 'wrap' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '6px' }}>System Update</h2>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
Keep this node on the latest published Synapsis build.
</p>
</div>
<button
className="btn btn-primary"
onClick={handleTriggerUpdate}
disabled={
triggeringUpdate ||
loadingUpdateStatus ||
updateStatus?.updater.available === false ||
updateStatus?.updater.status === 'updating' ||
!updateStatus?.updateAvailable
}
>
{triggeringUpdate || updateStatus?.updater.status === 'updating' ? 'Updating...' : 'Update Now'}
</button>
</div>
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
<div>
<strong>Current build:</strong>{' '}
{updateStatus?.current?.version || 'Unknown'}
</div>
<div>
<strong>Latest build:</strong>{' '}
{updateStatus?.latest?.version || 'Unavailable'}
</div>
<div>
<strong>Status:</strong>{' '}
{loadingUpdateStatus ? 'Checking…' : updateStatus?.updater.message || 'Ready'}
</div>
{updateStatus?.updater.lastStartedAt && (
<div>
<strong>Last started:</strong>{' '}
{formatTimestamp(updateStatus.updater.lastStartedAt)}
</div>
)}
{updateStatus?.updater.lastFinishedAt && (
<div>
<strong>Last finished:</strong>{' '}
{formatTimestamp(updateStatus.updater.lastFinishedAt)}
</div>
)}
{typeof updateStatus?.updater.lastExitCode === 'number' && (
<div>
<strong>Last exit code:</strong>{' '}
{updateStatus.updater.lastExitCode}
</div>
)}
{updateStatus?.updater.lastError && (
<div style={{ color: 'var(--danger)' }}>
<strong>Last error:</strong>{' '}
{updateStatus.updater.lastError}
</div>
)}
</div>
{!updateStatus?.updater.available && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--background)',
fontSize: '12px',
color: 'var(--foreground-secondary)',
}}>
One-click updates are unavailable on this host. Use:
<div style={{ marginTop: '8px', fontFamily: 'monospace', color: 'var(--foreground)' }}>
curl -fsSL https://synapsis.social/update.sh | bash
</div>
</div>
)}
</div>
</div>
)}
+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());
}
+16 -5
View File
@@ -8,6 +8,7 @@ import { Post } from '@/lib/types';
import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot, Network, Server, EyeOff } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch';
interface User {
id: string;
@@ -81,7 +82,7 @@ interface SwarmPost {
}
export default function ExplorePage() {
const { user } = useAuth();
const { user, did, handle } = useAuth();
const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
const [nodePosts, setNodePosts] = useState<Post[]>([]);
@@ -268,8 +269,13 @@ export default function ExplorePage() {
};
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
const res = await fetch(`/api/posts/${postId}/like`, { method });
if (!did || !handle) {
throw new Error('Please log in again.');
}
const res = currentLiked
? await signedAPI.unlikePost(postId, did, handle)
: await signedAPI.likePost(postId, did, handle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -278,8 +284,13 @@ export default function ExplorePage() {
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
const method = currentReposted ? 'DELETE' : 'POST';
const res = await fetch(`/api/posts/${postId}/repost`, { method });
if (!did || !handle) {
throw new Error('Please log in again.');
}
const res = currentReposted
? await signedAPI.unrepostPost(postId, did, handle)
: await signedAPI.repostPost(postId, did, handle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
+17 -4
View File
@@ -7,6 +7,8 @@ import { useFormattedHandle } from '@/lib/utils/handle';
import { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types';
import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch';
interface User {
id: string;
@@ -132,6 +134,7 @@ export default function SearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const initialQuery = searchParams.get('q') || '';
const { did, handle } = useAuth();
const [query, setQuery] = useState(initialQuery);
const [users, setUsers] = useState<User[]>([]);
@@ -181,8 +184,13 @@ export default function SearchPage() {
};
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
const res = await fetch(`/api/posts/${postId}/like`, { method });
if (!did || !handle) {
throw new Error('Please log in again.');
}
const res = currentLiked
? await signedAPI.unlikePost(postId, did, handle)
: await signedAPI.likePost(postId, did, handle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -191,8 +199,13 @@ export default function SearchPage() {
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
const method = currentReposted ? 'DELETE' : 'POST';
const res = await fetch(`/api/posts/${postId}/repost`, { method });
if (!did || !handle) {
throw new Error('Please log in again.');
}
const res = currentReposted
? await signedAPI.unrepostPost(postId, did, handle)
: await signedAPI.repostPost(postId, did, handle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
+24 -7
View File
@@ -12,6 +12,7 @@ import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch';
interface BotOwner {
id: string;
@@ -81,7 +82,7 @@ export default function ProfilePage() {
const params = useParams();
const router = useRouter();
const handle = (params.handle as string)?.replace(/^@/, '') || '';
const { isIdentityUnlocked, signUserAction } = useAuth();
const { did, handle: currentHandle, isIdentityUnlocked, signUserAction } = useAuth();
const [user, setUser] = useState<User | null>(null);
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
@@ -207,8 +208,13 @@ export default function ProfilePage() {
}, [activeTab, postsCursor, repliesCursor, postsLoadingMore, repliesLoadingMore, loadMorePosts, loadMoreReplies]);
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
const res = await fetch(`/api/posts/${postId}/like`, { method });
if (!did || !currentHandle) {
throw new Error('Please log in again.');
}
const res = currentLiked
? await signedAPI.unlikePost(postId, did, currentHandle)
: await signedAPI.likePost(postId, did, currentHandle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -217,8 +223,13 @@ export default function ProfilePage() {
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
const method = currentReposted ? 'DELETE' : 'POST';
const res = await fetch(`/api/posts/${postId}/repost`, { method });
if (!did || !currentHandle) {
throw new Error('Please log in again.');
}
const res = currentReposted
? await signedAPI.unrepostPost(postId, did, currentHandle)
: await signedAPI.repostPost(postId, did, currentHandle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -322,8 +333,14 @@ export default function ProfilePage() {
return;
}
const method = isFollowing ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${handle}/follow`, { method });
if (!did || !currentHandle) {
alert('Session expired. Please log in again.');
return;
}
const res = isFollowing
? await signedAPI.unfollowUser(handle, did, currentHandle)
: await signedAPI.followUser(handle, did, currentHandle);
if (res.ok && user) {
setIsFollowing(!isFollowing);
+20 -19
View File
@@ -19,7 +19,12 @@ export function RightSidebar() {
bannerUrl: '',
admins: [] as Admin[],
});
const [version, setVersion] = useState<{ count: number | null; hash: string; fullHash: string | null; githubUrl: string | null } | null>(null);
const [version, setVersion] = useState<{
version: string;
commit: string | null;
buildDate: string | null;
githubUrl: string | null;
} | null>(null);
const [loading, setLoading] = useState(true);
@@ -45,7 +50,7 @@ export function RightSidebar() {
fetch('/api/version')
.then(res => res.json())
.then(data => setVersion(data))
.catch(() => setVersion({ count: null, hash: 'unknown', fullHash: null, githubUrl: null }));
.catch(() => setVersion({ version: 'unknown', commit: null, buildDate: null, githubUrl: null }));
}, []);
if (loading) {
@@ -115,25 +120,21 @@ export function RightSidebar() {
<div className="card" style={{ marginTop: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Network Info</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
Running <a href="https://synapsis.social" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Synapsis</a>
{version && version.count !== null && (
Running{' '}
<a href="https://synapsis.social" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Synapsis</a>
{version?.version ? ` ${version.version}` : ''}
{version?.githubUrl && version?.commit && (
<>
{' • '}
{version.githubUrl ? (
<a
href={version.githubUrl}
target="_blank"
rel="noopener noreferrer"
title={`${version.hash} (${version.fullHash})`}
style={{ color: 'var(--accent)' }}
>
Commit {version.count}
</a>
) : (
<span title={`${version.hash} (${version.fullHash})`}>
Commit {version.count}
</span>
)}
<a
href={version.githubUrl}
target="_blank"
rel="noopener noreferrer"
title={version.commit}
style={{ color: 'var(--accent)' }}
>
{version.commit.slice(0, 7)}
</a>
</>
)}
</p>
+111
View File
@@ -0,0 +1,111 @@
import http from 'http';
const DEFAULT_SOCKET_PATH = '/var/run/synapsis-updater/updater.sock';
export interface HostUpdaterStatus {
available: boolean;
status: 'unavailable' | 'idle' | 'updating' | 'success' | 'error';
message?: string;
lastStartedAt?: string | null;
lastFinishedAt?: string | null;
lastExitCode?: number | null;
lastError?: string | null;
pid?: number | null;
}
function getUpdaterConfig() {
const socketPath = process.env.HOST_UPDATER_SOCKET || DEFAULT_SOCKET_PATH;
const token = process.env.HOST_UPDATER_TOKEN || '';
return {
socketPath,
token,
enabled: Boolean(socketPath && token),
};
}
function requestUpdater<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
const { socketPath, token, enabled } = getUpdaterConfig();
if (!enabled) {
return Promise.reject(new Error('Host updater is not configured'));
}
const payload = body ? JSON.stringify(body) : undefined;
return new Promise((resolve, reject) => {
const request = http.request(
{
method,
socketPath,
path,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(payload
? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
}
: {}),
},
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
response.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const data = text ? JSON.parse(text) : {};
if ((response.statusCode || 500) >= 400) {
const message = data.error || `Updater request failed with ${response.statusCode}`;
reject(new Error(message));
return;
}
resolve(data as T);
});
}
);
request.on('error', (error) => {
reject(error);
});
if (payload) {
request.write(payload);
}
request.end();
});
}
export async function getHostUpdaterStatus(): Promise<HostUpdaterStatus> {
const config = getUpdaterConfig();
if (!config.enabled) {
return {
available: false,
status: 'unavailable',
message: 'Host updater is not configured for this install.',
};
}
try {
const status = await requestUpdater<HostUpdaterStatus>('GET', '/status');
return {
...status,
available: true,
};
} catch (error) {
return {
available: false,
status: 'unavailable',
message: error instanceof Error ? error.message : 'Host updater is unavailable.',
};
}
}
export async function triggerHostUpdate() {
return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update');
}
-3
View File
@@ -58,12 +58,9 @@ export interface SwarmLikePayload {
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
actorDid: string; // User's DID
actorPublicKey: string; // User's public key for verification
interactionId: string;
timestamp: string;
};
userSignature: string; // User's cryptographic signature
}
export interface SwarmUnlikePayload {
+241
View File
@@ -0,0 +1,241 @@
import packageJson from '../../package.json';
const DEFAULT_IMAGE_REPO = 'ghcr.io/gnosyslabs/synapsis';
const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
export interface BuildInfo {
version: string;
commit: string | null;
buildDate: string | null;
githubUrl: string | null;
imageDigest: string | null;
imageRepo: string;
sourceRepo: string;
}
type CachedLatest = {
expiresAt: number;
value: PublishedBuildInfo | null;
};
export interface PublishedBuildInfo extends BuildInfo {
tag: string;
}
let latestVersionCache: CachedLatest | null = null;
function normalizeImageRepo(value: string): string {
if (!value) {
return DEFAULT_IMAGE_REPO;
}
return value.startsWith('ghcr.io/') ? value : `ghcr.io/${value}`;
}
function imageRepoPath(value: string): string {
return normalizeImageRepo(value).replace(/^ghcr\.io\//, '');
}
function buildGithubUrl(sourceRepo: string, commit: string | null): string | null {
if (!commit || !sourceRepo) {
return null;
}
return `${sourceRepo.replace(/\/$/, '')}/commit/${commit}`;
}
export function getCurrentBuildInfo(): BuildInfo {
const sourceRepo = process.env.APP_SOURCE_REPO || 'https://github.com/GnosysLabs/Synapsis';
const version = process.env.APP_VERSION || packageJson.version || 'dev';
const commit = process.env.APP_COMMIT || null;
const buildDate = process.env.APP_BUILD_DATE || null;
const imageDigest = process.env.APP_IMAGE_DIGEST || null;
const imageRepo = normalizeImageRepo(process.env.APP_IMAGE_REPO || DEFAULT_IMAGE_REPO);
const githubUrl = process.env.APP_GITHUB_URL || buildGithubUrl(sourceRepo, commit);
return {
version,
commit,
buildDate,
githubUrl,
imageDigest,
imageRepo,
sourceRepo,
};
}
export function parseVersionTuple(version: string): [number, number, number, number] | null {
const match = /^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/.exec(version);
if (!match) {
return null;
}
return [
Number(match[1]),
Number(match[2]),
Number(match[3]),
Number(match[4]),
];
}
export function compareBuildVersions(a: string, b: string): number {
const aTuple = parseVersionTuple(a);
const bTuple = parseVersionTuple(b);
if (!aTuple && !bTuple) {
return a.localeCompare(b);
}
if (!aTuple) {
return -1;
}
if (!bTuple) {
return 1;
}
for (let i = 0; i < aTuple.length; i += 1) {
if (aTuple[i] !== bTuple[i]) {
return aTuple[i] - bTuple[i];
}
}
return 0;
}
async function fetchGhcrToken(repoPath: string): Promise<string | null> {
const tokenResponse = await fetch(`https://ghcr.io/token?scope=repository:${repoPath}:pull`, {
cache: 'no-store',
});
if (!tokenResponse.ok) {
return null;
}
const tokenData = await tokenResponse.json();
return tokenData.token || null;
}
async function fetchRegistryJson(
repoPath: string,
reference: string,
accept: string,
token: string
) {
const response = await fetch(`https://ghcr.io/v2/${repoPath}/manifests/${reference}`, {
headers: {
Accept: accept,
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`Registry request failed with ${response.status}`);
}
return {
digest: response.headers.get('docker-content-digest'),
body: await response.json(),
};
}
async function fetchRegistryBlob(repoPath: string, digest: string, token: string) {
const response = await fetch(`https://ghcr.io/v2/${repoPath}/blobs/${digest}`, {
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`Registry blob request failed with ${response.status}`);
}
return response.json();
}
async function loadLatestPublishedBuild(): Promise<PublishedBuildInfo | null> {
const current = getCurrentBuildInfo();
const repoPath = imageRepoPath(current.imageRepo);
const token = await fetchGhcrToken(repoPath);
if (!token) {
return null;
}
const manifestAccept = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', ');
const latestManifest = await fetchRegistryJson(repoPath, 'latest', manifestAccept, token);
const latestBody = latestManifest.body;
let configDigest: string | undefined;
if (Array.isArray(latestBody.manifests)) {
const platformManifest =
latestBody.manifests.find(
(manifest: any) =>
manifest.platform?.os === 'linux' && manifest.platform?.architecture === 'amd64'
) ||
latestBody.manifests.find((manifest: any) => manifest.platform?.os && manifest.platform?.architecture);
if (!platformManifest?.digest) {
return null;
}
const platformManifestResponse = await fetchRegistryJson(repoPath, platformManifest.digest, manifestAccept, token);
configDigest = platformManifestResponse.body?.config?.digest;
} else {
configDigest = latestBody?.config?.digest;
}
if (!configDigest) {
return null;
}
const configBlob = await fetchRegistryBlob(repoPath, configDigest, token);
const labels = configBlob?.config?.Labels || {};
const sourceRepo = labels['org.opencontainers.image.source'] || current.sourceRepo;
const commit = labels['org.opencontainers.image.revision'] || null;
const version = labels['org.opencontainers.image.version'] || null;
if (!version) {
return null;
}
return {
tag: 'latest',
version,
commit,
buildDate: labels['org.opencontainers.image.created'] || null,
githubUrl: buildGithubUrl(sourceRepo, commit),
imageDigest: latestManifest.digest || null,
imageRepo: current.imageRepo,
sourceRepo,
};
}
export async function getLatestPublishedBuild(): Promise<PublishedBuildInfo | null> {
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
return latestVersionCache.value;
}
try {
const latest = await loadLatestPublishedBuild();
latestVersionCache = {
expiresAt: Date.now() + VERSION_CACHE_TTL_MS,
value: latest,
};
return latest;
} catch (error) {
console.error('[Version] Failed to fetch latest published build:', error);
latestVersionCache = {
expiresAt: Date.now() + VERSION_CACHE_TTL_MS,
value: null,
};
return null;
}
}