diff --git a/src/app/api/users/[handle]/block/route.ts b/src/app/api/users/[handle]/block/route.ts index c61f5ad..31443b0 100644 --- a/src/app/api/users/[handle]/block/route.ts +++ b/src/app/api/users/[handle]/block/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db'; import { users, blocks, follows } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireAuth } from '@/lib/auth'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -11,9 +12,10 @@ export async function GET(req: NextRequest, context: RouteContext) { try { const currentUser = await requireAuth(); const { handle } = await context.params; + const targetHandle = resolveUserHandle(handle).canonicalHandle; const targetUser = await db.query.users.findFirst({ - where: { handle: handle }, + where: { handle: targetHandle }, }); if (!targetUser) { @@ -39,9 +41,10 @@ export async function POST(req: NextRequest, context: RouteContext) { try { const currentUser = await requireAuth(); const { handle } = await context.params; + const targetHandle = resolveUserHandle(handle).canonicalHandle; const targetUser = await db.query.users.findFirst({ - where: { handle: handle }, + where: { handle: targetHandle }, }); if (!targetUser) { @@ -96,9 +99,10 @@ export async function DELETE(req: NextRequest, context: RouteContext) { try { const currentUser = await requireAuth(); const { handle } = await context.params; + const targetHandle = resolveUserHandle(handle).canonicalHandle; const targetUser = await db.query.users.findFirst({ - where: { handle: handle }, + where: { handle: targetHandle }, }); if (!targetUser) { diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index 6aa832d..0006282 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -7,25 +7,18 @@ import { requireSignedAction } from '@/lib/auth/verify-signature'; import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; import { buildNotificationTarget } from '@/lib/notifications'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; 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 export async function GET(request: Request, context: RouteContext) { try { const currentUser = await requireAuth(); const { handle } = await context.params; - const cleanHandle = handle.toLowerCase().replace(/^@/, ''); - const remote = parseRemoteHandle(handle); + const resolvedHandle = resolveUserHandle(handle); + const cleanHandle = resolvedHandle.canonicalHandle; + const remote = resolvedHandle.remote; if (currentUser.isSuspended || currentUser.isSilenced) { 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. const { handle } = await context.params; - const cleanHandle = handle.toLowerCase().replace(/^@/, ''); - const remote = parseRemoteHandle(handle); + const resolvedHandle = resolveUserHandle(handle); + const cleanHandle = resolvedHandle.canonicalHandle; + const remote = resolvedHandle.remote; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; if (currentUser.isSuspended || currentUser.isSilenced) { @@ -254,8 +248,9 @@ export async function DELETE(request: Request, context: RouteContext) { const currentUser = await requireSignedAction(signedAction); const { handle } = await context.params; - const cleanHandle = handle.toLowerCase().replace(/^@/, ''); - const remote = parseRemoteHandle(handle); + const resolvedHandle = resolveUserHandle(handle); + const cleanHandle = resolvedHandle.canonicalHandle; + const remote = resolvedHandle.remote; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; if (remote) { diff --git a/src/app/api/users/[handle]/followers/route.ts b/src/app/api/users/[handle]/followers/route.ts index 4d16325..9e69005 100644 --- a/src/app/api/users/[handle]/followers/route.ts +++ b/src/app/api/users/[handle]/followers/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { db, follows, users, remoteFollowers } from '@/db'; import { eq } from 'drizzle-orm'; import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; 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) { try { 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 limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); // 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 - const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit); + const swarmData = await fetchSwarmFollowers(remote.handle, remote.domain, limit); if (swarmData?.followers) { // Transform to include full handles for local users on that node const followers = swarmData.followers.map((f: any) => ({ - id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, - handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, + id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`, + handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`, displayName: f.displayName, avatarUrl: f.avatarUrl, bio: f.bio, diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts index d5a22db..6bdb55f 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { db, follows, users, remoteFollows } from '@/db'; import { eq } from 'drizzle-orm'; import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; 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) { try { 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 limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); // 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 - const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit); + const swarmData = await fetchSwarmFollowing(remote.handle, remote.domain, limit); if (swarmData?.following) { // Transform to include full handles for local users on that node const following = swarmData.following.map((f: any) => ({ - id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, - handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, + id: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`, + handle: f.isRemote ? f.handle : `${f.handle}@${remote.domain}`, displayName: f.displayName, avatarUrl: f.avatarUrl, bio: f.bio, diff --git a/src/app/api/users/[handle]/likes/route.ts b/src/app/api/users/[handle]/likes/route.ts index 87b0b35..51f14b8 100644 --- a/src/app/api/users/[handle]/likes/route.ts +++ b/src/app/api/users/[handle]/likes/route.ts @@ -3,7 +3,8 @@ import { db, likes, posts, users, userSwarmLikes } from '@/db'; import { eq, desc, and, inArray } from 'drizzle-orm'; import { discoverNode } from '@/lib/swarm/discovery'; 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 { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; @@ -44,10 +45,11 @@ const parseMediaJson = (mediaJson: string | null) => { export async function GET(request: Request, context: RouteContext) { try { 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 limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); - const remote = parseRemoteHandle(handle); + const remote = resolvedHandle.remote; const fetchRemoteLikesRoute = async () => { if (!remote) { @@ -110,7 +112,7 @@ export async function GET(request: Request, context: RouteContext) { const user = await db.query.users.findFirst({ where: { handle: cleanHandle }, }); - const isRemotePlaceholder = user && cleanHandle.includes('@'); + const isRemotePlaceholder = Boolean(user && remote); if (!user || isRemotePlaceholder) { if (!remote) { diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 93372a3..0e844ee 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -4,7 +4,8 @@ import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; 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'; const embeddedPostRelations = { @@ -213,12 +214,13 @@ async function populateViewerLikeState( export async function GET(request: Request, context: RouteContext) { try { 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 limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); const cursor = searchParams.get('cursor'); - const remote = parseRemoteHandle(handle); + const remote = resolvedHandle.remote; const fetchRemotePostsRoute = async () => { if (!remote) { 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({ where: { handle: cleanHandle }, }); - const isRemotePlaceholder = user && cleanHandle.includes('@'); + const isRemotePlaceholder = Boolean(user && remote); if (!user || isRemotePlaceholder) { if (!remote) { diff --git a/src/app/api/users/[handle]/replies/route.ts b/src/app/api/users/[handle]/replies/route.ts index 50541d4..9c6d631 100644 --- a/src/app/api/users/[handle]/replies/route.ts +++ b/src/app/api/users/[handle]/replies/route.ts @@ -2,9 +2,10 @@ import { NextResponse } from 'next/server'; import { db, likes, posts, users } from '@/db'; import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm'; 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 { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; const embeddedPostRelations = { author: true, @@ -31,11 +32,12 @@ type RouteContext = { params: Promise<{ handle: string }> }; export async function GET(request: Request, context: RouteContext) { try { 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 limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); const cursor = searchParams.get('cursor'); - const remote = parseRemoteHandle(handle); + const remote = resolvedHandle.remote; const fetchRemoteReplies = async () => { if (!remote) { @@ -97,7 +99,7 @@ export async function GET(request: Request, context: RouteContext) { const user = await db.query.users.findFirst({ where: { handle: cleanHandle }, }); - const isRemotePlaceholder = user && cleanHandle.includes('@'); + const isRemotePlaceholder = Boolean(user && remote); if (!user || isRemotePlaceholder) { if (!remote) { diff --git a/src/app/api/users/[handle]/route.test.ts b/src/app/api/users/[handle]/route.test.ts new file mode 100644 index 0000000..ad100a1 --- /dev/null +++ b/src/app/api/users/[handle]/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index d4405d4..096cdc2 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -4,14 +4,16 @@ import { getSession } from '@/lib/auth'; import { db, users, follows } from '@/db'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; type RouteContext = { params: Promise<{ handle: string }> }; export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; - const cleanHandle = handle.toLowerCase().replace(/^@/, ''); - const [remoteHandle, remoteDomain] = cleanHandle.split('@'); + const resolvedHandle = resolveUserHandle(handle); + const cleanHandle = resolvedHandle.canonicalHandle; + const remote = resolvedHandle.remote; // Return mock user if no database 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 - const isRemotePlaceholder = user && cleanHandle.includes('@'); + const isRemotePlaceholder = Boolean(user && remote); if (!user || isRemotePlaceholder) { - if (remoteHandle && remoteDomain) { + if (remote) { // Only fetch from swarm nodes - let isSwarm = await isSwarmNode(remoteDomain); + let isSwarm = await isSwarmNode(remote.domain); if (!isSwarm) { - const discovery = await discoverNode(remoteDomain); + const discovery = await discoverNode(remote.domain); isSwarm = discovery.success; } if (isSwarm) { - const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0); + const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, 0); if (profileData?.profile) { const profile = profileData.profile; const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null; const normalizedBotOwnerHandle = rawBotOwnerHandle ? rawBotOwnerHandle.includes('@') ? rawBotOwnerHandle - : `${rawBotOwnerHandle}@${remoteDomain}` + : `${rawBotOwnerHandle}@${remote.domain}` : null; const botOwnerLocalHandle = rawBotOwnerHandle ? rawBotOwnerHandle.split('@')[0] @@ -64,7 +66,7 @@ export async function GET(request: Request, context: RouteContext) { // CACHE: Upsert the remote user into our local database const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); await upsertRemoteUser({ - handle: `${profile.handle}@${remoteDomain}`, + handle: `${profile.handle}@${remote.domain}`, displayName: profile.displayName, avatarUrl: profile.avatarUrl || null, did: profile.did || '', @@ -74,8 +76,8 @@ export async function GET(request: Request, context: RouteContext) { return NextResponse.json({ user: { - id: `swarm:${remoteDomain}:${profile.handle}`, - handle: `${profile.handle}@${remoteDomain}`, + id: `swarm:${remote.domain}:${profile.handle}`, + handle: `${profile.handle}@${remote.domain}`, displayName: profile.displayName, bio: profile.bio || null, avatarUrl: profile.avatarUrl || null, @@ -87,10 +89,10 @@ export async function GET(request: Request, context: RouteContext) { createdAt: profile.createdAt, isRemote: true, isSwarm: true, - nodeDomain: remoteDomain, + nodeDomain: remote.domain, isBot: profile.isBot || false, botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? { - id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`, + id: `swarm:${remote.domain}:${botOwnerLocalHandle}`, handle: normalizedBotOwnerHandle, displayName: botOwnerLocalHandle, avatarUrl: null, @@ -111,6 +113,9 @@ export async function GET(request: Request, context: RouteContext) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } } + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } if (user.isSuspended) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } diff --git a/src/lib/swarm/remote-profile-posts.ts b/src/lib/swarm/remote-profile-posts.ts index cb8ad96..e2f0298 100644 --- a/src/lib/swarm/remote-profile-posts.ts +++ b/src/lib/swarm/remote-profile-posts.ts @@ -1,11 +1,6 @@ -export 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; -}; +import { resolveUserHandle } from './user-handle'; + +export const parseRemoteHandle = (handle: string) => resolveUserHandle(handle).remote; export const getRemoteBaseUrl = (domain: string) => domain.startsWith('http') diff --git a/src/lib/swarm/user-handle.test.ts b/src/lib/swarm/user-handle.test.ts new file mode 100644 index 0000000..f60662b --- /dev/null +++ b/src/lib/swarm/user-handle.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/swarm/user-handle.ts b/src/lib/swarm/user-handle.ts new file mode 100644 index 0000000..b96594e --- /dev/null +++ b/src/lib/swarm/user-handle.ts @@ -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 }, + }; +}