From 4b3a47ae49188f962880f3ca481f88f562f7def4 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 7 May 2026 16:34:56 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20HermesWorld=20name=20reservations=20?= =?UTF-8?q?=E2=80=94=20public=20claim=20flow=20(#383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/hermesworld/name-reservations.sql | 23 ++ src/routeTree.gen.ts | 129 +++++++ src/routes/api/hermesworld/reservations.ts | 79 ++++ .../api/hermesworld/reservations/confirm.ts | 45 +++ src/routes/early-access.tsx | 98 +++++ src/routes/reserve.tsx | 283 +++++++++++++++ src/routes/reserve/confirm.tsx | 84 +++++ src/server/name-reservations.test.ts | 237 ++++++++++++ src/server/name-reservations.ts | 338 ++++++++++++++++++ wrangler.jsonc | 14 + 10 files changed, 1330 insertions(+) create mode 100644 docs/hermesworld/name-reservations.sql create mode 100644 src/routes/api/hermesworld/reservations.ts create mode 100644 src/routes/api/hermesworld/reservations/confirm.ts create mode 100644 src/routes/early-access.tsx create mode 100644 src/routes/reserve.tsx create mode 100644 src/routes/reserve/confirm.tsx create mode 100644 src/server/name-reservations.test.ts create mode 100644 src/server/name-reservations.ts create mode 100644 wrangler.jsonc diff --git a/docs/hermesworld/name-reservations.sql b/docs/hermesworld/name-reservations.sql new file mode 100644 index 00000000..18924c83 --- /dev/null +++ b/docs/hermesworld/name-reservations.sql @@ -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'); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e0218564..f77321c7 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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,9 +2820,27 @@ 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 SettingsIndexRoute: typeof SettingsIndexRoute @@ -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, diff --git a/src/routes/api/hermesworld/reservations.ts b/src/routes/api/hermesworld/reservations.ts new file mode 100644 index 00000000..198aa81d --- /dev/null +++ b/src/routes/api/hermesworld/reservations.ts @@ -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 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/api/hermesworld/reservations/confirm.ts b/src/routes/api/hermesworld/reservations/confirm.ts new file mode 100644 index 00000000..b693da2b --- /dev/null +++ b/src/routes/api/hermesworld/reservations/confirm.ts @@ -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 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/early-access.tsx b/src/routes/early-access.tsx new file mode 100644 index 00000000..814a9a83 --- /dev/null +++ b/src/routes/early-access.tsx @@ -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 ( +
+
+
+
+
+
+ +
+
+ HermesWorld +
+
+ HermesWorld +
+
+ Persistent agent RPG +
+
+
+ +
+ + + Early access — keys rolling out + +

+ HermesWorld is opening soon. +

+

+ 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. +

+ + + +
+ {[ + ['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]) => ( +
+
+ Step {i} +
+
{title}
+
{copy}
+
+ ))} +
+ + +
+
+
+ ) +} diff --git a/src/routes/reserve.tsx b/src/routes/reserve.tsx new file mode 100644 index 00000000..d1e6f3d2 --- /dev/null +++ b/src/routes/reserve.tsx @@ -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({ + loading: true, + count: 0, + error: null, + }) + const [submitState, setSubmitState] = useState({ + 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) { + 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 ( +
+
+
+
+
+ +
+
+ + ← Back to HermesWorld + +
+ Name reservation live +
+

+ Reserve your HermesWorld name before accounts launch. +

+

+ 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. +

+ +
+ + + +
+ +
+
+ Reservation notes +
+
    +
  • • Desired names must use letters, numbers, or underscores only.
  • +
  • • Duplicate names are rejected immediately.
  • +
  • • Wallet is optional today, but helps with future account linking.
  • +
  • • Confirmation email required before the reservation is considered locked.
  • +
+
+
+ +
+
+
+
+ Reserve handle +
+
+ {trimmedName ? `Claim ${trimmedName}` : 'Enter your launch-day name'} +
+
+
+ hermes-world.ai/reserve +
+
+ +
+ + + + + + + + {submitState.message ? ( +
+ {submitState.message} +
+ ) : null} +
+
+
+ ) +} + +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 ( + + ) +} + +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 ( +
+
{label}
+
+ {value} +
+
{subcopy}
+
+ ) +} diff --git a/src/routes/reserve/confirm.tsx b/src/routes/reserve/confirm.tsx new file mode 100644 index 00000000..700bba8b --- /dev/null +++ b/src/routes/reserve/confirm.tsx @@ -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 ( +
+
+
+ Email confirmation +
+

+ {state.status === 'success' + ? 'Name locked in.' + : state.status === 'error' + ? 'Confirmation problem.' + : 'Confirming reservation…'} +

+

{state.message}

+ +
+
+ ) +} diff --git a/src/server/name-reservations.test.ts b/src/server/name-reservations.test.ts new file mode 100644 index 00000000..999fc57f --- /dev/null +++ b/src/server/name-reservations.test.ts @@ -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') + }) +}) diff --git a/src/server/name-reservations.ts b/src/server/name-reservations.ts new file mode 100644 index 00000000..17af2cea --- /dev/null +++ b/src/server/name-reservations.ts @@ -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 + insertReservation(input: { + desiredName: string + normalizedName: string + email: string + wallet: string | null + confirmationToken: string + createdAt: string + }): Promise + countReservations(): Promise + confirmByToken(token: string): Promise +} + +export type ConfirmationEmailPayload = { + email: string + desiredName: string + confirmationUrl: string +} + +export function normalizeReservationName(value: string): string { + return value.trim().toLowerCase() +} + +function getReservedNames(): Set { + 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 + baseUrl?: string + now?: () => Date + randomToken?: () => string + }, +): Promise { + 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 { + const normalizedToken = token.trim() + if (!normalizedToken) { + throw new ReservationValidationError('Confirmation token is required.') + } + return store.confirmByToken(normalizedToken) +} + +export async function countReservations(store: NameReservationStore): Promise { + 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 { + 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 + 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 + 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 + return rows[0] ? mapSupabaseRow(rows[0]) : null + }, + } +} + +export async function sendReservationConfirmationEmail( + payload: ConfirmationEmailPayload, +): Promise { + 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: `
+

Confirm your HermesWorld reservation

+

You asked to reserve ${payload.desiredName}.

+

Confirm it here:

+

${payload.confirmationUrl}

+

If this wasn't you, ignore this email.

+
`, + }), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Failed to send confirmation email (${response.status}): ${text}`) + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 00000000..f78e03c8 --- /dev/null +++ b/wrangler.jsonc @@ -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" + } +}