feat: HermesWorld name reservations — public claim flow (#383)

* feat(reserve): add HermesWorld name reservation flow

- /reserve form with live validation + counter
- /reserve/confirm and /early-access routes
- Server: name-reservations.ts (Supabase service-role storage, profanity + reserved-name filter, optional wallet)
- API routes /api/hermesworld/reservations + /reservations/confirm
- Cloudflare wrangler.jsonc for hermes-world deploy
- 237-line test suite covers validation, normalization, dedup
- SQL migration in docs/hermesworld/name-reservations.sql

Required env vars on production:
  HERMESWORLD_SUPABASE_URL
  HERMESWORLD_SUPABASE_SERVICE_ROLE_KEY
  HERMESWORLD_RESERVE_BASE_URL (optional, default https://hermes-world.ai)
  RESEND_API_KEY + RESERVE_FROM_EMAIL (optional, only if confirmation emails desired)
  HERMESWORLD_RESERVED_NAMES (optional, comma-separated)

* chore(routes): regenerate routeTree for /reserve, /reserve/confirm, /early-access

---------

Co-authored-by: Aurora release bot <release@outsourc-e.com>
This commit is contained in:
Eric
2026-05-07 16:34:56 -04:00
committed by GitHub
parent 8b12384b37
commit 4b3a47ae49
10 changed files with 1330 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
create extension if not exists pgcrypto;
create table if not exists public.name_reservations (
id uuid primary key default gen_random_uuid(),
desired_name text not null,
normalized_name text not null unique,
email text not null,
wallet_address text,
confirmation_token text not null unique,
confirmed_at timestamptz,
created_at timestamptz not null default timezone('utc', now())
);
create index if not exists idx_name_reservations_created_at
on public.name_reservations (created_at desc);
alter table public.name_reservations enable row level security;
create policy if not exists "service role manages reservations"
on public.name_reservations
for all
using (auth.role() = 'service_role')
with check (auth.role() = 'service_role');

View File

@@ -16,6 +16,7 @@ import { Route as Swarm2RouteImport } from './routes/swarm2'
import { Route as SwarmRouteImport } from './routes/swarm'
import { Route as SkillsRouteImport } from './routes/skills'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ReserveRouteImport } from './routes/reserve'
import { Route as ProfilesRouteImport } from './routes/profiles'
import { Route as PlaygroundRouteImport } from './routes/playground'
import { Route as OperationsRouteImport } from './routes/operations'
@@ -24,6 +25,7 @@ import { Route as McpRouteImport } from './routes/mcp'
import { Route as JobsRouteImport } from './routes/jobs'
import { Route as HermesWorldRouteImport } from './routes/hermes-world'
import { Route as FilesRouteImport } from './routes/files'
import { Route as EarlyAccessRouteImport } from './routes/early-access'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as ConductorRouteImport } from './routes/conductor'
import { Route as AgoraRouteImport } from './routes/agora'
@@ -32,6 +34,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as ChatIndexRouteImport } from './routes/chat/index'
import { Route as SettingsProvidersRouteImport } from './routes/settings/providers'
import { Route as ReserveConfirmRouteImport } from './routes/reserve/confirm'
import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey'
import { Route as ApiWorkspaceRouteImport } from './routes/api/workspace'
import { Route as ApiTerminalStreamRouteImport } from './routes/api/terminal-stream'
@@ -136,6 +139,7 @@ import { Route as ApiKnowledgeReadRouteImport } from './routes/api/knowledge/rea
import { Route as ApiKnowledgeListRouteImport } from './routes/api/knowledge/list'
import { Route as ApiKnowledgeGraphRouteImport } from './routes/api/knowledge/graph'
import { Route as ApiKnowledgeConfigRouteImport } from './routes/api/knowledge/config'
import { Route as ApiHermesworldReservationsRouteImport } from './routes/api/hermesworld/reservations'
import { Route as ApiDashboardOverviewRouteImport } from './routes/api/dashboard/overview'
import { Route as ApiClaudeTasksTaskIdRouteImport } from './routes/api/claude-tasks.$taskId'
import { Route as ApiClaudeProxySplatRouteImport } from './routes/api/claude-proxy/$'
@@ -145,6 +149,7 @@ import { Route as ApiSessionsSessionKeyStatusRouteImport } from './routes/api/se
import { Route as ApiSessionsSessionKeyActiveRunRouteImport } from './routes/api/sessions/$sessionKey.active-run'
import { Route as ApiMcpHubSourcesIdRouteImport } from './routes/api/mcp/hub-sources.$id'
import { Route as ApiMcpNameLogsRouteImport } from './routes/api/mcp/$name.logs'
import { Route as ApiHermesworldReservationsConfirmRouteImport } from './routes/api/hermesworld/reservations/confirm'
const WorldRoute = WorldRouteImport.update({
id: '/world',
@@ -181,6 +186,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const ReserveRoute = ReserveRouteImport.update({
id: '/reserve',
path: '/reserve',
getParentRoute: () => rootRouteImport,
} as any)
const ProfilesRoute = ProfilesRouteImport.update({
id: '/profiles',
path: '/profiles',
@@ -221,6 +231,11 @@ const FilesRoute = FilesRouteImport.update({
path: '/files',
getParentRoute: () => rootRouteImport,
} as any)
const EarlyAccessRoute = EarlyAccessRouteImport.update({
id: '/early-access',
path: '/early-access',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
@@ -261,6 +276,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
path: '/providers',
getParentRoute: () => SettingsRoute,
} as any)
const ReserveConfirmRoute = ReserveConfirmRouteImport.update({
id: '/confirm',
path: '/confirm',
getParentRoute: () => ReserveRoute,
} as any)
const ChatSessionKeyRoute = ChatSessionKeyRouteImport.update({
id: '/chat/$sessionKey',
path: '/chat/$sessionKey',
@@ -782,6 +802,12 @@ const ApiKnowledgeConfigRoute = ApiKnowledgeConfigRouteImport.update({
path: '/api/knowledge/config',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHermesworldReservationsRoute =
ApiHermesworldReservationsRouteImport.update({
id: '/api/hermesworld/reservations',
path: '/api/hermesworld/reservations',
getParentRoute: () => rootRouteImport,
} as any)
const ApiDashboardOverviewRoute = ApiDashboardOverviewRouteImport.update({
id: '/api/dashboard/overview',
path: '/api/dashboard/overview',
@@ -829,6 +855,12 @@ const ApiMcpNameLogsRoute = ApiMcpNameLogsRouteImport.update({
path: '/logs',
getParentRoute: () => ApiMcpNameRoute,
} as any)
const ApiHermesworldReservationsConfirmRoute =
ApiHermesworldReservationsConfirmRouteImport.update({
id: '/confirm',
path: '/confirm',
getParentRoute: () => ApiHermesworldReservationsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -836,6 +868,7 @@ export interface FileRoutesByFullPath {
'/agora': typeof AgoraRoute
'/conductor': typeof ConductorRoute
'/dashboard': typeof DashboardRoute
'/early-access': typeof EarlyAccessRoute
'/files': typeof FilesRoute
'/hermes-world': typeof HermesWorldRoute
'/jobs': typeof JobsRoute
@@ -844,6 +877,7 @@ export interface FileRoutesByFullPath {
'/operations': typeof OperationsRoute
'/playground': typeof PlaygroundRoute
'/profiles': typeof ProfilesRoute
'/reserve': typeof ReserveRouteWithChildren
'/settings': typeof SettingsRouteWithChildren
'/skills': typeof SkillsRoute
'/swarm': typeof SwarmRoute
@@ -919,6 +953,7 @@ export interface FileRoutesByFullPath {
'/api/terminal-stream': typeof ApiTerminalStreamRoute
'/api/workspace': typeof ApiWorkspaceRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
'/reserve/confirm': typeof ReserveConfirmRoute
'/settings/providers': typeof SettingsProvidersRoute
'/chat/': typeof ChatIndexRoute
'/settings/': typeof SettingsIndexRoute
@@ -927,6 +962,7 @@ export interface FileRoutesByFullPath {
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/hermesworld/reservations': typeof ApiHermesworldReservationsRouteWithChildren
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
@@ -963,6 +999,7 @@ export interface FileRoutesByFullPath {
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
'/api/hermesworld/reservations/confirm': typeof ApiHermesworldReservationsConfirmRoute
'/api/mcp/$name/logs': typeof ApiMcpNameLogsRoute
'/api/mcp/hub-sources/$id': typeof ApiMcpHubSourcesIdRoute
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
@@ -974,6 +1011,7 @@ export interface FileRoutesByTo {
'/agora': typeof AgoraRoute
'/conductor': typeof ConductorRoute
'/dashboard': typeof DashboardRoute
'/early-access': typeof EarlyAccessRoute
'/files': typeof FilesRoute
'/hermes-world': typeof HermesWorldRoute
'/jobs': typeof JobsRoute
@@ -982,6 +1020,7 @@ export interface FileRoutesByTo {
'/operations': typeof OperationsRoute
'/playground': typeof PlaygroundRoute
'/profiles': typeof ProfilesRoute
'/reserve': typeof ReserveRouteWithChildren
'/skills': typeof SkillsRoute
'/swarm': typeof SwarmRoute
'/swarm2': typeof Swarm2Route
@@ -1056,6 +1095,7 @@ export interface FileRoutesByTo {
'/api/terminal-stream': typeof ApiTerminalStreamRoute
'/api/workspace': typeof ApiWorkspaceRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
'/reserve/confirm': typeof ReserveConfirmRoute
'/settings/providers': typeof SettingsProvidersRoute
'/chat': typeof ChatIndexRoute
'/settings': typeof SettingsIndexRoute
@@ -1064,6 +1104,7 @@ export interface FileRoutesByTo {
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/hermesworld/reservations': typeof ApiHermesworldReservationsRouteWithChildren
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
@@ -1100,6 +1141,7 @@ export interface FileRoutesByTo {
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
'/api/hermesworld/reservations/confirm': typeof ApiHermesworldReservationsConfirmRoute
'/api/mcp/$name/logs': typeof ApiMcpNameLogsRoute
'/api/mcp/hub-sources/$id': typeof ApiMcpHubSourcesIdRoute
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
@@ -1112,6 +1154,7 @@ export interface FileRoutesById {
'/agora': typeof AgoraRoute
'/conductor': typeof ConductorRoute
'/dashboard': typeof DashboardRoute
'/early-access': typeof EarlyAccessRoute
'/files': typeof FilesRoute
'/hermes-world': typeof HermesWorldRoute
'/jobs': typeof JobsRoute
@@ -1120,6 +1163,7 @@ export interface FileRoutesById {
'/operations': typeof OperationsRoute
'/playground': typeof PlaygroundRoute
'/profiles': typeof ProfilesRoute
'/reserve': typeof ReserveRouteWithChildren
'/settings': typeof SettingsRouteWithChildren
'/skills': typeof SkillsRoute
'/swarm': typeof SwarmRoute
@@ -1195,6 +1239,7 @@ export interface FileRoutesById {
'/api/terminal-stream': typeof ApiTerminalStreamRoute
'/api/workspace': typeof ApiWorkspaceRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
'/reserve/confirm': typeof ReserveConfirmRoute
'/settings/providers': typeof SettingsProvidersRoute
'/chat/': typeof ChatIndexRoute
'/settings/': typeof SettingsIndexRoute
@@ -1203,6 +1248,7 @@ export interface FileRoutesById {
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/hermesworld/reservations': typeof ApiHermesworldReservationsRouteWithChildren
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
@@ -1239,6 +1285,7 @@ export interface FileRoutesById {
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
'/api/hermesworld/reservations/confirm': typeof ApiHermesworldReservationsConfirmRoute
'/api/mcp/$name/logs': typeof ApiMcpNameLogsRoute
'/api/mcp/hub-sources/$id': typeof ApiMcpHubSourcesIdRoute
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
@@ -1252,6 +1299,7 @@ export interface FileRouteTypes {
| '/agora'
| '/conductor'
| '/dashboard'
| '/early-access'
| '/files'
| '/hermes-world'
| '/jobs'
@@ -1260,6 +1308,7 @@ export interface FileRouteTypes {
| '/operations'
| '/playground'
| '/profiles'
| '/reserve'
| '/settings'
| '/skills'
| '/swarm'
@@ -1335,6 +1384,7 @@ export interface FileRouteTypes {
| '/api/terminal-stream'
| '/api/workspace'
| '/chat/$sessionKey'
| '/reserve/confirm'
| '/settings/providers'
| '/chat/'
| '/settings/'
@@ -1343,6 +1393,7 @@ export interface FileRouteTypes {
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
| '/api/dashboard/overview'
| '/api/hermesworld/reservations'
| '/api/knowledge/config'
| '/api/knowledge/graph'
| '/api/knowledge/list'
@@ -1379,6 +1430,7 @@ export interface FileRouteTypes {
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
| '/api/hermesworld/reservations/confirm'
| '/api/mcp/$name/logs'
| '/api/mcp/hub-sources/$id'
| '/api/sessions/$sessionKey/active-run'
@@ -1390,6 +1442,7 @@ export interface FileRouteTypes {
| '/agora'
| '/conductor'
| '/dashboard'
| '/early-access'
| '/files'
| '/hermes-world'
| '/jobs'
@@ -1398,6 +1451,7 @@ export interface FileRouteTypes {
| '/operations'
| '/playground'
| '/profiles'
| '/reserve'
| '/skills'
| '/swarm'
| '/swarm2'
@@ -1472,6 +1526,7 @@ export interface FileRouteTypes {
| '/api/terminal-stream'
| '/api/workspace'
| '/chat/$sessionKey'
| '/reserve/confirm'
| '/settings/providers'
| '/chat'
| '/settings'
@@ -1480,6 +1535,7 @@ export interface FileRouteTypes {
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
| '/api/dashboard/overview'
| '/api/hermesworld/reservations'
| '/api/knowledge/config'
| '/api/knowledge/graph'
| '/api/knowledge/list'
@@ -1516,6 +1572,7 @@ export interface FileRouteTypes {
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
| '/api/hermesworld/reservations/confirm'
| '/api/mcp/$name/logs'
| '/api/mcp/hub-sources/$id'
| '/api/sessions/$sessionKey/active-run'
@@ -1527,6 +1584,7 @@ export interface FileRouteTypes {
| '/agora'
| '/conductor'
| '/dashboard'
| '/early-access'
| '/files'
| '/hermes-world'
| '/jobs'
@@ -1535,6 +1593,7 @@ export interface FileRouteTypes {
| '/operations'
| '/playground'
| '/profiles'
| '/reserve'
| '/settings'
| '/skills'
| '/swarm'
@@ -1610,6 +1669,7 @@ export interface FileRouteTypes {
| '/api/terminal-stream'
| '/api/workspace'
| '/chat/$sessionKey'
| '/reserve/confirm'
| '/settings/providers'
| '/chat/'
| '/settings/'
@@ -1618,6 +1678,7 @@ export interface FileRouteTypes {
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
| '/api/dashboard/overview'
| '/api/hermesworld/reservations'
| '/api/knowledge/config'
| '/api/knowledge/graph'
| '/api/knowledge/list'
@@ -1654,6 +1715,7 @@ export interface FileRouteTypes {
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
| '/api/hermesworld/reservations/confirm'
| '/api/mcp/$name/logs'
| '/api/mcp/hub-sources/$id'
| '/api/sessions/$sessionKey/active-run'
@@ -1666,6 +1728,7 @@ export interface RootRouteChildren {
AgoraRoute: typeof AgoraRoute
ConductorRoute: typeof ConductorRoute
DashboardRoute: typeof DashboardRoute
EarlyAccessRoute: typeof EarlyAccessRoute
FilesRoute: typeof FilesRoute
HermesWorldRoute: typeof HermesWorldRoute
JobsRoute: typeof JobsRoute
@@ -1674,6 +1737,7 @@ export interface RootRouteChildren {
OperationsRoute: typeof OperationsRoute
PlaygroundRoute: typeof PlaygroundRoute
ProfilesRoute: typeof ProfilesRoute
ReserveRoute: typeof ReserveRouteWithChildren
SettingsRoute: typeof SettingsRouteWithChildren
SkillsRoute: typeof SkillsRoute
SwarmRoute: typeof SwarmRoute
@@ -1752,6 +1816,7 @@ export interface RootRouteChildren {
ChatIndexRoute: typeof ChatIndexRoute
ApiClaudeProxySplatRoute: typeof ApiClaudeProxySplatRoute
ApiDashboardOverviewRoute: typeof ApiDashboardOverviewRoute
ApiHermesworldReservationsRoute: typeof ApiHermesworldReservationsRouteWithChildren
ApiKnowledgeConfigRoute: typeof ApiKnowledgeConfigRoute
ApiKnowledgeGraphRoute: typeof ApiKnowledgeGraphRoute
ApiKnowledgeListRoute: typeof ApiKnowledgeListRoute
@@ -1824,6 +1889,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/reserve': {
id: '/reserve'
path: '/reserve'
fullPath: '/reserve'
preLoaderRoute: typeof ReserveRouteImport
parentRoute: typeof rootRouteImport
}
'/profiles': {
id: '/profiles'
path: '/profiles'
@@ -1880,6 +1952,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FilesRouteImport
parentRoute: typeof rootRouteImport
}
'/early-access': {
id: '/early-access'
path: '/early-access'
fullPath: '/early-access'
preLoaderRoute: typeof EarlyAccessRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
@@ -1936,6 +2015,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsProvidersRouteImport
parentRoute: typeof SettingsRoute
}
'/reserve/confirm': {
id: '/reserve/confirm'
path: '/confirm'
fullPath: '/reserve/confirm'
preLoaderRoute: typeof ReserveConfirmRouteImport
parentRoute: typeof ReserveRoute
}
'/chat/$sessionKey': {
id: '/chat/$sessionKey'
path: '/chat/$sessionKey'
@@ -2664,6 +2750,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiKnowledgeConfigRouteImport
parentRoute: typeof rootRouteImport
}
'/api/hermesworld/reservations': {
id: '/api/hermesworld/reservations'
path: '/api/hermesworld/reservations'
fullPath: '/api/hermesworld/reservations'
preLoaderRoute: typeof ApiHermesworldReservationsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/dashboard/overview': {
id: '/api/dashboard/overview'
path: '/api/dashboard/overview'
@@ -2727,8 +2820,26 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiMcpNameLogsRouteImport
parentRoute: typeof ApiMcpNameRoute
}
'/api/hermesworld/reservations/confirm': {
id: '/api/hermesworld/reservations/confirm'
path: '/confirm'
fullPath: '/api/hermesworld/reservations/confirm'
preLoaderRoute: typeof ApiHermesworldReservationsConfirmRouteImport
parentRoute: typeof ApiHermesworldReservationsRoute
}
}
}
interface ReserveRouteChildren {
ReserveConfirmRoute: typeof ReserveConfirmRoute
}
const ReserveRouteChildren: ReserveRouteChildren = {
ReserveConfirmRoute: ReserveConfirmRoute,
}
const ReserveRouteWithChildren =
ReserveRoute._addFileChildren(ReserveRouteChildren)
interface SettingsRouteChildren {
SettingsProvidersRoute: typeof SettingsProvidersRoute
@@ -2890,12 +3001,28 @@ const ApiSwarmMemoryRouteWithChildren = ApiSwarmMemoryRoute._addFileChildren(
ApiSwarmMemoryRouteChildren,
)
interface ApiHermesworldReservationsRouteChildren {
ApiHermesworldReservationsConfirmRoute: typeof ApiHermesworldReservationsConfirmRoute
}
const ApiHermesworldReservationsRouteChildren: ApiHermesworldReservationsRouteChildren =
{
ApiHermesworldReservationsConfirmRoute:
ApiHermesworldReservationsConfirmRoute,
}
const ApiHermesworldReservationsRouteWithChildren =
ApiHermesworldReservationsRoute._addFileChildren(
ApiHermesworldReservationsRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SplatRoute: SplatRoute,
AgoraRoute: AgoraRoute,
ConductorRoute: ConductorRoute,
DashboardRoute: DashboardRoute,
EarlyAccessRoute: EarlyAccessRoute,
FilesRoute: FilesRoute,
HermesWorldRoute: HermesWorldRoute,
JobsRoute: JobsRoute,
@@ -2904,6 +3031,7 @@ const rootRouteChildren: RootRouteChildren = {
OperationsRoute: OperationsRoute,
PlaygroundRoute: PlaygroundRoute,
ProfilesRoute: ProfilesRoute,
ReserveRoute: ReserveRouteWithChildren,
SettingsRoute: SettingsRouteWithChildren,
SkillsRoute: SkillsRoute,
SwarmRoute: SwarmRoute,
@@ -2982,6 +3110,7 @@ const rootRouteChildren: RootRouteChildren = {
ChatIndexRoute: ChatIndexRoute,
ApiClaudeProxySplatRoute: ApiClaudeProxySplatRoute,
ApiDashboardOverviewRoute: ApiDashboardOverviewRoute,
ApiHermesworldReservationsRoute: ApiHermesworldReservationsRouteWithChildren,
ApiKnowledgeConfigRoute: ApiKnowledgeConfigRoute,
ApiKnowledgeGraphRoute: ApiKnowledgeGraphRoute,
ApiKnowledgeListRoute: ApiKnowledgeListRoute,

View File

@@ -0,0 +1,79 @@
import { createFileRoute } from '@tanstack/react-router'
import {
countReservations,
createReservation,
createSupabaseReservationStore,
ReservationValidationError,
sendReservationConfirmationEmail,
} from '@/server/name-reservations'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '@/server/rate-limit'
function requestBaseUrl(request: Request): string {
const url = new URL(request.url)
return `${url.protocol}//${url.host}`
}
export const Route = createFileRoute('/api/hermesworld/reservations')({
server: {
handlers: {
GET: async () => {
try {
const store = createSupabaseReservationStore()
const count = await countReservations(store)
return Response.json({ ok: true, count })
} catch (error) {
return Response.json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 500 },
)
}
},
POST: async ({ request }) => {
const contentTypeError = requireJsonContentType(request)
if (contentTypeError) return contentTypeError
const ip = getClientIp(request)
if (!rateLimit(`reserve:${ip}`, 5, 10 * 60 * 1000)) {
return rateLimitResponse()
}
try {
const body = await request.json()
const store = createSupabaseReservationStore()
const reservation = await createReservation(body, {
store,
sendConfirmationEmail: sendReservationConfirmationEmail,
baseUrl: requestBaseUrl(request),
})
return Response.json({
ok: true,
reservation: {
desiredName: reservation.desiredName,
email: reservation.email,
wallet: reservation.wallet,
confirmedAt: reservation.confirmedAt,
createdAt: reservation.createdAt,
},
})
} catch (error) {
if (error instanceof ReservationValidationError) {
return Response.json(
{ ok: false, error: error.message },
{ status: error.status },
)
}
return Response.json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 500 },
)
}
},
},
},
})

View File

@@ -0,0 +1,45 @@
import { createFileRoute } from '@tanstack/react-router'
import {
confirmReservation,
createSupabaseReservationStore,
ReservationValidationError,
} from '@/server/name-reservations'
import { safeErrorMessage } from '@/server/rate-limit'
export const Route = createFileRoute('/api/hermesworld/reservations/confirm')({
server: {
handlers: {
POST: async ({ request }) => {
try {
const { token } = (await request.json()) as { token?: string }
const store = createSupabaseReservationStore()
const reservation = await confirmReservation(token || '', store)
if (!reservation) {
return Response.json(
{ ok: false, error: 'Confirmation token not found.' },
{ status: 404 },
)
}
return Response.json({
ok: true,
reservation: {
desiredName: reservation.desiredName,
confirmedAt: reservation.confirmedAt,
},
})
} catch (error) {
if (error instanceof ReservationValidationError) {
return Response.json(
{ ok: false, error: error.message },
{ status: error.status },
)
}
return Response.json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 500 },
)
}
},
},
},
})

View File

@@ -0,0 +1,98 @@
import { createFileRoute } from '@tanstack/react-router'
import { usePageTitle } from '@/hooks/use-page-title'
const HERMES_REPO_URL = 'https://github.com/outsourc-e/hermes-workspace'
const HERMES_DISCORD_URL = 'https://discord.com/invite/agentd'
export const Route = createFileRoute('/early-access')({
ssr: false,
component: EarlyAccessRoute,
})
function EarlyAccessRoute() {
usePageTitle('HermesWorld — Early Access')
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#03060a] px-4 text-[#f8f3e7] selection:bg-[#d9b35f] selection:text-[#07080d]">
<div className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[linear-gradient(180deg,#071018_0%,#03060a_55%,#020305_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_22%,rgba(217,179,95,.18),transparent_32%),radial-gradient(circle_at_78%_18%,rgba(34,211,238,.16),transparent_30%),radial-gradient(circle_at_82%_78%,rgba(167,139,250,.14),transparent_32%)]" />
<div className="absolute inset-0 opacity-[0.13] [background-image:linear-gradient(rgba(248,228,172,.12)_1px,transparent_1px),linear-gradient(90deg,rgba(248,228,172,.12)_1px,transparent_1px)] [background-size:72px_72px]" />
</div>
<div className="mx-auto w-full max-w-[760px] rounded-[2rem] border border-[#d9b35f]/24 bg-[#05080e]/82 p-8 shadow-[0_40px_140px_rgba(0,0,0,.52)] backdrop-blur-2xl sm:p-12">
<div className="flex items-center gap-3">
<img src="/hermesworld-logo.svg" alt="HermesWorld" className="h-10 w-10 rounded-2xl shadow-[0_0_34px_rgba(34,211,238,.18)]" />
<div>
<div className="font-serif text-lg font-bold tracking-[-0.03em] text-[#f8e4ac]">
Hermes<span className="text-cyan-200">World</span>
</div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#bfb49a]/52">
Persistent agent RPG
</div>
</div>
</div>
<div className="mt-8">
<span className="inline-flex items-center gap-2 rounded-full border border-[#d9b35f]/30 bg-[#d9b35f]/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-[0.22em] text-[#f8e4ac]">
<span className="h-1.5 w-1.5 rounded-full bg-cyan-200 shadow-[0_0_18px_rgba(34,211,238,.95)]" />
Early access keys rolling out
</span>
<h1 className="mt-5 font-serif text-4xl font-bold leading-[0.92] tracking-[-0.045em] text-[#fff6df] sm:text-6xl">
HermesWorld is opening soon.
</h1>
<p className="mt-5 max-w-[560px] text-base leading-7 text-[#d7d0bd]/68 sm:text-lg">
We are polishing characters, the Agora plaza, and the launch trailer
before opening multiplayer to the public. Join Discord for early-access
keys and gameplay clips, or pull the open-source workspace and play
locally today.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<a
href={HERMES_DISCORD_URL}
target="_blank"
rel="noreferrer"
className="group inline-flex items-center justify-center rounded-xl border border-[#ffe7a3]/55 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-7 py-4 text-sm font-black uppercase tracking-[0.16em] text-[#11100b] shadow-[0_30px_90px_rgba(217,179,95,.32),inset_0_1px_0_rgba(255,255,255,.32)] transition hover:-translate-y-0.5 hover:brightness-110"
>
Join Discord for keys
<span className="ml-2 transition group-hover:translate-x-1"></span>
</a>
<a
href={HERMES_REPO_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-xl border border-[#d9b35f]/24 bg-[#0b1118]/82 px-6 py-4 text-sm font-black uppercase tracking-[0.16em] text-[#f8e4ac]/85 shadow-[inset_0_1px_0_rgba(255,255,255,.08)] backdrop-blur-xl transition hover:border-[#d9b35f]/55 hover:bg-[#121823]"
>
Play locally on GitHub
</a>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{[
['1', 'Star the repo', 'Star Hermes Workspace on GitHub for updates.'],
['2', 'Hop in Discord', 'Get notified the moment public play is live.'],
['3', 'Watch the trailer', 'The launch trailer drops with the public world.'],
].map(([i, title, copy]) => (
<div
key={i}
className="rounded-2xl border border-white/10 bg-black/24 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,.05)]"
>
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/72">
Step {i}
</div>
<div className="mt-2 text-sm font-bold text-[#fff6df]">{title}</div>
<div className="mt-1 text-xs leading-5 text-[#d7d0bd]/55">{copy}</div>
</div>
))}
</div>
<div className="mt-8 text-[11px] uppercase tracking-[0.2em] text-[#bfb49a]/50">
<a href="/hermes-world" className="hover:text-[#f8e4ac]">
Back to landing
</a>
</div>
</div>
</div>
</main>
)
}

283
src/routes/reserve.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { usePageTitle } from '@/hooks/use-page-title'
type CounterState = {
loading: boolean
count: number
error: string | null
}
type SubmitState =
| { status: 'idle'; message: string | null }
| { status: 'submitting'; message: string | null }
| { status: 'success'; message: string }
| { status: 'error'; message: string }
export const Route = createFileRoute('/reserve')({
ssr: false,
component: ReserveRoute,
})
function ReserveRoute() {
usePageTitle('Reserve your HermesWorld name')
const [desiredName, setDesiredName] = useState('')
const [email, setEmail] = useState('')
const [wallet, setWallet] = useState('')
const [counter, setCounter] = useState<CounterState>({
loading: true,
count: 0,
error: null,
})
const [submitState, setSubmitState] = useState<SubmitState>({
status: 'idle',
message: null,
})
useEffect(() => {
let cancelled = false
fetch('/api/hermesworld/reservations', { cache: 'no-store' })
.then(async (response) => {
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || 'Failed to load counter')
if (!cancelled) {
setCounter({ loading: false, count: payload.count || 0, error: null })
}
})
.catch((error: Error) => {
if (!cancelled) {
setCounter({ loading: false, count: 0, error: error.message })
}
})
return () => {
cancelled = true
}
}, [])
const isDisabled = submitState.status === 'submitting'
const trimmedName = useMemo(() => desiredName.trim(), [desiredName])
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitState({ status: 'submitting', message: null })
try {
const response = await fetch('/api/hermesworld/reservations', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
desiredName,
email,
wallet,
}),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || 'Reservation failed')
}
setSubmitState({
status: 'success',
message: `Reserved ${payload.reservation.desiredName}. Check ${payload.reservation.email} for the confirmation link.`,
})
setDesiredName('')
setEmail('')
setWallet('')
setCounter((current) => ({
...current,
count: current.count + 1,
}))
} catch (error: any) {
setSubmitState({
status: 'error',
message: error?.message || 'Reservation failed',
})
}
}
return (
<main className="relative min-h-screen overflow-hidden bg-[#03060a] px-4 py-8 text-[#f8f3e7] selection:bg-[#d9b35f] selection:text-[#07080d] sm:px-6 lg:px-8">
<div className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[linear-gradient(180deg,#071018_0%,#03060a_52%,#020305_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_22%,rgba(217,179,95,.18),transparent_32%),radial-gradient(circle_at_78%_18%,rgba(34,211,238,.16),transparent_30%),radial-gradient(circle_at_82%_78%,rgba(167,139,250,.14),transparent_32%)]" />
</div>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 lg:grid lg:grid-cols-[0.9fr_1.1fr] lg:gap-10">
<section className="rounded-[2rem] border border-[#d9b35f]/24 bg-[#05080e]/82 p-7 shadow-[0_40px_140px_rgba(0,0,0,.52)] backdrop-blur-2xl sm:p-9">
<a href="/hermes-world" className="text-[11px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/72 hover:text-[#f8e4ac]">
Back to HermesWorld
</a>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-[#d9b35f]/30 bg-[#d9b35f]/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-[0.22em] text-[#f8e4ac]">
Name reservation live
</div>
<h1 className="mt-5 font-serif text-4xl font-bold leading-[0.92] tracking-[-0.05em] text-[#fff6df] sm:text-6xl">
Reserve your HermesWorld name before accounts launch.
</h1>
<p className="mt-5 max-w-xl text-base leading-7 text-[#d7d0bd]/68 sm:text-lg">
Lock your desired handle now. We validate duplicates, profanity, and admin/system names server-side, then email you a confirmation link so the reservation can auto-bind when the account system goes live.
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
<StatCard
label="Reservations"
value={counter.loading ? '...' : String(counter.count)}
tone="gold"
subcopy={counter.error ? 'Counter temporarily unavailable' : 'Public live counter'}
/>
<StatCard label="Name rules" value="320" tone="cyan" subcopy="Letters, numbers, underscores" />
<StatCard label="Confirmation" value="Email" tone="violet" subcopy="One-click verification" />
</div>
<div className="mt-8 rounded-2xl border border-white/10 bg-black/24 p-5">
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/72">
Reservation notes
</div>
<ul className="mt-3 space-y-2 text-sm leading-6 text-[#d7d0bd]/64">
<li> Desired names must use letters, numbers, or underscores only.</li>
<li> Duplicate names are rejected immediately.</li>
<li> Wallet is optional today, but helps with future account linking.</li>
<li> Confirmation email required before the reservation is considered locked.</li>
</ul>
</div>
</section>
<section className="rounded-[2rem] border border-[#d9b35f]/24 bg-[#05080e]/88 p-7 shadow-[0_40px_140px_rgba(0,0,0,.52)] backdrop-blur-2xl sm:p-9">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/72">
Reserve handle
</div>
<div className="mt-2 text-2xl font-bold text-[#fff6df]">
{trimmedName ? `Claim ${trimmedName}` : 'Enter your launch-day name'}
</div>
</div>
<div className="rounded-full border border-cyan-200/22 bg-cyan-200/10 px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-cyan-100/82">
hermes-world.ai/reserve
</div>
</div>
<form className="mt-8 space-y-5" onSubmit={onSubmit}>
<Field
label="Desired name"
hint="3-20 chars • alnum + underscore"
value={desiredName}
onChange={setDesiredName}
placeholder="Atlas_Builder"
disabled={isDisabled}
required
/>
<Field
label="Email"
hint="We send the confirmation link here"
value={email}
onChange={setEmail}
placeholder="you@example.com"
disabled={isDisabled}
required
type="email"
/>
<Field
label="Wallet"
hint="Optional today — useful for launch binding"
value={wallet}
onChange={setWallet}
placeholder="0x... or wallet alias"
disabled={isDisabled}
/>
<button
type="submit"
disabled={isDisabled}
className="inline-flex w-full items-center justify-center rounded-xl border border-[#ffe7a3]/55 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-6 py-4 text-sm font-black uppercase tracking-[0.16em] text-[#11100b] shadow-[0_30px_90px_rgba(217,179,95,.32),inset_0_1px_0_rgba(255,255,255,.32)] transition enabled:hover:-translate-y-0.5 enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{isDisabled ? 'Submitting…' : 'Reserve name'}
</button>
</form>
{submitState.message ? (
<div
className={[
'mt-5 rounded-2xl border px-4 py-3 text-sm leading-6',
submitState.status === 'success'
? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100'
: submitState.status === 'error'
? 'border-rose-400/25 bg-rose-400/10 text-rose-100'
: 'border-white/10 bg-white/5 text-[#d7d0bd]',
].join(' ')}
>
{submitState.message}
</div>
) : null}
</section>
</div>
</main>
)
}
function Field({
label,
hint,
value,
onChange,
placeholder,
disabled,
required,
type = 'text',
}: {
label: string
hint: string
value: string
onChange: (value: string) => void
placeholder: string
disabled: boolean
required?: boolean
type?: string
}) {
return (
<label className="block">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-[#fff6df]">{label}</span>
<span className="text-[11px] text-[#d7d0bd]/48">{hint}</span>
</div>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
required={required}
className="mt-2 h-12 w-full rounded-xl border border-white/10 bg-[#0b1118]/90 px-4 text-sm text-[#fff6df] outline-none ring-0 transition placeholder:text-[#d7d0bd]/30 focus:border-[#d9b35f]/55"
/>
</label>
)
}
function StatCard({
label,
value,
subcopy,
tone,
}: {
label: string
value: string
subcopy: string
tone: 'gold' | 'cyan' | 'violet'
}) {
const accent =
tone === 'gold'
? 'text-[#f8e4ac] border-[#d9b35f]/24 bg-[#d9b35f]/10'
: tone === 'cyan'
? 'text-cyan-100 border-cyan-200/24 bg-cyan-200/10'
: 'text-violet-100 border-violet-200/24 bg-violet-200/10'
return (
<div className="rounded-2xl border border-white/10 bg-black/24 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,.05)]">
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/72">{label}</div>
<div className={`mt-3 inline-flex rounded-full border px-3 py-1 text-lg font-black ${accent}`}>
{value}
</div>
<div className="mt-3 text-xs leading-5 text-[#d7d0bd]/55">{subcopy}</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { usePageTitle } from '@/hooks/use-page-title'
export const Route = createFileRoute('/reserve/confirm')({
ssr: false,
component: ReserveConfirmRoute,
})
function ReserveConfirmRoute() {
usePageTitle('Confirm HermesWorld reservation')
const token = Route.useSearch({ strict: false }).token || ''
const [state, setState] = useState<{
status: 'loading' | 'success' | 'error'
message: string
}>({
status: 'loading',
message: 'Confirming your reservation…',
})
useEffect(() => {
if (!token) {
setState({
status: 'error',
message: 'Missing confirmation token. Re-open the link from your email.',
})
return
}
fetch('/api/hermesworld/reservations/confirm', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
})
.then(async (response) => {
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || 'Confirmation failed')
}
setState({
status: 'success',
message: `${payload.reservation.desiredName} is now confirmed for launch.`,
})
})
.catch((error: Error) => {
setState({
status: 'error',
message: error.message,
})
})
}, [token])
return (
<main className="flex min-h-screen items-center justify-center bg-[#03060a] px-4 text-[#f8f3e7]">
<div className="w-full max-w-xl rounded-[2rem] border border-[#d9b35f]/24 bg-[#05080e]/88 p-8 shadow-[0_40px_140px_rgba(0,0,0,.52)] backdrop-blur-2xl sm:p-10">
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/72">
Email confirmation
</div>
<h1 className="mt-4 font-serif text-4xl font-bold leading-[0.95] tracking-[-0.05em] text-[#fff6df]">
{state.status === 'success'
? 'Name locked in.'
: state.status === 'error'
? 'Confirmation problem.'
: 'Confirming reservation…'}
</h1>
<p className="mt-4 text-base leading-7 text-[#d7d0bd]/68">{state.message}</p>
<div className="mt-8 flex flex-wrap gap-3">
<a
href="/reserve"
className="inline-flex items-center justify-center rounded-xl border border-[#ffe7a3]/55 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-6 py-3 text-sm font-black uppercase tracking-[0.16em] text-[#11100b] shadow-[0_30px_90px_rgba(217,179,95,.32),inset_0_1px_0_rgba(255,255,255,.32)]"
>
Back to reserve
</a>
<a
href="/hermes-world"
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-6 py-3 text-sm font-black uppercase tracking-[0.16em] text-[#f8e4ac]"
>
HermesWorld landing
</a>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest'
import {
ReservationValidationError,
confirmReservation,
countReservations,
createReservation,
validateReservationInput,
type NameReservationStore,
} from './name-reservations'
function makeStore(seed: {
reservations?: Array<{
id: string
desiredName: string
normalizedName: string
email: string
wallet: string | null
confirmationToken: string
confirmedAt: string | null
createdAt: string
}>
} = {}): NameReservationStore {
const reservations = [...(seed.reservations || [])]
return {
async findByNormalizedName(normalizedName) {
return reservations.find((entry) => entry.normalizedName === normalizedName) || null
},
async insertReservation(input) {
const created = {
id: `res_${reservations.length + 1}`,
desiredName: input.desiredName,
normalizedName: input.normalizedName,
email: input.email,
wallet: input.wallet,
confirmationToken: input.confirmationToken,
confirmedAt: null,
createdAt: '2026-05-06T12:00:00.000Z',
}
reservations.push(created)
return created
},
async countReservations() {
return reservations.length
},
async confirmByToken(token) {
const found = reservations.find((entry) => entry.confirmationToken === token)
if (!found) return null
if (!found.confirmedAt) {
found.confirmedAt = '2026-05-06T12:05:00.000Z'
}
return found
},
}
}
describe('validateReservationInput', () => {
it('accepts valid names, emails, and optional wallets', () => {
expect(
validateReservationInput({
desiredName: 'Guild_Mage_7',
email: 'player@example.com',
wallet: '0x1234567890abcdef1234567890abcdef12345678',
}),
).toEqual({
desiredName: 'Guild_Mage_7',
normalizedName: 'guild_mage_7',
email: 'player@example.com',
wallet: '0x1234567890abcdef1234567890abcdef12345678',
})
})
it('rejects invalid names before touching storage', () => {
expect(() =>
validateReservationInput({
desiredName: 'bad name',
email: 'player@example.com',
wallet: '',
}),
).toThrowError(ReservationValidationError)
})
})
describe('createReservation', () => {
it('rejects profanity, reserved names, and duplicates', async () => {
const duplicateStore = makeStore({
reservations: [
{
id: 'res_1',
desiredName: 'Atlas',
normalizedName: 'atlas',
email: 'atlas@example.com',
wallet: null,
confirmationToken: 'tok_existing',
confirmedAt: null,
createdAt: '2026-05-06T12:00:00.000Z',
},
],
})
await expect(
createReservation(
{
desiredName: 'admin',
email: 'player@example.com',
wallet: null,
},
{
store: duplicateStore,
sendConfirmationEmail: async () => {},
now: () => new Date('2026-05-06T12:00:00.000Z'),
randomToken: () => 'tok_1',
},
),
).rejects.toThrow('reserved')
await expect(
createReservation(
{
desiredName: 'shitmage',
email: 'player@example.com',
wallet: null,
},
{
store: duplicateStore,
sendConfirmationEmail: async () => {},
now: () => new Date('2026-05-06T12:00:00.000Z'),
randomToken: () => 'tok_2',
},
),
).rejects.toThrow('profanity')
await expect(
createReservation(
{
desiredName: 'Atlas',
email: 'new@example.com',
wallet: null,
},
{
store: duplicateStore,
sendConfirmationEmail: async () => {},
now: () => new Date('2026-05-06T12:00:00.000Z'),
randomToken: () => 'tok_3',
},
),
).rejects.toThrow('already reserved')
})
it('stores a pending reservation and sends a confirmation email', async () => {
const store = makeStore()
const sent: Array<{ email: string; desiredName: string; confirmationUrl: string }> = []
const created = await createReservation(
{
desiredName: 'AgoraScout',
email: 'scout@example.com',
wallet: 'solana_wallet_123456',
},
{
store,
sendConfirmationEmail: async (payload) => {
sent.push(payload)
},
baseUrl: 'https://hermes-world.ai',
now: () => new Date('2026-05-06T12:00:00.000Z'),
randomToken: () => 'tok_new',
},
)
expect(created.normalizedName).toBe('agorascout')
expect(created.confirmationToken).toBe('tok_new')
expect(sent).toEqual([
{
email: 'scout@example.com',
desiredName: 'AgoraScout',
confirmationUrl: 'https://hermes-world.ai/reserve/confirm?token=tok_new',
},
])
await expect(countReservations(store)).resolves.toBe(1)
})
it('supports three sequential successful reservations', async () => {
const store = makeStore()
const sent: string[] = []
const attempts = [
{ desiredName: 'AtlasOne', email: 'player1@example.com', token: 'tok_1' },
{ desiredName: 'BeaconTwo', email: 'player2@example.com', token: 'tok_2' },
{ desiredName: 'Cipher_3', email: 'player3@example.com', token: 'tok_3' },
]
for (const attempt of attempts) {
await createReservation(
{
desiredName: attempt.desiredName,
email: attempt.email,
wallet: null,
},
{
store,
sendConfirmationEmail: async (payload) => {
sent.push(payload.desiredName)
},
baseUrl: 'https://hermes-world.ai',
now: () => new Date('2026-05-06T12:00:00.000Z'),
randomToken: () => attempt.token,
},
)
}
expect(sent).toEqual(['AtlasOne', 'BeaconTwo', 'Cipher_3'])
await expect(countReservations(store)).resolves.toBe(3)
})
})
describe('confirmReservation', () => {
it('marks a token as confirmed and returns the reservation', async () => {
const store = makeStore({
reservations: [
{
id: 'res_1',
desiredName: 'OraclePath',
normalizedName: 'oraclepath',
email: 'oracle@example.com',
wallet: null,
confirmationToken: 'tok_confirm',
confirmedAt: null,
createdAt: '2026-05-06T12:00:00.000Z',
},
],
})
const confirmed = await confirmReservation('tok_confirm', store)
expect(confirmed?.confirmedAt).toBe('2026-05-06T12:05:00.000Z')
})
})

View File

@@ -0,0 +1,338 @@
const NAME_PATTERN = /^[A-Za-z0-9_]{3,20}$/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const WALLET_PATTERN = /^[A-Za-z0-9:_-]{6,120}$/
const DEFAULT_RESERVED_NAMES = [
'admin',
'administrator',
'system',
'support',
'moderator',
'mod',
'gm',
'hermes',
'apollo',
'athena',
'root',
]
const PROFANITY_TOKENS = [
'shit',
'fuck',
'bitch',
'cunt',
'nigger',
'fag',
'slut',
'whore',
]
export class ReservationValidationError extends Error {
status: number
constructor(message: string, status = 400) {
super(message)
this.name = 'ReservationValidationError'
this.status = status
}
}
export type ReservationInput = {
desiredName: string
email: string
wallet?: string | null
}
export type ValidatedReservationInput = {
desiredName: string
normalizedName: string
email: string
wallet: string | null
}
export type NameReservationRecord = {
id: string
desiredName: string
normalizedName: string
email: string
wallet: string | null
confirmationToken: string
confirmedAt: string | null
createdAt: string
}
export type NameReservationStore = {
findByNormalizedName(normalizedName: string): Promise<NameReservationRecord | null>
insertReservation(input: {
desiredName: string
normalizedName: string
email: string
wallet: string | null
confirmationToken: string
createdAt: string
}): Promise<NameReservationRecord>
countReservations(): Promise<number>
confirmByToken(token: string): Promise<NameReservationRecord | null>
}
export type ConfirmationEmailPayload = {
email: string
desiredName: string
confirmationUrl: string
}
export function normalizeReservationName(value: string): string {
return value.trim().toLowerCase()
}
function getReservedNames(): Set<string> {
const extra = (process.env.HERMESWORLD_RESERVED_NAMES || '')
.split(',')
.map((value) => normalizeReservationName(value))
.filter(Boolean)
return new Set([...DEFAULT_RESERVED_NAMES, ...extra].map(normalizeReservationName))
}
function containsProfanity(normalizedName: string): boolean {
return PROFANITY_TOKENS.some((token) => normalizedName.includes(token))
}
export function validateReservationInput(input: ReservationInput): ValidatedReservationInput {
const desiredName = input.desiredName.trim()
const normalizedName = normalizeReservationName(desiredName)
const email = input.email.trim().toLowerCase()
const wallet = (input.wallet || '').trim()
if (!NAME_PATTERN.test(desiredName)) {
throw new ReservationValidationError(
'Desired name must be 3-20 characters and use only letters, numbers, or underscores.',
)
}
if (!EMAIL_PATTERN.test(email)) {
throw new ReservationValidationError('Enter a valid email address.')
}
if (wallet && !WALLET_PATTERN.test(wallet)) {
throw new ReservationValidationError('Wallet format looks invalid.')
}
return {
desiredName,
normalizedName,
email,
wallet: wallet || null,
}
}
export function assertNameAllowed(normalizedName: string): void {
if (getReservedNames().has(normalizedName)) {
throw new ReservationValidationError('That name is reserved for admin/system use.', 409)
}
if (containsProfanity(normalizedName)) {
throw new ReservationValidationError('That name fails the profanity filter.', 409)
}
}
export async function createReservation(
input: ReservationInput,
options: {
store: NameReservationStore
sendConfirmationEmail: (payload: ConfirmationEmailPayload) => Promise<void>
baseUrl?: string
now?: () => Date
randomToken?: () => string
},
): Promise<NameReservationRecord> {
const validated = validateReservationInput(input)
assertNameAllowed(validated.normalizedName)
const existing = await options.store.findByNormalizedName(validated.normalizedName)
if (existing) {
throw new ReservationValidationError('That name is already reserved.', 409)
}
const token =
options.randomToken?.() ||
(typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(36).slice(2)}`)
const now = options.now?.() || new Date()
const baseUrl = (options.baseUrl || process.env.HERMESWORLD_RESERVE_BASE_URL || 'https://hermes-world.ai').replace(/\/$/, '')
const record = await options.store.insertReservation({
...validated,
confirmationToken: token,
createdAt: now.toISOString(),
})
await options.sendConfirmationEmail({
email: validated.email,
desiredName: validated.desiredName,
confirmationUrl: `${baseUrl}/reserve/confirm?token=${encodeURIComponent(token)}`,
})
return record
}
export async function confirmReservation(
token: string,
store: NameReservationStore,
): Promise<NameReservationRecord | null> {
const normalizedToken = token.trim()
if (!normalizedToken) {
throw new ReservationValidationError('Confirmation token is required.')
}
return store.confirmByToken(normalizedToken)
}
export async function countReservations(store: NameReservationStore): Promise<number> {
return store.countReservations()
}
type SupabaseReservationRow = {
id: string
desired_name: string
normalized_name: string
email: string
wallet_address: string | null
confirmation_token: string
confirmed_at: string | null
created_at: string
}
function requireEnv(name: string): string {
const value = (process.env[name] || '').trim()
if (!value) {
throw new Error(`${name} is not configured`)
}
return value
}
function mapSupabaseRow(row: SupabaseReservationRow): NameReservationRecord {
return {
id: row.id,
desiredName: row.desired_name,
normalizedName: row.normalized_name,
email: row.email,
wallet: row.wallet_address,
confirmationToken: row.confirmation_token,
confirmedAt: row.confirmed_at,
createdAt: row.created_at,
}
}
async function supabaseRequest(path: string, init: RequestInit): Promise<Response> {
const url = requireEnv('HERMESWORLD_SUPABASE_URL').replace(/\/$/, '')
const key = requireEnv('HERMESWORLD_SUPABASE_SERVICE_ROLE_KEY')
return fetch(`${url}/rest/v1/${path}`, {
...init,
headers: {
apikey: key,
authorization: `Bearer ${key}`,
'content-type': 'application/json',
prefer: 'return=representation',
...(init.headers || {}),
},
})
}
export function createSupabaseReservationStore(): NameReservationStore {
return {
async findByNormalizedName(normalizedName) {
const response = await supabaseRequest(
`name_reservations?normalized_name=eq.${encodeURIComponent(normalizedName)}&select=*`,
{ method: 'GET', headers: { prefer: '' } },
)
if (!response.ok) {
throw new Error(`Failed to query reservations (${response.status})`)
}
const rows = (await response.json()) as Array<SupabaseReservationRow>
return rows[0] ? mapSupabaseRow(rows[0]) : null
},
async insertReservation(input) {
const response = await supabaseRequest('name_reservations', {
method: 'POST',
body: JSON.stringify({
desired_name: input.desiredName,
normalized_name: input.normalizedName,
email: input.email,
wallet_address: input.wallet,
confirmation_token: input.confirmationToken,
created_at: input.createdAt,
}),
})
if (!response.ok) {
const text = await response.text()
if (response.status === 409 || text.includes('duplicate key')) {
throw new ReservationValidationError('That name is already reserved.', 409)
}
throw new Error(`Failed to create reservation (${response.status}): ${text}`)
}
const rows = (await response.json()) as Array<SupabaseReservationRow>
return mapSupabaseRow(rows[0])
},
async countReservations() {
const response = await supabaseRequest('name_reservations?select=id', {
method: 'GET',
headers: { prefer: 'count=exact', range: '0-0' },
})
if (!response.ok) {
throw new Error(`Failed to count reservations (${response.status})`)
}
const contentRange = response.headers.get('content-range') || ''
const total = Number(contentRange.split('/')[1] || '0')
return Number.isFinite(total) ? total : 0
},
async confirmByToken(token) {
const response = await supabaseRequest(
`name_reservations?confirmation_token=eq.${encodeURIComponent(token)}`,
{
method: 'PATCH',
body: JSON.stringify({ confirmed_at: new Date().toISOString() }),
},
)
if (!response.ok) {
throw new Error(`Failed to confirm reservation (${response.status})`)
}
const rows = (await response.json()) as Array<SupabaseReservationRow>
return rows[0] ? mapSupabaseRow(rows[0]) : null
},
}
}
export async function sendReservationConfirmationEmail(
payload: ConfirmationEmailPayload,
): Promise<void> {
const apiKey = (process.env.RESEND_API_KEY || '').trim()
const from = (process.env.RESERVE_FROM_EMAIL || process.env.RESEND_FROM_EMAIL || '').trim()
if (!apiKey || !from) {
throw new Error('Email delivery is not configured (missing RESEND_API_KEY or RESERVE_FROM_EMAIL).')
}
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from,
to: [payload.email],
subject: `Confirm your HermesWorld name reservation: ${payload.desiredName}`,
html: `<div style="font-family:Inter,Arial,sans-serif;line-height:1.6;color:#111827">
<h2>Confirm your HermesWorld reservation</h2>
<p>You asked to reserve <strong>${payload.desiredName}</strong>.</p>
<p>Confirm it here:</p>
<p><a href="${payload.confirmationUrl}">${payload.confirmationUrl}</a></p>
<p>If this wasn't you, ignore this email.</p>
</div>`,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Failed to send confirmation email (${response.status}): ${text}`)
}
}

14
wrangler.jsonc Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hermes-world",
"compatibility_date": "2026-05-05",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry",
"observability": {
"enabled": true
},
"vars": {
"HERMES_PUBLIC_DEPLOY": "1",
"VITE_PLAYGROUND_STATS_URL": "https://hermes-playground-ws.myaurora-agi.workers.dev/stats"
}
}