feat: add Hono API with JWT authentication

- Implement REST API using Hono framework with token-based auth
- Add JWT middleware for scope-based access control
- Create version endpoints: launcher, downloader, patches, server
- Add account lookup endpoints (by UUID or username)
- Fetch server software with signed URLs and full version data
- Add token signing utility script for testing
- Support configurable account UUID for game session creation
- Integrate API server with existing module system

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
devilreef 2026-01-17 00:24:27 +06:00
parent 317c886529
commit 65f900169b
11 changed files with 489 additions and 0 deletions

47
src/api/index.ts Normal file
View file

@ -0,0 +1,47 @@
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import type { StateStore } from '../core/state-store.js'
import type { TokenManager } from '../core/token-manager.js'
import type { ApiContext } from '../types.js'
import { createJwtMiddleware, requireScope } from './middleware.js'
import { createVersionRoutes } from './routes/versions.js'
import { createAccountRoutes } from './routes/accounts.js'
export interface ApiServerOptions {
port: number
jwtSecret: string
stateStore: StateStore
tokenManager: TokenManager
accountUuid?: string
}
export async function startApiServer(options: ApiServerOptions): Promise<void> {
const app = new Hono<{ Variables: { apiContext: ApiContext } }>()
// Global JWT middleware
app.use('*', createJwtMiddleware(options.jwtSecret))
// Version routes (require launcher or downloader scope)
const versionRoutes = createVersionRoutes(options.stateStore, options.tokenManager)
app.use('/api/versions/launcher', requireScope('launcher'))
app.use('/api/versions/downloader', requireScope('downloader'))
app.use('/api/versions/patches', requireScope('launcher'))
app.use('/api/versions/server', requireScope('downloader'))
app.route('/api/versions', versionRoutes)
// Account routes (require accounts scope)
const accountRoutes = createAccountRoutes(options.tokenManager, options.accountUuid)
app.use('/api/accounts/*', requireScope('accounts'))
app.route('/api/accounts', accountRoutes)
// Health check (no auth required, but we need to handle it before JWT middleware)
app.get('/health', (c) => {
return c.json({ status: 'ok' })
})
console.log(`[API] Starting server on port ${options.port}`)
serve({
fetch: app.fetch,
port: options.port,
})
}

53
src/api/middleware.ts Normal file
View file

@ -0,0 +1,53 @@
import { createMiddleware } from 'hono/factory'
import jwt from 'jsonwebtoken'
import type { JwtPayload, ApiContext } from '../types.js'
export function createJwtMiddleware(jwtSecret: string) {
return createMiddleware<{ Variables: { apiContext: ApiContext } }>(async (c, next) => {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
}
const token = authHeader.slice(7)
try {
const payload = jwt.verify(token, jwtSecret) as JwtPayload
if (!payload.scope) {
return c.json({ error: 'Token missing scope claim' }, 401)
}
c.set('apiContext', {
scope: payload.scope,
sub: payload.sub,
})
await next()
} catch (error) {
return c.json({ error: 'Invalid token' }, 401)
}
})
}
export function requireScope(...requiredScopes: string[]) {
return createMiddleware<{ Variables: { apiContext: ApiContext } }>(async (c, next) => {
const apiContext = c.get('apiContext')
if (!apiContext) {
return c.json({ error: 'No API context' }, 401)
}
const tokenScopes = apiContext.scope.split(' ')
const hasRequiredScope = requiredScopes.some(
scope => tokenScopes.includes(scope) || tokenScopes.includes('admin'),
)
if (!hasRequiredScope) {
return c.json({ error: 'Insufficient permissions' }, 403)
}
await next()
})
}

View file

