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:
parent
317c886529
commit
65f900169b
11 changed files with 489 additions and 0 deletions
47
src/api/index.ts
Normal file
47
src/api/index.ts
Normal 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
53
src/api/middleware.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
88
src/api/routes/accounts.ts
Normal file
88
src/api/routes/accounts.ts
Normal 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
|
||||
}
|
||||
98
src/api/routes/versions.ts
Normal file
98
src/api/routes/versions.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
23
src/index.ts
23
src/index.ts
|
|
@ -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...')
|
||||
|
|
|
|||
19
src/types.ts
19
src/types.ts
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue