Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs
Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0 Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908 Hop-Task: T_06FPAWA3279RBMJAR0BHM10 Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
* Handles node discovery and announcement in the swarm network.
|
||||
*/
|
||||
|
||||
import { db, nodes, users, posts } from '@/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db, users, posts } from '@/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
||||
import { getCurrentBuildInfo } from '@/lib/version';
|
||||
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
||||
@@ -55,7 +55,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
|
||||
postCount = Number(postResult[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions'];
|
||||
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions', 'e2ee_dm_v1'];
|
||||
|
||||
return {
|
||||
domain,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { getActiveSwarmNodes } from './registry';
|
||||
import type { SwarmNodeInfo } from './types';
|
||||
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
import { getPublicSwarmDomain } from './node-domain';
|
||||
import { safeFederationRequest } from './safe-federation-http';
|
||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||
|
||||
// ============================================
|
||||
@@ -390,6 +392,49 @@ export interface SwarmProfileResponse {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const DEVELOPMENT_LOOPBACK_DOMAIN =
|
||||
/^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSwarmUserPost(value: unknown): value is SwarmUserPost {
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.content === 'string' &&
|
||||
typeof value.createdAt === 'string' &&
|
||||
typeof value.isNsfw === 'boolean' &&
|
||||
typeof value.likesCount === 'number' &&
|
||||
typeof value.repostsCount === 'number' &&
|
||||
typeof value.repliesCount === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isSwarmProfileResponse(value: unknown): value is SwarmProfileResponse {
|
||||
if (!isRecord(value) || !isRecord(value.profile) || !Array.isArray(value.posts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const profile = value.profile;
|
||||
return (
|
||||
typeof profile.handle === 'string' &&
|
||||
typeof profile.displayName === 'string' &&
|
||||
typeof profile.followersCount === 'number' &&
|
||||
typeof profile.followingCount === 'number' &&
|
||||
typeof profile.postsCount === 'number' &&
|
||||
typeof profile.createdAt === 'string' &&
|
||||
typeof profile.isNsfw === 'boolean' &&
|
||||
typeof profile.nodeIsNsfw === 'boolean' &&
|
||||
typeof profile.nodeDomain === 'string' &&
|
||||
typeof value.nodeDomain === 'string' &&
|
||||
typeof value.timestamp === 'string' &&
|
||||
value.posts.every(isSwarmUserPost)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user profile from a swarm node
|
||||
*/
|
||||
@@ -401,33 +446,51 @@ export async function fetchSwarmUserProfile(
|
||||
): Promise<SwarmProfileResponse | null> {
|
||||
try {
|
||||
const normalizedDomain = normalizeNodeDomain(domain);
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
const publicDomain = getPublicSwarmDomain(normalizedDomain);
|
||||
const developmentDomain =
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
DEVELOPMENT_LOOPBACK_DOMAIN.test(normalizedDomain)
|
||||
? normalizedDomain
|
||||
: null;
|
||||
const targetDomain = publicDomain ?? developmentDomain;
|
||||
const cleanHandle = handle.trim().replace(/^@/, '').toLowerCase();
|
||||
|
||||
if (
|
||||
!targetDomain ||
|
||||
!/^[a-z0-9_]{1,64}$/.test(cleanHandle) ||
|
||||
(await isNodeBlocked(targetDomain))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = domain.startsWith('http')
|
||||
? domain
|
||||
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
||||
? `http://${normalizedDomain}`
|
||||
: `https://${normalizedDomain}`;
|
||||
const baseUrl = developmentDomain
|
||||
? `http://${targetDomain}`
|
||||
: `https://${targetDomain}`;
|
||||
const url = new URL(`/api/swarm/users/${encodeURIComponent(cleanHandle)}`, baseUrl);
|
||||
url.searchParams.set(
|
||||
'limit',
|
||||
String(Number.isSafeInteger(postsLimit) ? Math.min(Math.max(postsLimit, 0), 50) : 25)
|
||||
);
|
||||
if (cursor) url.searchParams.set('cursor', cursor.slice(0, 128));
|
||||
|
||||
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFederationRequest(url.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal,
|
||||
maxResponseBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const payload = response.json();
|
||||
if (
|
||||
!isSwarmProfileResponse(payload) ||
|
||||
payload.profile.handle.toLowerCase() !== cleanHandle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error(`[Swarm] Failed to fetch profile for ${handle}@${domain}:`, error);
|
||||
return null;
|
||||
@@ -452,7 +515,6 @@ export async function cacheSwarmUserPosts(
|
||||
}
|
||||
|
||||
const { db, remotePosts } = await import('@/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
if (!db) {
|
||||
return { cached: 0, skipped: 0 };
|
||||
@@ -672,8 +734,7 @@ export async function deliverSwarmPost(
|
||||
*/
|
||||
export async function getSwarmFollowerDomains(userId: string): Promise<string[]> {
|
||||
try {
|
||||
const { db, remoteFollowers } = await import('@/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const { db } = await import('@/db');
|
||||
|
||||
if (!db) return [];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
E2EE_FEDERATION_MAX_REQUEST_BYTES,
|
||||
SafeFederationError,
|
||||
createSafeFederationRequester,
|
||||
isPublicFederationAddress,
|
||||
@@ -200,6 +201,17 @@ describe('E2EE federation URL and DNS policy', () => {
|
||||
});
|
||||
|
||||
describe('E2EE federation HTTP behavior', () => {
|
||||
it('rejects an oversized request body before DNS or network activity', async () => {
|
||||
const dnsResolver = vi.fn(async () => [{ address: '8.8.8.8', family: 4 as const }]);
|
||||
const request = createSafeFederationRequester({ development: false, dnsResolver });
|
||||
|
||||
await expect(request('https://node.synapsis.social/api/chat/receive', {
|
||||
method: 'POST',
|
||||
body: 'x'.repeat(E2EE_FEDERATION_MAX_REQUEST_BYTES + 1),
|
||||
})).rejects.toSatisfy(expectCode('REQUEST_TOO_LARGE'));
|
||||
expect(dnsResolver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports GET, bounded buffering, UTF-8 text, and JSON parsing', async () => {
|
||||
const server = await startServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||
@@ -338,5 +350,11 @@ describe('E2EE federation HTTP behavior', () => {
|
||||
await expect(
|
||||
request('http://127.0.0.1:1/', { headers: { 'content-length': '999' } })
|
||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||
await expect(
|
||||
request('http://127.0.0.1:1/', { headers: { authorization: 'Bearer secret' } })
|
||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||
await expect(
|
||||
request('http://127.0.0.1:1/', { headers: { cookie: 'session=secret' } })
|
||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,14 @@ const EXACT_DEVELOPMENT_LOOPBACK_URL =
|
||||
/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?(?:[/?#]|$)/i;
|
||||
const FORBIDDEN_REQUEST_HEADERS = new Set([
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'connection',
|
||||
'content-length',
|
||||
'cookie',
|
||||
'expect',
|
||||
'host',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
|
||||
+91
-29
@@ -10,13 +10,41 @@ import crypto from 'crypto';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||
import { isNodeBlocked } from './node-blocklist';
|
||||
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||
import { getPublicSwarmDomain } from './node-domain';
|
||||
import { safeFederationRequest } from './safe-federation-http';
|
||||
|
||||
const NODE_PUBLIC_KEY_CACHE_TTL_MS = 60_000;
|
||||
const MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES = 1_000;
|
||||
const nodePublicKeyCache = new Map<string, { publicKey: string; expiresAt: number }>();
|
||||
const pendingNodePublicKeyRequests = new Map<string, Promise<string | null>>();
|
||||
|
||||
function cacheNodePublicKey(domain: string, publicKey: string): void {
|
||||
if (!nodePublicKeyCache.has(domain)
|
||||
&& nodePublicKeyCache.size >= MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES) {
|
||||
const oldest = nodePublicKeyCache.keys().next().value as string | undefined;
|
||||
if (oldest) nodePublicKeyCache.delete(oldest);
|
||||
}
|
||||
nodePublicKeyCache.set(domain, {
|
||||
publicKey,
|
||||
expiresAt: Date.now() + NODE_PUBLIC_KEY_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFederationDomain(domain: string): { domain: string; protocol: 'http' | 'https' } | null {
|
||||
const normalized = normalizeNodeDomain(domain);
|
||||
const developmentLoopback = process.env.NODE_ENV === 'development'
|
||||
&& /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i.test(normalized);
|
||||
if (developmentLoopback) return { domain: normalized, protocol: 'http' };
|
||||
|
||||
const publicDomain = getPublicSwarmDomain(normalized);
|
||||
return publicDomain ? { domain: publicDomain, protocol: 'https' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a payload with the node's private key
|
||||
*/
|
||||
export function signPayload(payload: any, privateKey: string): string {
|
||||
export function signPayload(payload: unknown, privateKey: string): string {
|
||||
const canonicalPayload = canonicalize(payload);
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update(canonicalPayload);
|
||||
@@ -40,7 +68,7 @@ function normalizePublicKey(publicKey: string): crypto.KeyObject | string {
|
||||
/**
|
||||
* Verify a signature using the sender's public key
|
||||
*/
|
||||
export function verifySignature(payload: any, signature: string, publicKey: string): boolean {
|
||||
export function verifySignature(payload: unknown, signature: string, publicKey: string): boolean {
|
||||
try {
|
||||
const canonicalPayload = canonicalize(payload);
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
@@ -58,29 +86,51 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
|
||||
*/
|
||||
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedDomain = getPublicSwarmDomain(domain);
|
||||
if (!normalizedDomain) {
|
||||
const target = resolveFederationDomain(domain);
|
||||
if (!target) {
|
||||
console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`);
|
||||
return null;
|
||||
}
|
||||
const normalizedDomain = target.domain;
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have a cached node info
|
||||
const response = await fetch(`https://${normalizedDomain}/api/node`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
||||
return null;
|
||||
const cached = nodePublicKeyCache.get(normalizedDomain);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
nodePublicKeyCache.delete(normalizedDomain);
|
||||
nodePublicKeyCache.set(normalizedDomain, cached);
|
||||
return cached.publicKey;
|
||||
}
|
||||
if (cached) nodePublicKeyCache.delete(normalizedDomain);
|
||||
|
||||
const data = await response.json();
|
||||
return data.publicKey || null;
|
||||
const pending = pendingNodePublicKeyRequests.get(normalizedDomain);
|
||||
if (pending) return await pending;
|
||||
|
||||
const keyRequest = (async (): Promise<string | null> => {
|
||||
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
timeoutMs: 5_000,
|
||||
maxResponseBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = response.json() as { publicKey?: unknown };
|
||||
const publicKey = typeof data.publicKey === 'string' ? data.publicKey : null;
|
||||
if (publicKey) cacheNodePublicKey(normalizedDomain, publicKey);
|
||||
return publicKey;
|
||||
})();
|
||||
pendingNodePublicKeyRequests.set(normalizedDomain, keyRequest);
|
||||
try {
|
||||
return await keyRequest;
|
||||
} finally {
|
||||
pendingNodePublicKeyRequests.delete(normalizedDomain);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Signature] Error fetching public key from ${domain}:`, error);
|
||||
return null;
|
||||
@@ -96,15 +146,16 @@ export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export async function verifySwarmRequest(
|
||||
payload: any,
|
||||
payload: unknown,
|
||||
signature: string,
|
||||
senderDomain: string
|
||||
): Promise<boolean> {
|
||||
const normalizedDomain = getPublicSwarmDomain(senderDomain);
|
||||
if (!normalizedDomain) {
|
||||
const target = resolveFederationDomain(senderDomain);
|
||||
if (!target) {
|
||||
console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`);
|
||||
return false;
|
||||
}
|
||||
const normalizedDomain = target.domain;
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
@@ -134,17 +185,18 @@ export async function verifySwarmRequest(
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export async function verifyUserInteraction(
|
||||
payload: any,
|
||||
payload: unknown,
|
||||
signature: string,
|
||||
userHandle: string,
|
||||
userDomain: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const normalizedDomain = getPublicSwarmDomain(userDomain);
|
||||
if (!normalizedDomain) {
|
||||
const target = resolveFederationDomain(userDomain);
|
||||
if (!target) {
|
||||
console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`);
|
||||
return false;
|
||||
}
|
||||
const normalizedDomain = target.domain;
|
||||
if (await isNodeBlocked(normalizedDomain)) {
|
||||
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
@@ -152,7 +204,7 @@ export async function verifyUserInteraction(
|
||||
|
||||
// Try to get cached user
|
||||
const fullHandle = `${userHandle}@${normalizedDomain}`;
|
||||
let user = await db?.query.users.findFirst({
|
||||
const user = await db?.query.users.findFirst({
|
||||
where: { handle: fullHandle },
|
||||
});
|
||||
|
||||
@@ -162,18 +214,28 @@ export async function verifyUserInteraction(
|
||||
publicKey = user.publicKey;
|
||||
} else {
|
||||
// Fetch from remote node
|
||||
const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, {
|
||||
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/users/${encodeURIComponent(userHandle)}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
timeoutMs: 5_000,
|
||||
maxResponseBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
publicKey = userData.user?.publicKey || userData.publicKey;
|
||||
const userData = response.json() as {
|
||||
publicKey?: unknown;
|
||||
user?: {
|
||||
avatarUrl?: string | null;
|
||||
did?: string;
|
||||
displayName?: string | null;
|
||||
publicKey?: unknown;
|
||||
};
|
||||
};
|
||||
const resolvedPublicKey = userData.user?.publicKey || userData.publicKey;
|
||||
publicKey = typeof resolvedPublicKey === 'string' ? resolvedPublicKey : null;
|
||||
|
||||
// Cache the user if we don't have them
|
||||
if (!user && publicKey && db) {
|
||||
@@ -217,7 +279,7 @@ export async function getNodePrivateKey(): Promise<string> {
|
||||
/**
|
||||
* Create a signed payload for sending to another node
|
||||
*/
|
||||
export async function createSignedPayload(payload: any): Promise<{ payload: any; signature: string }> {
|
||||
export async function createSignedPayload<T>(payload: T): Promise<{ payload: T; signature: string }> {
|
||||
const privateKey = await getNodePrivateKey();
|
||||
const signature = signPayload(payload, privateKey);
|
||||
return { payload, signature };
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SwarmNodeInfo {
|
||||
lastSeenAt?: string;
|
||||
}
|
||||
|
||||
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions';
|
||||
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions' | 'e2ee_dm_v1';
|
||||
|
||||
export interface SwarmAnnouncement {
|
||||
domain: string;
|
||||
|
||||
+34
-10
@@ -1,5 +1,6 @@
|
||||
import { db, users } from '@/db';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface RemoteProfile {
|
||||
handle: string;
|
||||
@@ -10,6 +11,14 @@ export interface RemoteProfile {
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
function signingKeysEqual(left: string, right: string): boolean {
|
||||
const normalizedLeft = normalizeSigningPublicKey(left);
|
||||
const normalizedRight = normalizeSigningPublicKey(right);
|
||||
return normalizedLeft && normalizedRight
|
||||
? normalizedLeft === normalizedRight
|
||||
: left === right;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a remote user into the local database for caching/display purposes.
|
||||
*
|
||||
@@ -19,20 +28,34 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: { OR: [{ did: profile.did }, { handle: profile.handle }] },
|
||||
});
|
||||
if (!profile.handle.includes('@')) {
|
||||
throw new Error('Remote user cache requires a fully qualified handle');
|
||||
}
|
||||
|
||||
const [byDid, byHandle] = await Promise.all([
|
||||
db.query.users.findFirst({ where: { did: profile.did } }),
|
||||
db.query.users.findFirst({ where: { handle: profile.handle } }),
|
||||
]);
|
||||
if (byDid && byHandle && byDid.id !== byHandle.id) {
|
||||
throw new Error('Remote user identity conflicts with the existing cache');
|
||||
}
|
||||
const existing = byDid || byHandle;
|
||||
|
||||
if (existing) {
|
||||
// Update metadata if changed
|
||||
// Self-healing: Update public key if missing
|
||||
if (!existing.handle.includes('@') && !existing.id.startsWith('swarm:')) {
|
||||
throw new Error('Federation cannot modify a local user');
|
||||
}
|
||||
if (existing.did !== profile.did || existing.handle !== profile.handle) {
|
||||
throw new Error('Remote user DID or handle changed unexpectedly');
|
||||
}
|
||||
if (profile.publicKey && existing.publicKey
|
||||
&& !signingKeysEqual(profile.publicKey, existing.publicKey)) {
|
||||
throw new Error('Remote user signing key changed unexpectedly');
|
||||
}
|
||||
const shouldUpdateKey = profile.publicKey && !existing.publicKey;
|
||||
|
||||
await db.update(users)
|
||||
.set({
|
||||
did: existing.did || profile.did,
|
||||
handle: existing.handle || profile.handle,
|
||||
displayName: profile.displayName || existing.displayName,
|
||||
avatarUrl: profile.avatarUrl || existing.avatarUrl,
|
||||
isBot: profile.isBot ?? existing.isBot,
|
||||
@@ -41,6 +64,7 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
||||
})
|
||||
.where(eq(users.id, existing.id));
|
||||
} else {
|
||||
if (!profile.publicKey) throw new Error('Remote user signing key is required');
|
||||
// Create new placeholder user
|
||||
await db.insert(users).values({
|
||||
did: profile.did,
|
||||
@@ -48,7 +72,7 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
isBot: profile.isBot || false,
|
||||
publicKey: profile.publicKey || '', // Cache provided key or default to empty
|
||||
publicKey: profile.publicKey,
|
||||
// Note: nodeId is null for remote placeholders unless we specifically link it
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user