@ -0,0 +1,88 @@
import { Hono } from 'hono'
import type { TokenManager } from '../../core/token-manager.js'
import type { ApiContext } from '../../types.js'
interface GameSessionResponse {
expiresAt: string
identityToken: string
sessionToken: string
}
interface ProfileResponse {
username: string
uuid: string
skin: string
entitlements?: string[]
}
export function createAccountRoutes(tokenManager: TokenManager, accountUuid?: string) {
const router = new Hono<{ Variables: { apiContext: ApiContext } }>()
router.get('/profile/:identifier', async (c) => {
try {
if (!accountUuid) {
return c.json({ error: 'Account UUID not configured' }, 500)
}
const identifier = c.req.param('identifier')
// Parse identifier format: "uuid:UUID" or "username:USERNAME"
const [type, value] = identifier.split(':')
if (!type || !value) {
return c.json({ error: 'Invalid identifier format. Use uuid:UUID or username:USERNAME' }, 400)
}
if (type !== 'uuid' && type !== 'username') {
return c.json({ error: 'Invalid identifier type. Use uuid or username' }, 400)
}
// Get launcher token for creating game session
const launcherToken = await tokenManager.getAccessToken('launcher')
// Create game session using the configured account UUID
const sessionResponse = await fetch('https://sessions.hytale.com/game-session/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${launcherToken}`,
},
body: JSON.stringify({ uuid: accountUuid }),
})
if (!sessionResponse.ok) {
console.warn(`[API] Failed to create game session: ${sessionResponse.statusText}`)
return c.json({ error: 'Failed to create game session' }, 500)
}
const sessionData = await sessionResponse.json() as GameSessionResponse
const sessionToken = sessionData.sessionToken
// Fetch profile using session token
const profileUrl = type === 'uuid'
? `https://account-data.hytale.com/profile/uuid/${value}`
: `https://account-data.hytale.com/profile/username/${value}`
const profileResponse = await fetch(profileUrl, {
headers: { Authorization: `Bearer ${sessionToken}` },
})
if (!profileResponse.ok) {
if (profileResponse.status === 404) {
return c.json({ error: 'Profile not found' }, 404)
}
console.warn(`[API] Failed to fetch profile: ${profileResponse.statusText}`)
return c.json({ error: 'Failed to fetch profile' }, 500)
}
const profile = await profileResponse.json() as ProfileResponse
return c.json(profile)
}
catch (err) {
console.error('[API] Error in /profile endpoint:', err)
return c.json({ error: 'Internal server error' }, 500)
}
})
return router
}

View file

