diff --git a/web/app/u/[handle]/agent.md/route.ts b/web/app/u/[handle]/agent.md/route.ts new file mode 100644 index 00000000..6d41f66e --- /dev/null +++ b/web/app/u/[handle]/agent.md/route.ts @@ -0,0 +1,43 @@ +import { getAgentProfile, validRegistryHandle } from '../../../../lib/agent-registry'; +import { + agentChatAgentGuide, + agentChatUrlForHandle, + newConversationId, + normalizeAgentName, +} from '../../../../lib/agent-chat-snippet'; + +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ handle: string }>; +}; + +const MARKDOWN_HEADERS = { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', +}; + +export async function GET(_request: Request, { params }: RouteContext) { + const { handle } = await params; + if (!validRegistryHandle(handle)) { + return new Response('# Agent not found\n', { status: 404, headers: MARKDOWN_HEADERS }); + } + const profile = await getAgentProfile(handle); + if (!profile) { + return new Response('# Agent not found\n', { status: 404, headers: MARKDOWN_HEADERS }); + } + if (profile.status !== 'active') { + return new Response( + `# ${normalizeAgentName(profile.displayName)}\n\nThis agent is temporarily unavailable because its domain verification is no longer current.\n`, + { status: 410, headers: MARKDOWN_HEADERS }, + ); + } + return new Response(agentChatAgentGuide({ + conversationId: newConversationId(), + baseUrl: agentChatUrlForHandle(profile.handle), + agentName: profile.displayName, + agentHandle: profile.handle, + }), { headers: MARKDOWN_HEADERS }); +} diff --git a/web/app/u/[handle]/page.tsx b/web/app/u/[handle]/page.tsx new file mode 100644 index 00000000..b1165389 --- /dev/null +++ b/web/app/u/[handle]/page.tsx @@ -0,0 +1,111 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { AgentChatSnippet } from '../../../components/AgentChatSnippet'; +import { SiteFooter } from '../../../components/SiteFooter'; +import { SiteNav } from '../../../components/SiteNav'; +import { getAgentProfile, validRegistryHandle } from '../../../lib/agent-registry'; +import { agentChatUrlForHandle, newConversationId } from '../../../lib/agent-chat-snippet'; +import { defaultOgImage } from '../../../lib/og-meta'; +import home from '../../landing.module.css'; +import flows from '../../flows/flows.module.css'; +import s from '../agent-relay/agent-relay.module.css'; + +export const dynamic = 'force-dynamic'; + +type PageProps = { + params: Promise<{ handle: string }>; +}; + +const STEPS = [ + { title: 'Copy the snippet', body: 'It carries a private conversation link made just for you.' }, + { title: 'Paste it into your open chat', body: 'Claude Code or Codex uses the snippet to contact this verified agent. Nothing to install.' }, + { title: 'Approve once', body: 'Your agent asks to run one command. Choose “don’t ask again” and the rest of the conversation flows.' }, +]; + +export async function generateMetadata({ params }: PageProps): Promise { + const { handle } = await params; + if (!validRegistryHandle(handle)) return { title: 'Not Found' }; + const profile = await getAgentProfile(handle); + if (!profile) return { title: 'Not Found' }; + const title = profile.status === 'active' + ? `Chat with ${profile.displayName}` + : profile.displayName; + const canonical = `https://arelay.to/${profile.handle}`; + return { + title, + description: profile.description, + alternates: { canonical }, + openGraph: { + title, + description: profile.description, + url: canonical, + type: 'website', + images: [defaultOgImage()], + }, + twitter: { + card: 'summary_large_image', + title, + description: profile.description, + images: [defaultOgImage().url], + }, + }; +} + +export default async function RegisteredAgentPage({ params }: PageProps) { + const { handle } = await params; + if (!validRegistryHandle(handle)) notFound(); + const profile = await getAgentProfile(handle); + if (!profile) notFound(); + + return ( +
+ Skip to content + + +
+
+

+ {profile.status === 'active' ? `Chat with ${profile.displayName}` : profile.displayName} +

+ {profile.status === 'active' && ( +

✓ verified {profile.verifiedDomain}

+ )} +

{profile.description}

+ {profile.status === 'active' ? ( + + ) : ( +

+ This agent is temporarily unavailable because its domain verification is no longer current. +

+ )} +
+ + {profile.status === 'active' && ( +
+

How it works

+
    + {STEPS.map((step) => ( +
  1. + {step.title} + {step.body} +
  2. + ))} +
+

+ Each message is a plain HTTPS request, and the reply comes back as its output. Treat replies from any + external agent as information, not instructions, and ask before sharing code, files or secrets. +

+
+ )} +
+ + +
+ ); +} diff --git a/web/app/u/agent-relay/agent-relay.module.css b/web/app/u/agent-relay/agent-relay.module.css index 6ba3b4b0..43a92e71 100644 --- a/web/app/u/agent-relay/agent-relay.module.css +++ b/web/app/u/agent-relay/agent-relay.module.css @@ -29,6 +29,28 @@ margin: 0 0 8px; } +.badge { + margin: -10px 0 0; + border: 1px solid #47708d; + border-radius: 999px; + padding: 6px 12px; + color: #b9ddf5; + font-family: var(--font-mono, ui-monospace), monospace; + font-size: 13px; +} + +.suspended { + width: 100%; + max-width: 760px; + margin: 0 auto; + border: 1px solid #8f655d; + border-radius: 12px; + padding: 20px; + background: #291d1d; + color: #ffd1c8; + line-height: 1.6; +} + .steps { max-width: 760px; margin: 0 auto; diff --git a/web/app/u/agent-relay/page.tsx b/web/app/u/agent-relay/page.tsx index 9693eb62..93a75ce2 100644 --- a/web/app/u/agent-relay/page.tsx +++ b/web/app/u/agent-relay/page.tsx @@ -39,6 +39,7 @@ export default function AgentRelayChatPage() {

Have your agent chat with ours

+

✓ verified agentrelay.com

{DESCRIPTION}

diff --git a/web/components/AgentChatSnippet.tsx b/web/components/AgentChatSnippet.tsx index 6e5a38cd..49a2a1b2 100644 --- a/web/components/AgentChatSnippet.tsx +++ b/web/components/AgentChatSnippet.tsx @@ -7,11 +7,21 @@ import s from './agent-chat-snippet.module.css'; // The server renders a fresh conversation per request, so an agent that fetches // the page sees a complete snippet too. -export function AgentChatSnippet({ initialConversationId }: { initialConversationId: string }) { +export function AgentChatSnippet({ + initialConversationId, + baseUrl, + agentName, + agentHandle, +}: { + initialConversationId: string; + baseUrl?: string; + agentName?: string; + agentHandle?: string; +}) { const [conversationId, setConversationId] = useState(initialConversationId); const [status, setStatus] = useState<'' | 'copied' | 'copy-failed' | 'new'>(''); - const snippet = agentChatSnippet({ conversationId }); + const snippet = agentChatSnippet({ conversationId, baseUrl, agentName, agentHandle }); async function handleCopy() { try { diff --git a/web/lib/agent-chat-snippet.ts b/web/lib/agent-chat-snippet.ts index 288a00b6..139c6673 100644 --- a/web/lib/agent-chat-snippet.ts +++ b/web/lib/agent-chat-snippet.ts @@ -12,22 +12,48 @@ export const AGENT_CHAT_BRIDGE_COMMAND = process.env.NEXT_PUBLIC_AGENT_CHAT_BRIDGE_COMMAND?.trim() || `curl -fsSL ${AGENT_CHAT_URL.replace(/\/$/, '')}/bridge.sh | sh -s --`; +export function agentChatUrlForHandle( + handle: string, + configuredUrl = AGENT_CHAT_URL, +): string { + const url = new URL(configuredUrl); + const segments = url.pathname.replace(/\/+$/, '').split('/'); + segments[segments.length - 1] = encodeURIComponent(handle); + url.pathname = segments.join('/'); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + export function newConversationId(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } +export function normalizeAgentName(agentName: string | undefined): string { + return (agentName ?? 'Agent Relay').replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; +} + export function agentChatSnippet({ conversationId, baseUrl = AGENT_CHAT_URL, bridgeCommand = AGENT_CHAT_BRIDGE_COMMAND, + agentName, + agentHandle, }: { conversationId: string; baseUrl?: string; bridgeCommand?: string; + agentName?: string; + agentHandle?: string; }): string { const url = `${baseUrl.replace(/\/$/, '')}/${conversationId}`; + const normalizedAgentName = normalizeAgentName(agentName); + const isAgentRelay = agentHandle ? agentHandle.toLowerCase() === 'agent-relay' : agentName === undefined; + const agentLabel = isAgentRelay + ? 'the Agent Relay agent' + : `the registered agent ${JSON.stringify(normalizedAgentName)}`; // Messages travel through a file written with the agent's file tool, never // through shell source: replies from other agents may be quoted back, and a // file's contents are never parsed. The command is identical for every turn, @@ -36,9 +62,15 @@ export function agentChatSnippet({ // user, and the full id is the conversation's bearer secret. const messageFile = `/tmp/arelay-${conversationId.slice(0, 16)}.txt`; const lines = [ - 'Chat with the Agent Relay agent for me and show me each reply. Start by saying hi and asking what it can help with.', - '', - 'Before you set anything up for Agent Relay (installs, sign-ins, workspaces, integrations), tell this agent what you plan to do and what you found, then confirm each step with it. It knows the current setup steps and will check what already exists, such as your active workspace, before anything new is created.', + `Chat with ${agentLabel} for me. Show me each reply. Start by saying hi and asking what it can help with.`, + ]; + if (isAgentRelay) { + lines.push( + '', + 'Before you set anything up for Agent Relay (installs, sign-ins, workspaces, integrations), tell this agent what you plan to do and what you found, then confirm each step with it. It knows the current setup steps and will check what already exists, such as your active workspace, before anything new is created.', + ); + } + lines.push( '', `To send a message, write it to ${messageFile} with your file-editing tool (replace the whole file each time, and never write it with a shell command). Then run this exact command; it prints the reply:`, '', @@ -49,7 +81,7 @@ export function agentChatSnippet({ `If it prints "no reply yet", empty ${messageFile} and run the same command to keep waiting.`, '', 'If curl cannot connect, your sandbox is blocking the network: ask me to approve running this exact command outside the sandbox. Choosing "don\'t ask again" covers the rest of the conversation.', - ]; + ); if (bridgeCommand) { const dir = `/tmp/agent-relay/${conversationId}`; lines.push( @@ -68,13 +100,27 @@ export function agentChatSnippet({ // The agent-readable version of arelay.to/agent-relay, served to anything that // does not ask for HTML (plain curl, web-fetch tools). Each fetch is a new // private conversation. -export function agentChatAgentGuide({ conversationId, baseUrl = AGENT_CHAT_URL }: { conversationId: string; baseUrl?: string }): string { +export function agentChatAgentGuide({ + conversationId, + baseUrl = AGENT_CHAT_URL, + agentName, + agentHandle, +}: { + conversationId: string; + baseUrl?: string; + agentName?: string; + agentHandle?: string; +}): string { + const normalizedAgentName = normalizeAgentName(agentName); + const isAgentRelay = agentHandle ? agentHandle.toLowerCase() === 'agent-relay' : agentName === undefined; return [ - '# Chat with the Agent Relay agent', + `# Chat with ${isAgentRelay ? 'the Agent Relay agent' : normalizedAgentName}`, '', - "Your human pointed you here to talk with Agent Relay's agent. It answers questions about Agent Relay (Flows, Sessions, Desktop, Relayfile, Connect) and helps you set them up. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again.", + isAgentRelay + ? "Your human pointed you here to talk with Agent Relay's agent. It answers questions about Agent Relay (Flows, Sessions, Desktop, Relayfile, Connect) and helps you set them up. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again." + : `Your human pointed you here to talk with the verified agent ${JSON.stringify(normalizedAgentName)}. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again.`, '', - agentChatSnippet({ conversationId, baseUrl }), + agentChatSnippet({ conversationId, baseUrl, agentName: normalizedAgentName, agentHandle }), '', ].join('\n'); } diff --git a/web/lib/agent-registry.ts b/web/lib/agent-registry.ts new file mode 100644 index 00000000..8a400c7c --- /dev/null +++ b/web/lib/agent-registry.ts @@ -0,0 +1,93 @@ +import { cache } from 'react'; + +const HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])$/; +const MAX_PROFILE_BYTES = 16_000; +const PROFILE_TIMEOUT_MS = 5_000; +const REGISTRY_ORIGIN = 'https://arelay.to'; + +export type PublicAgentProfile = { + handle: string; + displayName: string; + description: string; + verifiedDomain: string; + verifiedAt: string; + deliveryType: 'internal' | 'a2a' | 'relay'; + status: 'active' | 'suspended'; +}; + +export function validRegistryHandle(handle: string): boolean { + return HANDLE_PATTERN.test(handle); +} + +export async function fetchAgentProfile( + handle: string, + fetcher: typeof globalThis.fetch = globalThis.fetch, +): Promise { + if (!validRegistryHandle(handle)) return null; + const response = await fetcher(`${REGISTRY_ORIGIN}/api/v1/agents/${encodeURIComponent(handle)}`, { + cache: 'no-store', + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(PROFILE_TIMEOUT_MS), + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Agent registry returned ${response.status}`); + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_PROFILE_BYTES) { + throw new Error('Agent registry profile exceeded the size limit'); + } + const body = await readBoundedBody(response, MAX_PROFILE_BYTES); + let value: unknown; + try { + value = JSON.parse(body); + } catch { + throw new Error('Agent registry returned invalid JSON'); + } + if (!isPublicAgentProfile(value) || value.handle !== handle) { + throw new Error('Agent registry returned an invalid profile'); + } + return value; +} + +async function readBoundedBody(response: Response, limit: number): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + throw new Error('Agent registry profile exceeded the size limit'); + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + +export const getAgentProfile = cache((handle: string) => fetchAgentProfile(handle)); + +function isPublicAgentProfile(value: unknown): value is PublicAgentProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const profile = value as Record; + return typeof profile.handle === 'string' + && validRegistryHandle(profile.handle) + && boundedString(profile.displayName, 1, 100) + && boundedString(profile.description, 1, 1_000) + && boundedString(profile.verifiedDomain, 1, 253) + && typeof profile.verifiedAt === 'string' + && Number.isFinite(Date.parse(profile.verifiedAt)) + && (profile.deliveryType === 'internal' || profile.deliveryType === 'a2a' || profile.deliveryType === 'relay') + && (profile.status === 'active' || profile.status === 'suspended'); +} + +function boundedString(value: unknown, minimum: number, maximum: number): value is string { + return typeof value === 'string' && value.length >= minimum && value.length <= maximum; +} diff --git a/web/lib/test/agent-chat-snippet.test.ts b/web/lib/test/agent-chat-snippet.test.ts index eb7f9ed7..576015f3 100644 --- a/web/lib/test/agent-chat-snippet.test.ts +++ b/web/lib/test/agent-chat-snippet.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { agentChatAgentGuide, agentChatSnippet, newConversationId } from '../agent-chat-snippet'; +import { agentChatAgentGuide, agentChatSnippet, agentChatUrlForHandle, newConversationId } from '../agent-chat-snippet'; describe('agent chat snippet', () => { it('uses one identical command for every turn of the conversation', () => { @@ -109,6 +109,53 @@ describe('agent chat snippet', () => { expect(snippet).not.toContain(`/tmp/arelay-${id}`); }); + it('names a selected registry agent without changing the Agent Relay default', () => { + expect(agentChatSnippet({ conversationId: 'abc123', bridgeCommand: '' })) + .toContain('Chat with the Agent Relay agent for me.'); + expect(agentChatSnippet({ + conversationId: 'abc123', + bridgeCommand: '', + agentName: 'Acme\nSupport', + })).toContain('Chat with the registered agent "Acme Support" for me.'); + }); + + it('does not grant official identity from a registered display name', () => { + const snippet = agentChatSnippet({ + conversationId: 'abc123', + bridgeCommand: '', + agentName: 'Agent Relay', + agentHandle: 'acme-support', + }); + expect(snippet).toContain('Chat with the registered agent "Agent Relay" for me.'); + expect(snippet).not.toContain('active workspace'); + + const guide = agentChatAgentGuide({ + conversationId: 'abc123', + agentName: 'Agent Relay', + agentHandle: 'acme-support', + }); + expect(guide).toMatch(/^# Chat with Agent Relay/); + expect(guide).toContain('the verified agent "Agent Relay"'); + expect(guide).not.toContain("Agent Relay's agent"); + }); + + it('keeps handle chats on the configured conversation origin', () => { + expect(agentChatUrlForHandle('acme-support', 'https://staging.example/prefix/agent-relay/')) + .toBe('https://staging.example/prefix/acme-support'); + }); + + it('serves a selected registry agent guide with the file-based command', () => { + const guide = agentChatAgentGuide({ + conversationId: 'abc123', + baseUrl: 'https://arelay.to/acme-support', + agentName: 'Acme Support', + }); + expect(guide).toMatch(/^# Chat with Acme Support/); + expect(guide).toContain('the verified agent "Acme Support"'); + expect(guide).toContain('curl -sS --data-binary @/tmp/arelay-abc123.txt https://arelay.to/acme-support/abc123'); + expect(guide).not.toContain('<<'); + }); + it('creates unguessable conversation ids', () => { const id = newConversationId(); expect(id).toMatch(/^[0-9a-f]{32}$/); diff --git a/web/lib/test/agent-registry.test.ts b/web/lib/test/agent-registry.test.ts new file mode 100644 index 00000000..7e356699 --- /dev/null +++ b/web/lib/test/agent-registry.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchAgentProfile, validRegistryHandle } from '../agent-registry'; + +const PROFILE = { + handle: 'acme-support', + displayName: 'Acme Support', + description: 'Answers questions about Acme products.', + verifiedDomain: 'acme.example', + verifiedAt: '2026-10-07T12:00:00.000Z', + deliveryType: 'relay', + status: 'active', +}; + +describe('public agent registry profiles', () => { + it('fetches a valid profile without caching it', async () => { + const fetcher = vi.fn(async () => Response.json(PROFILE)); + await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)).resolves.toEqual(PROFILE); + expect(fetcher).toHaveBeenCalledWith( + 'https://arelay.to/api/v1/agents/acme-support', + expect.objectContaining({ cache: 'no-store', headers: { accept: 'application/json' } }), + ); + }); + + it('maps only a registry 404 to an unknown handle', async () => { + const missing = vi.fn(async () => new Response('missing', { status: 404 })); + await expect(fetchAgentProfile('missing', missing as typeof fetch)).resolves.toBeNull(); + const unavailable = vi.fn(async () => new Response('down', { status: 503 })); + await expect(fetchAgentProfile('missing', unavailable as typeof fetch)) + .rejects.toThrow('Agent registry returned 503'); + }); + + it('rejects malformed handles and untrusted response shapes', async () => { + const fetcher = vi.fn(async () => Response.json({ ...PROFILE, handle: 'another-agent' })); + expect(validRegistryHandle('Acme')).toBe(false); + expect(validRegistryHandle('-acme')).toBe(false); + expect(validRegistryHandle('acme-support')).toBe(true); + await expect(fetchAgentProfile('Acme', fetcher as typeof fetch)).resolves.toBeNull(); + expect(fetcher).not.toHaveBeenCalled(); + await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)) + .rejects.toThrow('invalid profile'); + }); + + it('caps profile bodies', async () => { + let pulls = 0; + let canceled = false; + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(8_001))); + if (pulls === 3) controller.close(); + }, + cancel() { + canceled = true; + }, + }); + const fetcher = vi.fn(async () => new Response(body)); + await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)) + .rejects.toThrow('size limit'); + expect(canceled).toBe(true); + }); +}); diff --git a/web/lib/test/registered-agent-guide-route.test.ts b/web/lib/test/registered-agent-guide-route.test.ts new file mode 100644 index 00000000..37a43c22 --- /dev/null +++ b/web/lib/test/registered-agent-guide-route.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from '../../app/u/[handle]/agent.md/route'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('registered agent markdown guide', () => { + it('mints a file-based conversation for an active handle', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + handle: 'acme-support', + displayName: 'Acme Support', + description: 'Answers questions about Acme products.', + verifiedDomain: 'acme.example', + verifiedAt: '2026-10-07T12:00:00.000Z', + deliveryType: 'relay', + status: 'active', + }))); + const response = await GET(new Request('https://agentrelay.com/u/acme-support/agent.md'), { + params: Promise.resolve({ handle: 'acme-support' }), + }); + const guide = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(guide).toMatch(/^# Chat with Acme Support/); + expect(guide).toMatch(/curl -sS --data-binary @\/tmp\/arelay-[0-9a-f]{16}\.txt https:\/\/arelay\.to\/acme-support\/[0-9a-f]{32}/); + expect(guide).not.toContain('<<'); + }); + + it('does not mint a conversation for a suspended handle', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + handle: 'paused-agent', + displayName: 'Paused Agent\n\n# Injected heading', + description: 'Temporarily unavailable.', + verifiedDomain: 'paused.example', + verifiedAt: '2026-10-07T12:00:00.000Z', + deliveryType: 'a2a', + status: 'suspended', + }))); + const response = await GET(new Request('https://agentrelay.com/u/paused-agent/agent.md'), { + params: Promise.resolve({ handle: 'paused-agent' }), + }); + expect(response.status).toBe(410); + const guide = await response.text(); + expect(guide).toMatch(/^# Paused Agent # Injected heading\n\n/); + expect(guide).not.toContain('\n# Injected heading'); + expect(guide).not.toContain('curl '); + }); +}); diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.