Normalize same-node qualified handles across profile and user interaction routes while preserving remote federation.

Hop-State: A_06FPF70QSGVCEXKW49YZXJ0
Hop-Proposal: R_06FPF6ZE99FWX3DJRDCC6JR
Hop-Task: T_06FPF593K79EBFYJ4X58SQG
Hop-Attempt: AT_06FPF593K46XZT11Q9CC86G
This commit is contained in:
2026-07-15 14:08:30 -07:00
committed by Hop
parent e040d6165d
commit bd7fbf004e
12 changed files with 307 additions and 63 deletions
+7 -3
View File
@@ -3,6 +3,7 @@ import { db } from '@/db';
import { users, blocks, follows } from '@/db/schema'; import { users, blocks, follows } from '@/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -11,9 +12,10 @@ export async function GET(req: NextRequest, context: RouteContext) {
try { try {
const currentUser = await requireAuth(); const currentUser = await requireAuth();
const { handle } = await context.params; const { handle } = await context.params;
const targetHandle = resolveUserHandle(handle).canonicalHandle;
const targetUser = await db.query.users.findFirst({ const targetUser = await db.query.users.findFirst({
where: { handle: handle }, where: { handle: targetHandle },
}); });
if (!targetUser) { if (!targetUser) {
@@ -39,9 +41,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
try { try {
const currentUser = await requireAuth(); const currentUser = await requireAuth();
const { handle } = await context.params; const { handle } = await context.params;
const targetHandle = resolveUserHandle(handle).canonicalHandle;
const targetUser = await db.query.users.findFirst({ const targetUser = await db.query.users.findFirst({
where: { handle: handle }, where: { handle: targetHandle },
}); });
if (!targetUser) { if (!targetUser) {
@@ -96,9 +99,10 @@ export async function DELETE(req: NextRequest, context: RouteContext) {
try { try {
const currentUser = await requireAuth(); const currentUser = await requireAuth();
const { handle } = await context.params; const { handle } = await context.params;
const targetHandle = resolveUserHandle(handle).canonicalHandle;
const targetUser = await db.query.users.findFirst({ const targetUser = await db.query.users.findFirst({
where: { handle: handle }, where: { handle: targetHandle },
}); });
if (!targetUser) { if (!targetUser) {
+10 -15
View File
@@ -7,25 +7,18 @@ import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions'; import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery'; import { discoverNode } from '@/lib/swarm/discovery';
import { buildNotificationTarget } from '@/lib/notifications'; import { buildNotificationTarget } from '@/lib/notifications';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
const parseRemoteHandle = (handle: string) => {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean);
if (parts.length === 2) {
return { handle: parts[0], domain: parts[1] };
}
return null;
};
// Check follow status // Check follow status
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const currentUser = await requireAuth(); const currentUser = await requireAuth();
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const remote = parseRemoteHandle(handle); const cleanHandle = resolvedHandle.canonicalHandle;
const remote = resolvedHandle.remote;
if (currentUser.isSuspended || currentUser.isSilenced) { if (currentUser.isSuspended || currentUser.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -88,8 +81,9 @@ export async function POST(request: Request, context: RouteContext) {
// Let's assume the client sends targetHandle in 'data' of signed action to be secure. // Let's assume the client sends targetHandle in 'data' of signed action to be secure.
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const remote = parseRemoteHandle(handle); const cleanHandle = resolvedHandle.canonicalHandle;
const remote = resolvedHandle.remote;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (currentUser.isSuspended || currentUser.isSilenced) { if (currentUser.isSuspended || currentUser.isSilenced) {
@@ -254,8 +248,9 @@ export async function DELETE(request: Request, context: RouteContext) {
const currentUser = await requireSignedAction(signedAction); const currentUser = await requireSignedAction(signedAction);
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const remote = parseRemoteHandle(handle); const cleanHandle = resolvedHandle.canonicalHandle;
const remote = resolvedHandle.remote;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (remote) { if (remote) {
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, follows, users, remoteFollowers } from '@/db'; import { db, follows, users, remoteFollowers } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration'; import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -26,21 +27,22 @@ const fetchSwarmFollowers = async (handle: string, domain: string, limit: number
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const cleanHandle = resolvedHandle.canonicalHandle;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
// Check if this is a remote user // Check if this is a remote user
const [remoteHandle, remoteDomain] = cleanHandle.split('@'); const remote = resolvedHandle.remote;
if (remoteDomain) { if (remote) {
// Fetch from remote swarm node // Fetch from remote swarm node
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit); const swarmData = await fetchSwarmFollowers(remote.handle, remote.domain, limit);
if (swarmData?.followers) { if (swarmData?.followers) {
// Transform to include full handles for local users on that node // Transform to include full handles for local users on that node
const followers = swarmData.followers.map((f: any) => ({ const followers = swarmData.followers.map((f: any) => ({
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
displayName: f.displayName, displayName: f.displayName,
avatarUrl: f.avatarUrl, avatarUrl: f.avatarUrl,
bio: f.bio, bio: f.bio,
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, follows, users, remoteFollows } from '@/db'; import { db, follows, users, remoteFollows } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration'; import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -26,21 +27,22 @@ const fetchSwarmFollowing = async (handle: string, domain: string, limit: number
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const cleanHandle = resolvedHandle.canonicalHandle;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
// Check if this is a remote user // Check if this is a remote user
const [remoteHandle, remoteDomain] = cleanHandle.split('@'); const remote = resolvedHandle.remote;
if (remoteDomain) { if (remote) {
// Fetch from remote swarm node // Fetch from remote swarm node
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit); const swarmData = await fetchSwarmFollowing(remote.handle, remote.domain, limit);
if (swarmData?.following) { if (swarmData?.following) {
// Transform to include full handles for local users on that node // Transform to include full handles for local users on that node
const following = swarmData.following.map((f: any) => ({ const following = swarmData.following.map((f: any) => ({
id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`,
displayName: f.displayName, displayName: f.displayName,
avatarUrl: f.avatarUrl, avatarUrl: f.avatarUrl,
bio: f.bio, bio: f.bio,
+6 -4
View File
@@ -3,7 +3,8 @@ import { db, likes, posts, users, userSwarmLikes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm'; import { eq, desc, and, inArray } from 'drizzle-orm';
import { discoverNode } from '@/lib/swarm/discovery'; import { discoverNode } from '@/lib/swarm/discovery';
import { isSwarmNode } from '@/lib/swarm/interactions'; import { isSwarmNode } from '@/lib/swarm/interactions';
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts'; import { getRemoteBaseUrl, mapRemoteProfilePost } from '@/lib/swarm/remote-profile-posts';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
@@ -44,10 +45,11 @@ const parseMediaJson = (mediaJson: string | null) => {
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const cleanHandle = resolvedHandle.canonicalHandle;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const remote = parseRemoteHandle(handle); const remote = resolvedHandle.remote;
const fetchRemoteLikesRoute = async () => { const fetchRemoteLikesRoute = async () => {
if (!remote) { if (!remote) {
@@ -110,7 +112,7 @@ export async function GET(request: Request, context: RouteContext) {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: { handle: cleanHandle }, where: { handle: cleanHandle },
}); });
const isRemotePlaceholder = user && cleanHandle.includes('@'); const isRemotePlaceholder = Boolean(user && remote);
if (!user || isRemotePlaceholder) { if (!user || isRemotePlaceholder) {
if (!remote) { if (!remote) {
+6 -4
View File
@@ -4,7 +4,8 @@ import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery'; import { discoverNode } from '@/lib/swarm/discovery';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts'; import { getRemoteBaseUrl, mapRemoteProfilePost } from '@/lib/swarm/remote-profile-posts';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
const embeddedPostRelations = { const embeddedPostRelations = {
@@ -213,12 +214,13 @@ async function populateViewerLikeState(
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const cleanHandle = resolvedHandle.canonicalHandle;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const cursor = searchParams.get('cursor'); const cursor = searchParams.get('cursor');
const remote = parseRemoteHandle(handle); const remote = resolvedHandle.remote;
const fetchRemotePostsRoute = async () => { const fetchRemotePostsRoute = async () => {
if (!remote) { if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null }); return NextResponse.json({ posts: [], nextCursor: null });
@@ -303,7 +305,7 @@ export async function GET(request: Request, context: RouteContext) {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: { handle: cleanHandle }, where: { handle: cleanHandle },
}); });
const isRemotePlaceholder = user && cleanHandle.includes('@'); const isRemotePlaceholder = Boolean(user && remote);
if (!user || isRemotePlaceholder) { if (!user || isRemotePlaceholder) {
if (!remote) { if (!remote) {
+6 -4
View File
@@ -2,9 +2,10 @@ import { NextResponse } from 'next/server';
import { db, likes, posts, users } from '@/db'; import { db, likes, posts, users } from '@/db';
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm'; import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
import { discoverNode } from '@/lib/swarm/discovery'; import { discoverNode } from '@/lib/swarm/discovery';
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts'; import { getRemoteBaseUrl, mapRemoteProfilePost } from '@/lib/swarm/remote-profile-posts';
import { isSwarmNode } from '@/lib/swarm/interactions'; import { isSwarmNode } from '@/lib/swarm/interactions';
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
const embeddedPostRelations = { const embeddedPostRelations = {
author: true, author: true,
@@ -31,11 +32,12 @@ type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const cleanHandle = resolvedHandle.canonicalHandle;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const cursor = searchParams.get('cursor'); const cursor = searchParams.get('cursor');
const remote = parseRemoteHandle(handle); const remote = resolvedHandle.remote;
const fetchRemoteReplies = async () => { const fetchRemoteReplies = async () => {
if (!remote) { if (!remote) {
@@ -97,7 +99,7 @@ export async function GET(request: Request, context: RouteContext) {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: { handle: cleanHandle }, where: { handle: cleanHandle },
}); });
const isRemotePlaceholder = user && cleanHandle.includes('@'); const isRemotePlaceholder = Boolean(user && remote);
if (!user || isRemotePlaceholder) { if (!user || isRemotePlaceholder) {
if (!remote) { if (!remote) {
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
findUser: vi.fn(),
fetchSwarmUserProfile: vi.fn(),
isSwarmNode: vi.fn(),
discoverNode: vi.fn(),
}));
vi.mock('@/db', () => ({
db: {
query: {
users: {
findFirst: mocks.findUser,
},
},
},
users: {},
follows: {},
}));
vi.mock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}));
vi.mock('@/lib/swarm/interactions', () => ({
fetchSwarmUserProfile: mocks.fetchSwarmUserProfile,
isSwarmNode: mocks.isSwarmNode,
}));
vi.mock('@/lib/swarm/discovery', () => ({
discoverNode: mocks.discoverNode,
}));
import { GET } from './route';
const localUser = {
id: 'user-1',
handle: 'wpb8erboy',
displayName: 'Wpb8erboy',
bio: null,
avatarUrl: null,
headerUrl: null,
followersCount: 1,
followingCount: 2,
postsCount: 1,
createdAt: new Date('2026-07-15T19:32:12Z'),
website: null,
movedTo: null,
isBot: false,
publicKey: 'public-key',
did: 'did:key:local-user',
dmPrivacy: 'everyone',
isNsfw: true,
isSuspended: false,
botOwnerId: null,
};
describe('user profile route', () => {
const previousDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
beforeEach(() => {
process.env.NEXT_PUBLIC_NODE_DOMAIN = 'rprh.link';
mocks.findUser.mockReset().mockResolvedValue(localUser);
mocks.fetchSwarmUserProfile.mockReset();
mocks.isSwarmNode.mockReset();
mocks.discoverNode.mockReset();
});
afterEach(() => {
if (previousDomain === undefined) {
delete process.env.NEXT_PUBLIC_NODE_DOMAIN;
} else {
process.env.NEXT_PUBLIC_NODE_DOMAIN = previousDomain;
}
});
it('resolves a same-node qualified handle as the local user', async () => {
const response = await GET(
new Request('https://rprh.link/api/users/wpb8erboy%40rprh.link'),
{ params: Promise.resolve({ handle: 'wpb8erboy@rprh.link' }) },
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
user: {
id: 'user-1',
handle: 'wpb8erboy',
},
});
expect(mocks.findUser).toHaveBeenCalledWith({ where: { handle: 'wpb8erboy' } });
expect(mocks.isSwarmNode).not.toHaveBeenCalled();
expect(mocks.discoverNode).not.toHaveBeenCalled();
expect(mocks.fetchSwarmUserProfile).not.toHaveBeenCalled();
});
});
+18 -13
View File
@@ -4,14 +4,16 @@ import { getSession } from '@/lib/auth';
import { db, users, follows } from '@/db'; import { db, users, follows } from '@/db';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery'; import { discoverNode } from '@/lib/swarm/discovery';
import { resolveUserHandle } from '@/lib/swarm/user-handle';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const resolvedHandle = resolveUserHandle(handle);
const [remoteHandle, remoteDomain] = cleanHandle.split('@'); const cleanHandle = resolvedHandle.canonicalHandle;
const remote = resolvedHandle.remote;
// Return mock user if no database // Return mock user if no database
if (!db) { if (!db) {
@@ -36,26 +38,26 @@ export async function GET(request: Request, context: RouteContext) {
}); });
// If user exists but is a remote placeholder (handle contains @), fetch fresh data from remote // If user exists but is a remote placeholder (handle contains @), fetch fresh data from remote
const isRemotePlaceholder = user && cleanHandle.includes('@'); const isRemotePlaceholder = Boolean(user && remote);
if (!user || isRemotePlaceholder) { if (!user || isRemotePlaceholder) {
if (remoteHandle && remoteDomain) { if (remote) {
// Only fetch from swarm nodes // Only fetch from swarm nodes
let isSwarm = await isSwarmNode(remoteDomain); let isSwarm = await isSwarmNode(remote.domain);
if (!isSwarm) { if (!isSwarm) {
const discovery = await discoverNode(remoteDomain); const discovery = await discoverNode(remote.domain);
isSwarm = discovery.success; isSwarm = discovery.success;
} }
if (isSwarm) { if (isSwarm) {
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0); const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, 0);
if (profileData?.profile) { if (profileData?.profile) {
const profile = profileData.profile; const profile = profileData.profile;
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null; const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
const normalizedBotOwnerHandle = rawBotOwnerHandle const normalizedBotOwnerHandle = rawBotOwnerHandle
? rawBotOwnerHandle.includes('@') ? rawBotOwnerHandle.includes('@')
? rawBotOwnerHandle ? rawBotOwnerHandle
: `${rawBotOwnerHandle}@${remoteDomain}` : `${rawBotOwnerHandle}@${remote.domain}`
: null; : null;
const botOwnerLocalHandle = rawBotOwnerHandle const botOwnerLocalHandle = rawBotOwnerHandle
? rawBotOwnerHandle.split('@')[0] ? rawBotOwnerHandle.split('@')[0]
@@ -64,7 +66,7 @@ export async function GET(request: Request, context: RouteContext) {
// CACHE: Upsert the remote user into our local database // CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({ await upsertRemoteUser({
handle: `${profile.handle}@${remoteDomain}`, handle: `${profile.handle}@${remote.domain}`,
displayName: profile.displayName, displayName: profile.displayName,
avatarUrl: profile.avatarUrl || null, avatarUrl: profile.avatarUrl || null,
did: profile.did || '', did: profile.did || '',
@@ -74,8 +76,8 @@ export async function GET(request: Request, context: RouteContext) {
return NextResponse.json({ return NextResponse.json({
user: { user: {
id: `swarm:${remoteDomain}:${profile.handle}`, id: `swarm:${remote.domain}:${profile.handle}`,
handle: `${profile.handle}@${remoteDomain}`, handle: `${profile.handle}@${remote.domain}`,
displayName: profile.displayName, displayName: profile.displayName,
bio: profile.bio || null, bio: profile.bio || null,
avatarUrl: profile.avatarUrl || null, avatarUrl: profile.avatarUrl || null,
@@ -87,10 +89,10 @@ export async function GET(request: Request, context: RouteContext) {
createdAt: profile.createdAt, createdAt: profile.createdAt,
isRemote: true, isRemote: true,
isSwarm: true, isSwarm: true,
nodeDomain: remoteDomain, nodeDomain: remote.domain,
isBot: profile.isBot || false, isBot: profile.isBot || false,
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? { botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`, id: `swarm:${remote.domain}:${botOwnerLocalHandle}`,
handle: normalizedBotOwnerHandle, handle: normalizedBotOwnerHandle,
displayName: botOwnerLocalHandle, displayName: botOwnerLocalHandle,
avatarUrl: null, avatarUrl: null,
@@ -111,6 +113,9 @@ export async function GET(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
} }
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (user.isSuspended) { if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
+3 -8
View File
@@ -1,11 +1,6 @@
export const parseRemoteHandle = (handle: string) => { import { resolveUserHandle } from './user-handle';
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean); export const parseRemoteHandle = (handle: string) => resolveUserHandle(handle).remote;
if (parts.length === 2) {
return { handle: parts[0], domain: parts[1] };
}
return null;
};
export const getRemoteBaseUrl = (domain: string) => export const getRemoteBaseUrl = (domain: string) =>
domain.startsWith('http') domain.startsWith('http')
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { resolveUserHandle } from './user-handle';
describe('resolveUserHandle', () => {
it('normalizes an unqualified local handle', () => {
expect(resolveUserHandle(' @Alice ', 'social.example.org')).toEqual({
canonicalHandle: 'alice',
handle: 'alice',
domain: null,
isQualified: false,
isLocal: true,
remote: null,
});
});
it('canonicalizes a same-node qualified handle to its local database handle', () => {
expect(resolveUserHandle('Alice@Social.Example.org', 'https://social.example.org/')).toEqual({
canonicalHandle: 'alice',
handle: 'alice',
domain: 'social.example.org',
isQualified: true,
isLocal: true,
remote: null,
});
});
it('preserves a genuinely remote qualified handle', () => {
expect(resolveUserHandle('Alice@remote.example.org', 'social.example.org')).toEqual({
canonicalHandle: 'alice@remote.example.org',
handle: 'alice',
domain: 'remote.example.org',
isQualified: true,
isLocal: false,
remote: {
handle: 'alice',
domain: 'remote.example.org',
},
});
});
it('compares development domains including ports', () => {
expect(resolveUserHandle('Alice@localhost:43821', 'http://localhost:43821')).toMatchObject({
canonicalHandle: 'alice',
isLocal: true,
remote: null,
});
expect(resolveUserHandle('Alice@localhost:43822', 'localhost:43821')).toMatchObject({
canonicalHandle: 'alice@localhost:43822',
isLocal: false,
remote: { handle: 'alice', domain: 'localhost:43822' },
});
});
it('does not misclassify malformed handles as remote', () => {
expect(resolveUserHandle('alice@@social.example.org', 'social.example.org')).toMatchObject({
canonicalHandle: 'alice@@social.example.org',
isQualified: false,
isLocal: true,
remote: null,
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { getPublicSwarmDomain, normalizeNodeDomain } from './node-domain';
export interface RemoteUserHandle {
handle: string;
domain: string;
}
export interface ResolvedUserHandle {
canonicalHandle: string;
handle: string;
domain: string | null;
isQualified: boolean;
isLocal: boolean;
remote: RemoteUserHandle | null;
}
function canonicalizeDomain(value: string | null | undefined): string | null {
if (!value) return null;
const publicDomain = getPublicSwarmDomain(value);
if (publicDomain) return publicDomain;
const normalized = normalizeNodeDomain(value).replace(/\.$/, '');
return normalized || null;
}
/**
* Resolve a profile handle relative to the current node.
*
* Fully-qualified handles remain remote unless their domain identifies this
* node. A same-node handle such as `alice@social.example` is canonicalized to
* the local database handle `alice` so every profile sub-route behaves the
* same way.
*/
export function resolveUserHandle(
value: string,
currentDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
): ResolvedUserHandle {
const clean = value.trim().toLowerCase().replace(/^@/, '');
const parts = clean.split('@');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return {
canonicalHandle: clean,
handle: clean,
domain: null,
isQualified: false,
isLocal: true,
remote: null,
};
}
const handle = parts[0];
const domain = canonicalizeDomain(parts[1]);
if (!domain) {
return {
canonicalHandle: clean,
handle: clean,
domain: null,
isQualified: false,
isLocal: true,
remote: null,
};
}
const localDomain = canonicalizeDomain(currentDomain);
const isLocal = localDomain !== null && domain === localDomain;
return {
canonicalHandle: isLocal ? handle : `${handle}@${domain}`,
handle,
domain,
isQualified: true,
isLocal,
remote: isLocal ? null : { handle, domain },
};
}