Management app
A TypeScript npm-workspaces monorepo under app/, itself one workspace tree
of the repository-root package.json workspaces list. It ships as a
packaged Electron desktop app — five non-Lambda packages make up the
local control plane: a Nest.js backend (desktop-main), a React dashboard
renderer (web), a pure shared library (shared), an AWS implementation of
the cloud-agnostic contracts (cloud-aws), and the Electron preload bridge
(desktop-preload) — plus the five Lambda packages documented
here. There is no HTTP server and no bearer token
anywhere in this app: the renderer talks to the backend exclusively over
Electron IPC, via window.hyveon (the desktop-preload bridge).
Install everything from the root:
npm install
Dev mode (npm run app:dev) launches the full Electron app with hot-reload
on renderer saves; electron-vite serves the renderer for HMR purposes only —
it is not a network API surface. See the setup guide for the
packaged-installer build.
@hyveon/shared
app/packages/shared — zero-runtime-dependency TypeScript consumed by the
server and the four core Lambdas (interactions, followup, update-dns,
watchdog — efs-seeder has no dependency on it). The canonical location for cross-boundary
types and permission logic.
| Module | Purpose |
|---|---|
types.ts | DiscordAction, DiscordConfig, RedactedDiscordConfig, GameStatus, StartResult, PendingInteraction. The API shapes every other package agrees on. |
canRun.ts | The pure permission-check function. Order: guild allowlist → admin user/role → per-game user/role + action. Imported verbatim by the Nest server and both Discord Lambdas. |
commands.ts | COMMAND_DESCRIPTORS — static JSON for the four slash commands. actionForCommand(name) maps to the start/stop/status bucket used by canRun(). |
sanitize.ts | isSafeGameKey() (blocks __proto__, constructor, prototype), asString(), asStringArray(), sanitizeGamePermission(). Applied on DDB reads where input is operator-provided. |
formatStatus.ts | formatGameStatus(status) — Discord-ready one-liner with emoji and hostname. |
ddb/client.ts | Lazy DynamoDB DocumentClient. Region fallback: AWS_REGION_ → AWS_REGION → AWS_DEFAULT_REGION → us-east-1. |
ddb/configStore.ts | getDiscordConfig() / putDiscordConfig() for the CONFIG#discord row. |
ddb/pendingStore.ts | getPending() / putPending() / deletePending() for PENDING#{taskArn}. putPending() sets expiresAt = now + 15 minutes so DDB TTL reaps stale rows. |
secrets/secretsStore.ts | Secrets Manager wrapper with a 5-minute in-process cache. Recognises Terraform's "placeholder" seed as "not configured". invalidateSecretsCache() is called by the Nest credentials endpoint. |
Invariants: canRun() lives in exactly one place; the four slash
commands are JSON descriptors, not classes; secrets' raw values never
leave this package's own callers.
@hyveon/desktop-main
app/packages/desktop-main — a Nest.js app running as an Electron IPC
microservice (NestFactory.createMicroservice), not an HTTP server. The
boot sequence in src/main.ts (invoked from electron-entry.ts after
app.whenReady()):
- Guards against running outside an Electron main process —
desktop-mainthrows immediately ifprocess.versions.electronis unset, rather than silently doing nothing under plain Node. NestFactory.createMicroservice(AppModule, { strategy: new BridgedElectronIPCTransport() }).app.listen()starts the transport, registering its internal@MessagePatterndispatch.registerIpcMainBridges(strategy)bridges each of those patterns onto a realipcMain.handleregistration, soipcRenderer.invokecalls from the renderer resolve instead of hanging.
There is no listen port, no NODE_ENV=production bearer-token check, and no
static-file serving — the renderer is a separate Electron BrowserWindow
loading the built Vite bundle (or the Vite dev server in dev mode), and it
never speaks HTTP to this process.
Module graph
AppModule— root. ImportsAwsModule,DiscordModule,TfvarsModule,TerraformModule,WizardModule, andElectronStoreModule. Also directly provides a handful of controller-adjacent services that don't warrant their own module (DiagnosticsService,DriftService,GamesWriteService,AuditService) plus theDIAGNOSTICS_LOG_DIRtoken.ConfigModule— standalone module providing justConfigService, theterraform.tfstate-backed configuration reader. Extracted on its own so every other feature module can depend on it without pulling inAwsModule.CloudProviderModule— importsConfigModule. Binds six cloud-agnostic contracts (from@hyveon/shared/cloud.js) to concrete@hyveon/cloud-awsimplementations viauseFactoryproviders keyed offConfigService.getActiveCloud():CLOUD_PROVIDER,SECRETS_STORE,REMOTE_FILE_STORE,DISCORD_RECEIVER,AUDIT_LOG_STORE, andRUN_RECORD_STORE(all declared incloud-provider.tokens.ts). Consumers inject via@Inject(CLOUD_PROVIDER)etc. and depend only on the@hyveon/sharedinterface — never the concrete AWS class — so swapping the active cloud is a one-module change, not a call-site hunt. Today every token still resolves to AWS; a future non-AWS provider is added by extending theCLOUD_BINDINGSregistry incloud-provider.module.ts, not by touching this module's provider definitions.AwsModule— importsConfigModuleandCloudProviderModule(re-exporting both); provides and exportsEc2Service,EcsService,LogsService,CostService,FileManagerService. It no longer providesConfigServicedirectly (that'sConfigModule's job —AwsModulere-exports it for existing consumers that importAwsModuleexpectingConfigServiceto be available) and no longer wiresAwsCloudProvider/AwsSecretsStoreitself —EcsServiceinjectsCLOUD_PROVIDERandDiscordConfigServiceinjectsSECRETS_STORE, both bound byCloudProviderModule.DiscordModule— importsAwsModule; providesDiscordConfigServiceandDiscordCommandRegistrar. No discord.js, no gateway — the bot is two Lambdas plus Discord's REST API.TfvarsModule— importsConfigModuleandCloudProviderModule(for theREMOTE_FILE_STOREtoken in S3 mode); providesTfvarsService, the local-vs-S3terraform.tfvarsreader/parser.TerraformModule— importsConfigModule,CloudProviderModule(forREMOTE_FILE_STOREandRUN_RECORD_STORE), andTfvarsModule(for the rollback flow); providesTerraformService,RunService(the apply-lock guard), andRunRecordService(run-history persistence).WizardModule— importsElectronStoreModule; providesPrerequisiteService,AwsProfileService,BootstrapService,IamCheckService,FirstRunWizardServicefor the first-run setup wizard.ElectronStoreModule— providesSafeStorageService(OS-keychain encryption) andElectronStoreService(the typedelectron-storeconsumer built on top of it). See Credential storage at rest below.
Controllers and IPC channels
Every controller is IPC-only: handlers are bound to a channel name via
@MessagePattern()/@Payload() — there are no HTTP routes anywhere in this
app. The renderer calls into these via window.hyveon.* (the preload bridge),
which forwards to ipcRenderer.invoke(channel, ...).
| Controller | Representative channels | Purpose |
|---|---|---|
GamesController | games.list, games.status, games.getStatus, games.start, games.stop, games.create, games.update, games.delete | List/read status, trigger RunTask/StopTask, manage game_servers config entries. Invalidates ConfigService's tfstate cache on list/status reads so fresh applies are picked up without restarting. |
ConfigController | config.get, config.update | Read/write watchdog knobs in server_config.json. Takes effect on next terraform apply (the values are baked into Lambda env). |
CostsController | costs.estimate, costs.actual | Per-game Fargate estimates; Cost Explorer actuals filtered on the SERVICE dimension (ECS + Fargate) — account-wide, not scoped by the Project tag. See Costs. |
LogsController | logs.get, logs.stream | Snapshot of last N log events; a streaming channel that pushes new events as they arrive (polls FilterLogEvents every 2 s under the hood). |
FilesController | files.list, files.start, files.stop | Ad-hoc FileBrowser task against the game's EFS access point. |
DiscordController | discord.getConfig, discord.putConfig, discord.listGuilds, discord.addGuild, discord.removeGuild, discord.registerCommands, discord.getAdmins, discord.putAdmins, discord.getPermissions, discord.putPermission, discord.deletePermission | Read-redacted config, save credentials, manage guild allowlist + commands, admins, per-game permissions. |
EnvController, DiagnosticsController, DriftController, AuditController | env.get; diagnostics.tail/diagnostics.path; drift.get; audit.list | Environment info, log-tail diagnostics, config-drift detection, and the audit-log view. |
TerraformController, TerraformRunsController | terraform.init, terraform.plan, terraform.apply, terraform.destroy.mintToken, terraform.destroy, terraform.output, terraform.approve, terraform.rollback.resolve, terraform.rollback.confirm, terraform.runs.* | Drives terraform as a child process for the apply pipeline; terraform.destroy.mintToken issues the type-to-confirm token the UI requires before a destroy call is accepted; run history is recorded for the apply-history view. |
WizardController | first-run wizard channels (prerequisites, AWS profile/credentials, bootstrap, IAM check, progress) | Backs the in-app setup wizard — see the setup guide. |
Key services
ConfigService— single place that parsesterraform.tfstateinto aTfOutputsobject (cluster ARN, subnets, SGs, EFS access points, game names, hosted zone, Discord table + secret ARNs, interactions URL). Caches in-memory;invalidateCache()is called by the games controller on list/status so a newterraform applyis picked up without an app restart.getTfStatePath()resolution order: (1)TF_STATE_PATHenv var, if set; (2) when running as a packaged Electron app (app.isPackaged),<resourcesPath>/terraform/aws/terraform.tfstate; (3) dev/test fallback — the repo-rootterraform/terraform.tfstate. A missing or unparsable state file degrades tonullrather than throwing, so the dashboard can still render pre-apply. Several other paths (getServerConfigPath(),getTfvarsPath(), the Terraform composer root) follow the same env-var → packaged-resourcesPath → dev-fallback shape — see the environment variables table below for each one's specific env var name.DiscordConfigService— persistence facade over DynamoDB (CONFIG#discord) + Secrets Manager. Concurrent reads are coalesced via an inflight-promise pattern.getRedacted()returnsbotTokenSet/publicKeySetbooleans only.getEffectiveToken()is the single escape hatch — used only by the command registrar.DiscordCommandRegistrar— callsPUT https://discord.com/api/v10/applications/{clientId}/guilds/{guildId}/commands. ValidatesguildIdas a 17–20-digit Discord snowflake before calling out (no path traversal, no SSRF).EcsService/Ec2Service/LogsService/CostService/FileManagerService— cloud-facing services.EcsServiceroutes ECS run/stop/status calls through the injectedCLOUD_PROVIDERtoken (aCloudProviderimplementation from@hyveon/cloud-aws) rather than instantiating an@aws-sdk/client-ecsclient directly;Ec2Service/LogsService/CostService/FileManagerServicestill call the AWS SDK v3 clients (CloudWatch Logs, Cost Explorer, EC2) directly, since those aren't yet behind a cloud-agnostic contract. New cloud-facing code should prefer adding to (or consuming) theCLOUD_PROVIDER/SECRETS_STORE/REMOTE_FILE_STORE/DISCORD_RECEIVER/AUDIT_LOG_STORE/RUN_RECORD_STOREtokens over reaching for a new AWS SDK client directly — see the maintainer guide.LogsService.streamLogs(game, signal)is anAsyncGeneratorthat pollsFilterLogEventsevery 2 s;getRecentLogsremains the snapshot path.DriftService— see Drift detection below.TfvarsService— seeTfvarsModule/TfvarsServicebelow.
Auth
There is no request-level auth to configure — Electron IPC is only reachable
from the app's own renderer process (via the contextBridge-exposed
window.hyveon), not from the network. There is no bearer token, no
API_TOKEN, and no equivalent of the old ApiTokenGuard anywhere in this
app.
Logging
Winston in src/logger.ts. Dev: colourised timestamps + JSON metadata.
Prod: JSON lines with ISO timestamps. Use logger.info / warn / error
everywhere, not console.log.
Env vars
| Name | Default | Purpose |
|---|---|---|
AWS_DEFAULT_REGION | — | AWS SDK region hint, read by ConfigService.readEnvRegion(). |
TF_STATE_PATH | — | Overrides the resolved path to terraform.tfstate (see ConfigService.getTfStatePath() resolution order above). |
TF_DIR | — | Overrides the Terraform composer root (terraform/, not terraform/aws/) that TerraformService spawns the terraform binary in. |
SERVER_CONFIG_PATH | — | Overrides the resolved path to server_config.json (the watchdog-knob store). Same env → packaged → dev-fallback resolution shape as TF_STATE_PATH. |
TFVARS_PATH | — | Overrides the resolved path to the local fallback copy of terraform.tfvars, used when no S3 tfvars backend is configured. |
TFVARS_CACHE_TTL_MS | 30000 | In-memory cache TTL for TfvarsService's parsed tfvars. Falls back to the default when unset, empty, non-numeric, or non-positive. |
RUNS_DIR_PATH | <tmpdir>/hyveon-runs | Directory TerraformService writes per-run plan/apply artifacts under. |
HYVEON_TFVARS_BUCKET | — | Overrides the S3 bucket name TfvarsService reads/writes tfvars against in S3 mode. Falls back to walking up from process.cwd() for a .hyveon/tfvars-bucket marker file (mirrors scripts/tfvars-sync.ts's findBucketMarker()), then null (local mode). |
NODE_ENV | — | 'production' selects Winston's JSON-lines log format over the dev colourised format; read in logger.ts. |
DIAGNOSTICS_LOG_DIR | os.tmpdir() | Outside Electron only — the directory DiagnosticsController's log-tail reads from. Inside Electron this is always <userData>/logs regardless of the env var. |
HYVEON_TEST_MODE | — | '1' enables the window.hyveon.__test mock-IPC seam in the preload script for Playwright's electron e2e project — see @hyveon/desktop-preload below. Absent (the default) in packaged/production builds. |
Credential storage at rest
Two services, both provided by ElectronStoreModule:
SafeStorageService— wraps Electron'ssafeStorageAPI, which encrypts strings using the OS keychain (Keychain on macOS, libsecret on Linux, DPAPI on Windows).isAvailable()istrueonly inside an Electron process with an unlocked keychain;encrypt()/decrypt()degrade to passthrough (with a warning onencrypt()) outside Electron — unit tests and plain-Node CI never need environment branching of their own. The caller must ensureisAvailable()returns the same value at write time and read time: a ciphertext written while the keychain was available cannot be safely round-tripped if it becomes unavailable later (locked keychain, or data shared across an Electron and a non-Electron context) —decrypt()returns the raw base64 blob unchanged in that case.ElectronStoreService— a typed wrapper overelectron-store(an ESM-only package, loaded via dynamicimport()gated onprocess.versions.electronso plain-Node test environments never hit anERR_REQUIRE_ESM). Outside Electron it falls back to an in-memoryMapwith an identical public API, so reads/writes just don't persist across process restarts in tests/CI. ItsAppStoreSchemaholdswizardCompleted, the selectedactiveCloud/AWS profile/region, the bootstrap step's last-submitted resource names (state bucket, lock table, tfvars bucket — so Settings' "Reconfigure" flow can rehydrate a non-default name), and pasted-credentials profiles keyed by profile name. Every secret field (aws.accessKeyId,aws.secretAccessKey,creds.aws.<profile>.accessKeyId/secretAccessKey) is encrypted viaSafeStorageServiceon write and decrypted on the dedicated getter — there is no path that reads or writes those fields' raw ciphertext directly. Decrypted pasted credentials must only ever be consumed inside main-process SDK client factories (e.g.CloudProviderModule'suseFactoryproviders) — never echoed back over IPC to the renderer.
TfvarsModule / TfvarsService
TfvarsService is the local-vs-S3 terraform.tfvars reader/parser backing
the Games page's declared-config view, the add/edit/remove game flows, and
drift detection. Source resolution mirrors ConfigService.getTfvarsBucket()
— when ConfigService reports an S3 backend configured (HYVEON_TFVARS_BUCKET
env var, or a .hyveon/tfvars-bucket marker file found by walking up from
process.cwd()), reads/writes go through the injected REMOTE_FILE_STORE
token; otherwise they hit ConfigService.getTfvarsPath() on local disk.
Parsed results are cached in-memory for TFVARS_CACHE_TTL_MS (default
30 s) so repeated reads (e.g. drift checks) don't re-parse HCL on every
call; invalidateCache() is called after any write. See the
S3 tfvars storage guide for the local-vs-S3 tradeoff
this mirrors.
Drift detection
DriftService (provided directly by AppModule, backing the drift.get
IPC channel and the /app/dashboard and
/app/games pages' drift indicators) computes the difference
between the declared game-server config (TfvarsService.getGameServers()
— what's in terraform.tfvars right now) and the applied config
(ConfigService.getTfOutputs()?.applied_game_servers — what Terraform last
actually applied). Per game, the pure computeDrift() function classifies:
pending_create— declared but not yet in the deployed set.pending_delete— deployed but no longer declared.config_drift— declared and deployed, butimage/cpu/memory/ports/volumesdiffer from what was last applied, withchangedFieldslisting exactly which.ports/volumescomparisons are order-insensitive (canonicalized before comparing), since HCL/JSON key order isn't guaranteed stable and reordering entries in tfvars isn't a real config change.- Games matching on every compared field produce no entry — the report only lists what's out of sync.
getDrift() invalidates both the tfstate cache and the TfvarsService
cache first, so a fresh terraform apply or tfvars edit is reflected
without an app restart.
@hyveon/cloud-aws
app/packages/cloud-aws — the AWS implementation of the six cloud-agnostic
contracts @hyveon/shared/cloud.js declares (CloudProvider, SecretsStore,
RemoteFileStore, DiscordEventReceiver, AuditLogStore, RunRecordStore).
CloudProviderModule (see the module graph above) is the only place that
imports from this package directly — every other consumer in desktop-main
depends on the @hyveon/shared interface via one of the six injection
tokens, never on a concrete class from here. Extracted as its own workspace
package (rather than living inside desktop-main) so a future non-AWS cloud
provider package can sit alongside it without desktop-main depending on
either concrete implementation.
@hyveon/desktop-preload
app/packages/desktop-preload — the Electron preload script, run in a
privileged-but-sandboxed context between the main process and the renderer.
contextBridge.exposeInMainWorld('hyveon', ...) exposes the typed IPC
surface the renderer calls as window.hyveon.*; every method forwards to
ipcRenderer.invoke(channel, ...args).
HYVEON_TEST_MODE and the test seam
When process.env.HYVEON_TEST_MODE === '1' at preload-script load time, the
bridge gains an additional window.hyveon.__test namespace:
window.hyveon.__test.mock(channel, handler) // handler: replacement fn or plain value
window.hyveon.__test.clearMocks() // alias: reset()
Once a channel is mocked, every subsequent invoke(channel, ...) call
consults an internal Map<string, fn> before ever reaching
ipcRenderer.invoke — so a Playwright spec can drive the real Electron
shell and real React app while the Nest-side main process is never touched
for that channel. This backs the electron Playwright project's specs
(electron-smoke.spec.ts, ipc-mock.spec.ts, discord.spec.ts, and the
documentation screenshot harness — see the
maintainer guide).
This seam is gated off in production. When HYVEON_TEST_MODE is unset —
the default for packaged/production builds and for npm run app:dev without
the flag explicitly set — the if (isTestMode) branch in the preload script
is never entered, and window.hyveon.__test is undefined. There is no
runtime toggle, config file, or IPC call that can expose the mock registry
to an end user's build.
@hyveon/web
app/packages/web — React + Vite.
- Entry:
src/main.tsx→src/App.tsx, rendered inside an ElectronBrowserWindow. - Auth: none — there's no bearer token, no login prompt, and nothing in
localStoragegating API access. The renderer'swindow.hyveonbridge is only reachable from the app's own preload-scoped context.
Routes
The renderer is a multi-route single-page app (react-router), not a single
dashboard screen. app.component.tsx declares:
| Path | Page | See |
|---|---|---|
/ | Dashboard — game cards, KPI strip, start/stop | /app/dashboard |
/games, /games/:name | Games list + game detail | /app/games |
/terraform, /terraform/history, /terraform/history/:runId | Plan/apply/destroy, run history, run detail | /app/terraform |
/discord | Discord bot credentials, guilds, admins, per-game permissions | /app/discord |
/logs | Live log viewer | /app/logs |
/costs | Cost estimates and actuals | /app/costs |
/audit | Audit log entries | /app/audit |
/settings | Watchdog knobs, cloud setup, diagnostics | /app/settings |
| — | First-run setup wizard, shown in place of the router until wizardCompleted | /app/first-run-wizard |
For what each screen looks like and how to use it, start at Using the app — this page stays at the wiring level (IPC channels, services, module graph).
API layer
src/api.service.ts exports a single api object with one method per IPC
channel. Every call is delegated straight to window.hyveon.* — there are no
fetch calls and no bearer-token plumbing anywhere in this module.
Vite dev config
vite.config.ts serves the renderer on :5173 for HMR purposes only; it is
driven by electron-vite (see electron.vite.config.ts), not accessed
directly as a network API. Production builds to dist/, packed into the
Electron app's asar archive.
Running e2e tests
The web package ships a Playwright harness with two projects, migrating from the first to the second: chromium runs specs against the production build (vite build + vite preview), polyfilling window.hyveon with an HTTP bridge so every /api/* call can be stubbed via page.route(); electron launches the packaged Electron app directly via _electron.launch() and stubs IPC responses through the window.hyveon.__test.mock() test bridge instead. The Nest server never starts in either project.
# One-off (builds the app, starts vite preview, runs specs, exits)
npm run app:test:e2e
# Keep vite preview running between runs (set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD if already installed)
cd app/packages/web
npm run build && npm run preview & # leave running
npx playwright test # fast re-run without rebuilding
First-time setup — install the Chromium browser binary:
cd app/packages/web
npx playwright install chromium
Specs live under app/packages/web/e2e/specs/. Shared stubs and fixtures are in app/packages/web/e2e/fixtures/. On CI, Playwright uploads traces and videos as artifacts when a spec fails; see .github/workflows/e2e.yml.