@ -0,0 +1,98 @@
import { Hono } from 'hono'
import type { StateStore } from '../../core/state-store.js'
import type { TokenManager } from '../../core/token-manager.js'
import type { ApiContext } from '../../types.js'
export function createVersionRoutes(stateStore: StateStore, tokenManager: TokenManager) {
const router = new Hono<{ Variables: { apiContext: ApiContext } }>()
router.get('/launcher', async (c) => {
const data = stateStore.get('hytale-launcher')
if (!data) {
return c.json({ error: 'No launcher data available' }, 404)
}
return c.json(data)
})
router.get('/downloader', async (c) => {
const data = stateStore.get('hytale-downloader')
if (!data) {
return c.json({ error: 'No downloader data available' }, 404)
}
return c.json(data)
})
router.get('/patches', async (c) => {
const data = stateStore.get('hytale-patches')
if (!data) {
return c.json({ error: 'No patches data available' }, 404)
}
return c.json(data)
})
router.get('/server', async (c) => {
try {
const serverData = stateStore.get('hytale-server')
if (!serverData) {
return c.json({ error: 'No server data available' }, 404)
}
const serverObj = serverData as Record<string, { version: string; lastCheck: number }>
const result: Record<string, { version: string; download_url: string; sha256?: string; url: string }> = {}
const token = await tokenManager.getAccessToken('downloader')
for (const [patchline, data] of Object.entries(serverObj)) {
try {
const response = await fetch(
`https://account-data.hytale.com/game-assets/version/${patchline}.json`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
if (!response.ok) {
console.warn(`[API] Failed to fetch signed URL for ${patchline}: ${response.statusText}`)
continue
}
const signedUrlData = await response.json() as { url: string }
// Fetch version data from signed URL
let versionData: { version?: string; download_url?: string; sha256?: string } = {}
try {
const versionResponse = await fetch(signedUrlData.url)
if (versionResponse.ok) {
versionData = await versionResponse.json()
}
}
catch (err) {
console.warn(`[API] Error fetching version data from signed URL for ${patchline}:`, err)
}
result[patchline] = {
version: versionData.version || data.version,
download_url: versionData.download_url || '',
sha256: versionData.sha256,
url: signedUrlData.url,
}
}
catch (err) {
console.warn(`[API] Error fetching signed URL for ${patchline}:`, err)
}
}
if (Object.keys(result).length === 0) {
return c.json({ error: 'Failed to fetch signed URLs' }, 500)
}
return c.json(result)
}
catch (err) {
console.error('[API] Error in /server endpoint:', err)
return c.json({ error: 'Internal server error' }, 500)
}
})
return router
}

View file

@ -1,83 +0,0 @@
import process, { env } from 'node:process'
import { Client } from 'discord.js-selfbot-v13'
const DISCORD_TOKEN = env['DISCORD_TOKEN']
if (!DISCORD_TOKEN) {
console.error('DISCORD_TOKEN is not set')
process.exit(1)
}
const link = process.argv[2]
if (!link) {
console.error('Usage: pnpm tsx src/debug-message.ts <discord-message-link>')
process.exit(1)
}
const match = link.match(/channels\/(\d+)\/(\d+)\/(\d+)/)
if (!match) {
console.error('Invalid Discord message link')
process.exit(1)
}
const [, guildId, channelId, messageId] = match
const client = new Client()
client.once('ready', async () => {
console.log(`Logged in as ${client.user?.tag}\n`)
try {
const guild = client.guilds.cache.get(guildId)
if (!guild) {
console.error(`Guild ${guildId} not found`)
process.exit(1)
}
const channel = guild.channels.cache.get(channelId)
if (!channel || !('messages' in channel)) {
console.error(`Channel ${channelId} not found or not a text channel`)
process.exit(1)
}
const message = await channel.messages.fetch(messageId)
console.log('=== MESSAGE PAYLOAD ===\n')
console.log(JSON.stringify({
id: message.id,
content: message.content,
author: {
id: message.author.id,
username: message.author.username,
displayName: message.author.displayName,
bot: message.author.bot,
},
type: message.type,
flags: message.flags.toArray(),
reference: message.reference,
messageSnapshots: (message as any).messageSnapshots,
embeds: message.embeds.map(e => ({
type: e.type,
title: e.title,
description: e.description?.slice(0, 100),
url: e.url,
author: e.author,
})),
attachments: message.attachments.map(a => ({
id: a.id,
name: a.name,
contentType: a.contentType,
url: a.url,
})),
components: message.components.length,
}, null, 2))
}
catch (err) {
console.error('Error fetching message:', err)
}
client.destroy()
process.exit(0)
})
client.login(env.discordToken)

View file

@ -4,6 +4,7 @@ import { config } from './config.js'
import { StateStore } from './core/state-store.js'
import { TelegramClient } from './core/telegram-client.js'
import { TokenManager } from './core/token-manager.js'
import { startApiServer } from './api/index.js'
// Module factories
import { discordForwarderFactory } from './modules/discord-forwarder/index.js'
@ -85,6 +86,28 @@ async function main() {
}
}
// Start API server if enabled
const apiConfig = config.modules.api as any
if (apiConfig?.enabled) {
const jwtSecret = process.env.JWT_SECRET || apiConfig.jwtSecret || 'your-secret-key'
const port = process.env.API_PORT ? parseInt(process.env.API_PORT, 10) : (apiConfig.port || 3000)
const accountUuid = process.env.ACCOUNT_UUID || apiConfig.accountUuid
try {
await startApiServer({
port,
jwtSecret,
stateStore,
tokenManager,
accountUuid,
})
}
catch (err) {
console.error('Failed to start API server:', err)
process.exit(1)
}
}
// Graceful shutdown
const shutdown = async () => {
console.log('Shutting down...')

View file

@ -82,3 +82,22 @@ export interface HytalePatchesConfig extends HytaleTrackerConfig {
export interface HytaleServerConfig extends HytaleTrackerConfig {
patchlines: string[]
}
export interface ApiConfig extends ModuleConfig {
enabled: boolean
port: number
jwtSecret: string
accountUuid?: string
}
export interface JwtPayload {
scope: string
sub?: string
iat?: number
exp?: number
}
export interface ApiContext {
scope: string
sub?: string
}