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:
23
docs/hermesworld/name-reservations.sql
Normal file
23
docs/hermesworld/name-reservations.sql
Normal 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');
|
||||
@@ -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,
|
||||
|
||||
79
src/routes/api/hermesworld/reservations.ts
Normal file
79
src/routes/api/hermesworld/reservations.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
45
src/routes/api/hermesworld/reservations/confirm.ts
Normal file
45
src/routes/api/hermesworld/reservations/confirm.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
98
src/routes/early-access.tsx
Normal file
98
src/routes/early-access.tsx
Normal 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
283
src/routes/reserve.tsx
Normal 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="3–20" 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>
|
||||
)
|
||||
}
|
||||
84
src/routes/reserve/confirm.tsx
Normal file
84
src/routes/reserve/confirm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
src/server/name-reservations.test.ts
Normal file
237
src/server/name-reservations.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
338
src/server/name-reservations.ts
Normal file
338
src/server/name-reservations.ts
Normal 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
14
wrangler.jsonc
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user