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 { 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) {
+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 { 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) {
@@ -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,
@@ -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,
+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 { 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) {
+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 { 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) {
+6 -4
View File
@@ -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) {
+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 { 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 });
}
+3 -8
View File
@@ -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')
+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 },
};
}