TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
202 lines
9.4 KiB
JavaScript
202 lines
9.4 KiB
JavaScript
require('dotenv').config()
|
|
const http = require('http')
|
|
|
|
// NOTE: `./app` and `./internalApp` are deliberately NOT required here. Requiring
|
|
// app.js runs `modules.load()`, which scans the volume and mounts whatever is on
|
|
// it (MODULE_API.md §4.1) — so the declared module set has to be resolved before
|
|
// that require, not before the listener. They are required inside start(), after
|
|
// resolveDeclaredModules(); everything else this file needs is safe to pull in
|
|
// now because none of it reaches the loader's scan.
|
|
const botScore = require('./middleware/botScore')
|
|
const announceWorker = require('./utils/announceWorker')
|
|
const teamActivityPrune = require('./utils/teamActivityPrune')
|
|
const { ensureSchema, close } = require('./utils/db')
|
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
|
const settings = require('./model/settings/settings.model')
|
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
|
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
|
const moduleLifecycle = require('./modules/lifecycle')
|
|
const declaredModules = require('./modules/declared')
|
|
const moduleInstall = require('./modules/install')
|
|
const moduleModel = require('./model/modules/modules.model')
|
|
const createLogger = require('./utils/logger')
|
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
|
const brand = require('./config/brand')
|
|
const pkg = require('../package.json')
|
|
|
|
const log = createLogger('server')
|
|
const PORT = Number(process.env.PORT) || 3000
|
|
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
|
|
// the public PORT so the decrypted-token route can't ride the listener Pangolin
|
|
// proxies to the world (issue #33). Must match the port in the bot's
|
|
// SITE_INTERNAL_URL (docker-compose.yml).
|
|
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
|
|
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
|
|
|
|
async function start() {
|
|
log.info(`starting ${brand.name} server v${pkg.version}`, {
|
|
node: process.version,
|
|
env: process.env.NODE_ENV || 'development',
|
|
logLevel: process.env.LOG_LEVEL || 'info',
|
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
|
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`,
|
|
cookieSecure: process.env.COOKIE_SECURE || 'auto',
|
|
email: 'gmail-oauth2 (configured in admin → settings)',
|
|
})
|
|
|
|
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
|
|
// production (the /internal/bot-config route hands back the decrypted Discord
|
|
// token and this key is its only guard); a warning otherwise.
|
|
const keyCheck = evaluateBotInternalKey({
|
|
key: process.env.BOT_INTERNAL_KEY,
|
|
nodeEnv: process.env.NODE_ENV,
|
|
})
|
|
if (keyCheck.fatal) {
|
|
log.error(keyCheck.message)
|
|
process.exit(1)
|
|
} else if (!keyCheck.ok) {
|
|
log.warn(keyCheck.message)
|
|
}
|
|
|
|
log.info('ensuring database schema...')
|
|
// Core's schema only. Each installed module's fragment is replayed further
|
|
// down, after the volume has been scanned — see the require of ./app below.
|
|
await ensureSchema({ replayModules: false })
|
|
log.info('seeding defaults...')
|
|
await seedDefaults()
|
|
await createInitialAdminFromEnv()
|
|
|
|
// Clear out session-denylist rows whose token has already expired (dead weight).
|
|
// Best-effort — a prune failure must never block startup.
|
|
try {
|
|
const pruned = await revokedSessions.pruneExpired()
|
|
if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
|
|
} catch (err) {
|
|
log.warn('revoked-session prune failed', { error: err.message })
|
|
}
|
|
|
|
// Same treatment for the mobile SSO bridge tables (also pruned opportunistically
|
|
// on each bridge write). Boot-time sweep catches rows orphaned by a crash.
|
|
try {
|
|
const pruned = await mobileAuthBridge.pruneExpired()
|
|
if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`)
|
|
} catch (err) {
|
|
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
|
}
|
|
|
|
const mode = await settings.get('site_mode')
|
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
|
|
|
// Bring the modules volume in line with what MODULES declares (§2.7.2
|
|
// decision 4), and only then require the app — the loader scans and mounts at
|
|
// require time, so this is the last moment at which a module can be put on the
|
|
// volume and still be part of this process.
|
|
//
|
|
// After the schema and the seed, because the host allowlist it installs under
|
|
// is a settings row that the seed creates on a fresh instance. Never throws:
|
|
// an unreachable release host leaves the site serving without that module
|
|
// rather than taking the site down with it.
|
|
await declaredModules.resolve({
|
|
hosts: moduleInstall.parseHosts(await settings.get(moduleInstall.HOSTS_SETTING)),
|
|
model: moduleModel,
|
|
})
|
|
|
|
// Requiring app.js is what scans the volume and mounts what is on it. Every
|
|
// line above this one runs against a core that has no modules in it yet.
|
|
// eslint-disable-next-line global-require
|
|
const app = require('./app')
|
|
// eslint-disable-next-line global-require
|
|
const internalApp = require('./internalApp')
|
|
|
|
// Now that the scan has happened, replay each module's schema fragment
|
|
// (MODULE_API.md §2.6). This used to ride inside ensureSchema() and could,
|
|
// because app.js was required at the top of this file; resolving the declared
|
|
// set first moved the scan after it, and a booting server quietly getting no
|
|
// module tables is precisely what §7.6 warns about. Caught by the browser
|
|
// smoke rather than by a test: every suite here stubs one side or the other.
|
|
// eslint-disable-next-line global-require
|
|
await require('./modules/schema').replayFragments()
|
|
|
|
// Reconcile installed_modules with what the loader found on the volume at
|
|
// require time, then run each module's onBoot (MODULE_API.md §2.5).
|
|
//
|
|
// This is where the shard now warms up. Core used to do it inline just above —
|
|
// rebuild the spawn atlas from the ServUO tree, refresh the cliloc table, open
|
|
// the uo-link WebSocket — and all of it is module-uo's `onBoot` since Phase 3.
|
|
// The ordering guarantee is unchanged and is why it belongs here rather than
|
|
// after the listener: the tables exist by now, and nothing is served until the
|
|
// warm-up finishes. Placed
|
|
// after core's own boot work and BEFORE the listener binds, for both reasons
|
|
// the contract gives: a module's warm-up may depend on core being up, and a
|
|
// module that must not serve traffic until it has warmed a cache gets that
|
|
// guarantee only if nothing is listening yet. Never throws — a module that
|
|
// fails here keeps its URLs and answers 503.
|
|
await moduleLifecycle.boot()
|
|
|
|
const server = http.createServer(app)
|
|
server.listen(PORT, HOST, () => {
|
|
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
|
|
})
|
|
|
|
// Internal server<->bot API on a separate, unpublished port. NEVER expose this
|
|
// through Pangolin/the public reverse proxy — it serves the decrypted Discord
|
|
// bot token to the bot process over the private compose network only (#33).
|
|
const internalServer = http.createServer(internalApp)
|
|
internalServer.listen(INTERNAL_PORT, HOST, () => {
|
|
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
|
})
|
|
|
|
// Start the news-announcement dispatcher: a light in-process poller that pushes
|
|
// published news posts to the in-game town crier + Discord with independent
|
|
// retry per leg. No-op until a news post is actually published.
|
|
announceWorker.start()
|
|
|
|
// Bound the per-Team activity feed (TEAMS.md §4.2). A feed fed by a game loop
|
|
// is the obvious unbounded-growth failure, so retention starts with the feed
|
|
// rather than after someone notices. No-op on a deployment with no Teams.
|
|
teamActivityPrune.start()
|
|
|
|
setupShutdown(server, internalServer)
|
|
}
|
|
|
|
function setupShutdown(server, internalServer) {
|
|
let closing = false
|
|
const shutdown = async (signal) => {
|
|
if (closing) return
|
|
closing = true
|
|
log.warn(`${signal} received — shutting down gracefully`)
|
|
// Modules first, while everything they were handed still works: the database
|
|
// pool, the push dispatcher and the SSE fan-out are all still open here, and
|
|
// a module's onShutdown is the only chance it gets to flush through them
|
|
// (MODULE_API.md §2.5). Each hook is budgeted, so one that will not let go
|
|
// costs five seconds rather than the whole shutdown.
|
|
await moduleLifecycle.shutdown()
|
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
|
announceWorker.stop() // stop the news-announcement dispatcher poller
|
|
teamActivityPrune.stop() // stop the Team activity retention timer
|
|
server.close(() => log.info('http server closed'))
|
|
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
|
try {
|
|
await close()
|
|
log.info('database pool closed')
|
|
} catch (err) {
|
|
log.error('error closing database pool', err)
|
|
}
|
|
await createLogger.close() // flush the log file
|
|
process.exit(0)
|
|
}
|
|
|
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
|
process.on('uncaughtException', (err) => {
|
|
log.error('uncaughtException', err)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
start().catch((err) => {
|
|
log.error('failed to start server', err)
|
|
process.exit(1)
|
|
})
|