From 65f900169bd6f43223052f675f3887d56028aff0 Mon Sep 17 00:00:00 2001 From: devilreef <86633411+devilr33f@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:24:27 +0600 Subject: [PATCH] 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 --- .gitignore | 1 + package.json | 4 + pnpm-lock.yaml | 122 ++++++++++++++++++++++++++++++ {src => scripts}/debug-message.ts | 0 scripts/sign-token.ts | 34 +++++++++ src/api/index.ts | 47 ++++++++++++ src/api/middleware.ts | 53 +++++++++++++ src/api/routes/accounts.ts | 88 +++++++++++++++++++++ src/api/routes/versions.ts | 98 ++++++++++++++++++++++++ src/index.ts | 23 ++++++ src/types.ts | 19 +++++ 11 files changed, 489 insertions(+) rename {src => scripts}/debug-message.ts (100%) create mode 100644 scripts/sign-token.ts create mode 100644 src/api/index.ts create mode 100644 src/api/middleware.ts create mode 100644 src/api/routes/accounts.ts create mode 100644 src/api/routes/versions.ts diff --git a/.gitignore b/.gitignore index f7db440..aa3f499 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ state.json .claude tokens/* !tokens/.gitkeep +hytale-endpoints.md ### Linux ### *~ diff --git a/package.json b/package.json index 68feb80..e0d993f 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,12 @@ "start": "tsx src/index.ts" }, "dependencies": { + "@hono/node-server": "^1.19.9", + "@types/jsonwebtoken": "^9.0.10", "discord.js-selfbot-v13": "^3.7.1", "env-var": "^7.5.0", + "hono": "^4.6.0", + "jsonwebtoken": "^9.0.2", "tsx": "^4.20.3", "wrappergram": "^1.3.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9686e83..90ec8c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,24 @@ importers: .: dependencies: + '@hono/node-server': + specifier: ^1.19.9 + version: 1.19.9(hono@4.11.4) + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 discord.js-selfbot-v13: specifier: ^3.7.1 version: 3.7.1 env-var: specifier: ^7.5.0 version: 7.5.0 + hono: + specifier: ^4.6.0 + version: 4.11.4 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 tsx: specifier: ^4.20.3 version: 4.20.3 @@ -366,6 +378,12 @@ packages: '@gramio/types@9.3.0': resolution: {integrity: sha512-gZGZFuxEjcV1pi2kjc5Nn2Js+sTJFLbPhuoCDYLY4awVjgcGRfrXK07Ah1zm4jsScT4xoVCAEf63dQvKzwooLQ==} + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -449,6 +467,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -606,6 +627,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -727,6 +751,9 @@ packages: engines: {node: '>=20.18'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.182: resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==} @@ -1073,6 +1100,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hono@4.11.4: + resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} + engines: {node: '>=16.9.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1150,6 +1181,16 @@ packages: resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1169,9 +1210,30 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -1521,6 +1583,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -2047,6 +2112,10 @@ snapshots: '@gramio/types@9.3.0': {} + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2128,6 +2197,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.16.3 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -2326,6 +2400,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + buffer-equal-constant-time@1.0.1: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -2441,6 +2517,10 @@ snapshots: - opusscript - utf-8-validate + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.182: {} emoji-regex@8.0.0: {} @@ -2862,6 +2942,8 @@ snapshots: has-flag@4.0.0: {} + hono@4.11.4: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -2916,6 +2998,30 @@ snapshots: espree: 9.6.1 semver: 7.7.2 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2939,8 +3045,22 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} loglevel@1.9.2: {} @@ -3439,6 +3559,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.1 diff --git a/src/debug-message.ts b/scripts/debug-message.ts similarity index 100% rename from src/debug-message.ts rename to scripts/debug-message.ts diff --git a/scripts/sign-token.ts b/scripts/sign-token.ts new file mode 100644 index 0000000..fb26b28 --- /dev/null +++ b/scripts/sign-token.ts @@ -0,0 +1,34 @@ +import jwt from 'jsonwebtoken' +import process from 'node:process' + +const args = process.argv.slice(2) +let scopes = 'admin' +let expirationHours = 24 + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--scope' && args[i + 1]) { + scopes = args[i + 1] + i++ + } + else if (args[i] === '--exp' && args[i + 1]) { + const expStr = args[i + 1] + const match = expStr.match(/^(\d+)h?$/) + if (match) { + expirationHours = parseInt(match[1], 10) + } + i++ + } +} + +const jwtSecret = process.env.JWT_SECRET || 'your-secret-key' + +const payload = { + scope: scopes, + sub: 'test-client', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + (expirationHours * 3600), +} + +const token = jwt.sign(payload, jwtSecret, { algorithm: 'HS256' }) + +console.log(token) diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..cacd29f --- /dev/null +++ b/src/api/index.ts @@ -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 { + 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, + }) +} diff --git a/src/api/middleware.ts b/src/api/middleware.ts new file mode 100644 index 0000000..634c0b5 --- /dev/null +++ b/src/api/middleware.ts @@ -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() + }) +} diff --git a/src/api/routes/accounts.ts b/src/api/routes/accounts.ts new file mode 100644 index 0000000..47ff64c --- /dev/null +++ b/src/api/routes/accounts.ts @@ -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 +} diff --git a/src/api/routes/versions.ts b/src/api/routes/versions.ts new file mode 100644 index 0000000..253a3d2 --- /dev/null +++ b/src/api/routes/versions.ts @@ -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 + const result: Record = {} + + 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 +} diff --git a/src/index.ts b/src/index.ts index c5506c7..dccf3b5 100644 --- a/src/index.ts +++ b/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...') diff --git a/src/types.ts b/src/types.ts index fa02006..6235054 100644 --- a/src/types.ts +++ b/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 +}