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

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ state.json
.claude
tokens/*
!tokens/.gitkeep
hytale-endpoints.md
### Linux ###
*~

View file

@ -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"
},

122
pnpm-lock.yaml generated
View file

@ -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

34
scripts/sign-token.ts Normal file
View file

@ -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)

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

@ -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
}