Skip to main content

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 desktop:dev) launches the full Electron app with hot-reload on renderer saves; electron-vite serves the renderer for HMR purposes only, never as 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.

ModulePurpose
types.tsDiscordAction, DiscordConfig, RedactedDiscordConfig, GameStatus, StartResult, PendingInteraction. The API shapes every other package agrees on.
canRun.tsThe 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.tsCOMMAND_DESCRIPTORS — static JSON for the four slash commands. actionForCommand(name) maps to the start/stop/status bucket used by canRun().
sanitize.tsisSafeGameKey() (blocks __proto__, constructor, prototype), asString(), asStringArray(), sanitizeGamePermission(). Applied on DDB reads where input is operator-provided.
formatStatus.tsformatGameStatus(status) — Discord-ready one-liner with emoji and hostname.
ddb/client.tsLazy DynamoDB DocumentClient. Region fallback: AWS_REGION_AWS_REGIONAWS_DEFAULT_REGIONus-east-1.
ddb/configStore.tsgetDiscordConfig() / putDiscordConfig() for the CONFIG#discord row.
ddb/pendingStore.tsgetPending() / putPending() / deletePending() for PENDING#{taskArn}. putPending() sets expiresAt = now + 15 minutes so DDB TTL reaps stale rows.
secrets/secretsStore.tsSecrets Manager wrapper with a 5-minute in-process cache. Recognises the infra program's "placeholder" seed value as "not configured". invalidateSecretsCache() is called by the Nest credentials endpoint. Also owns the app-owned health-check credential secret's full write lifecycle — healthCheckAuthSecretName(gameId) (deterministic name hyveon-{gameId}-healthcheck-auth, one per game), upsertHealthCheckAuthSecret() (tries PutSecretValueCommand first, falls back to CreateSecretCommand only on ResourceNotFoundException — no separate existence check; if the same name is still within its recovery window from a prior deleteHealthCheckAuthSecret(), both commands instead fail with InvalidRequestException, confirmed via DescribeSecretCommand's DeletedDate, so this un-schedules the deletion with RestoreSecretCommand and retries the put once), and deleteHealthCheckAuthSecret() (default recovery window, no-op if already absent). See Games for the operator-facing credential-type model this backs.

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()):

  1. Guards against running outside an Electron main process — desktop-main throws immediately if process.versions.electron is unset, rather than silently doing nothing under plain Node.
  2. NestFactory.createMicroservice(AppModule, { strategy: new BridgedElectronIPCTransport() }).
  3. app.listen() starts the transport, registering its internal @MessagePattern dispatch.
  4. registerIpcMainBridges(strategy) bridges each of those patterns onto a real ipcMain.handle registration, so ipcRenderer.invoke calls from the renderer resolve instead of hanging.

This app has no NestJS exception filter — registerIpcMainBridges is the one structural choke point every bridged handler passes through, so it also catches any rejection there and normalizes it to a plain Error (message only) before rethrowing, logging the pattern name and original message/stack via the winston logger. Without this, a handler that lets a raw SDK/Node error escape (e.g. an AWS SDK exception carrying non-plain fields like $metadata) fails Electron's structured-clone when the rejection is marshalled back to the renderer, surfacing as Error: An object could not be cloned and leaving the caller's invoke() promise unresolved instead of the real error message.

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. Imports AwsModule, DiscordModule, DeploymentConfigModule, RunRecordModule, PulumiEngineModule, PulumiWorkspaceModule, PulumiServiceModule, WizardModule, and ElectronStoreModule (nine imports; ConfigModule and CloudProviderModule are not direct imports — they arrive transitively through AwsModule/DeploymentConfigModule/RunRecordModule, each of which imports both). Also directly provides a handful of controller-adjacent services that don't warrant their own module (DiagnosticsService, DriftService, GamesWriteService, AuditService, GameWizardDraftService) plus the DIAGNOSTICS_LOG_DIR token.
  • ConfigModule — imports ElectronStoreModule and PulumiServiceModule (so ConfigService can inject PulumiService); provides just ConfigService. Extracted on its own so every other feature module can depend on it without pulling in AwsModule.
  • CloudProviderModule — imports ConfigModule. Binds seven cloud-agnostic contracts (from @hyveon/shared/cloud.js) to concrete @hyveon/cloud-aws implementations via useFactory providers keyed off ConfigService.getActiveCloud(): CLOUD_PROVIDER, SECRETS_STORE, REMOTE_FILE_STORE, DISCORD_RECEIVER, AUDIT_LOG_STORE, DISCORD_CONFIG_STORE, and RUN_RECORD_STORE (all declared in cloud-provider.tokens.ts). Consumers inject via @Inject(CLOUD_PROVIDER) etc. and depend only on the @hyveon/shared interface — 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 the CLOUD_BINDINGS registry in cloud-provider.module.ts, not by touching this module's provider definitions.
  • AwsModule — imports ConfigModule and CloudProviderModule (re-exporting both); provides and exports Ec2Service, EcsService, LogsService, CostService, SchedulerService, FileManagerService. It no longer provides ConfigService directly (that's ConfigModule's job — AwsModule re-exports it for existing consumers that import AwsModule expecting ConfigService to be available) and no longer wires AwsCloudProvider/ AwsSecretsStore itself — EcsService injects CLOUD_PROVIDER and DiscordConfigService injects SECRETS_STORE and DISCORD_CONFIG_STORE, all bound by CloudProviderModule. DISCORD_CONFIG_STORE (bound to AwsDiscordConfigStore) is what gives DiscordConfigService's DynamoDB reads/writes the wizard's resolved AWS credentials — before this token existed, the service read/wrote through @hyveon/shared's uncredentialed getDocClient() singleton, which falls back to the SDK's default provider chain and throws a spurious CredentialsProviderError ("Your session has expired") in the GUI-launched Electron process once any unrelated ambient aws login session on the host expires.
  • DiscordModule — imports AwsModule; provides DiscordConfigService and DiscordCommandRegistrar. No discord.js, no gateway — the bot is two Lambdas plus Discord's REST API.
  • DeploymentConfigModule — imports ConfigModule and CloudProviderModule (for the REMOTE_FILE_STORE token); provides DeploymentConfigService, the S3-backed deployment-config JSON reader/parser. There is no local-file fallback — see DeploymentConfigModule / DeploymentConfigService below.
  • RunRecordModule — imports ConfigModule and CloudProviderModule; provides RunService (the in-memory + DynamoDB apply lock guarding plan/apply/destroy submissions) and RunRecordService (run-history persistence), bound to the narrow RUN_LOCK_SERVICE/RUN_RECORD_PERSISTER DI tokens that PulumiService resolves lazily.
  • PulumiEngineModule — imports nothing; provides PulumiEngineService, which resolves and provisions the pinned Pulumi CLI engine into an app-owned directory (never ~/.pulumi, never PATH) without requiring the operator to install anything.
  • PulumiWorkspaceModule — imports PulumiEngineModule and ElectronStoreModule; provides PulumiWorkspaceService, the Automation API LocalWorkspace/S3-backend/secrets-passphrase seam behind getOrCreateStack().
  • PulumiServiceModule — imports PulumiWorkspaceModule, PulumiEngineModule, and ElectronStoreModule; provides PulumiService, the plan/apply/destroy/rollback + getStackOutputs() engine service. It deliberately does not import RunRecordModule, CloudProviderModule, or DeploymentConfigModule — those would close a native-ESM module cycle through ConfigModule — so PulumiService resolves RUN_RECORD_PERSISTER, REMOTE_FILE_STORE, and the DeploymentConfigService it needs at call time via ModuleRef.get(token, { strict: false }) instead of constructor injection.
  • WizardModule — imports ElectronStoreModule; provides AwsProfileService, BootstrapService, IamCheckService, FirstRunWizardService, and GuidedIamService for the first-run setup wizard. GuidedIamService backs the guided-IAM step: renders the iam-bootstrap.yaml CloudFormation template, hands off to the console, intakes the operator-pasted bootstrap key, and performs the mandatory mint-then-revoke rotation onto a freshly-minted key pair — plus a standalone manual-revoke action for the rotation's failure path. PrerequisiteService was deleted (migrate-iac-to-pulumi change, tasks 10.1/10.2) along with the wizard's old prerequisites step.
  • ElectronStoreModule — provides SafeStorageService (OS-keychain encryption) and ElectronStoreService (the typed electron-store consumer 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, ...). The one exception is window.maximizedChange, a one-way main → renderer push (webContents.send, consumed via ipcRenderer.on) rather than an invoked request/response — called out again in the WindowController row below.

ControllerRepresentative channelsPurpose
GamesControllergames.list, games.status, games.getStatus, games.start, games.stop, games.create, games.update, games.delete, games.draft.get, games.draft.save, games.draft.updateStepIndex, games.draft.clearList/read status, trigger RunTask/StopTask, manage gameServers entries in the JSON configuration object (deployment-config.json) via DeploymentConfigService. Invalidates DeploymentConfigService's cache on list/status reads so a config edit made outside the app (e.g. by another operator) is picked up without restarting; ConfigService's cached stack outputs are untouched by this and expire on their own 20s/invalidateCache() schedule. The four games.draft.* channels save/resume/discard a single in-progress add-game wizard draft via GameWizardDraftService — see Credential storage at rest for where it's persisted. games.draft.updateStepIndex exists so the renderer can persist step-only navigation on a resumed draft without re-sending its (secret-redacted) copy through games.draft.save, which would otherwise overwrite the real values still on disk.
CostsControllercosts.estimatePer-game Fargate estimates, derived from each game's {game}-server task-definition CPU/memory. The app makes no AWS Cost Explorer API calls — see Costs.
LogsControllerlogs.get, logs.stream, logs.lambda.get, logs.lambda.streamSnapshot of last N log events and a streaming channel that pushes new events as they arrive (polls FilterLogEvents every 2 s under the hood) for a game's /ecs/{game}-server log group; the logs.lambda.* pair does the same against /aws/lambda/{projectName}-{functionKey} for one of the app's 5 Lambda functions (LambdaFunctionKey), resolving projectName from DeploymentConfig settings and falling back to the hyveon default on any read failure.
FilesControllerfiles.list, files.start, files.stopAd-hoc FileBrowser task against the game's EFS access point. files.start seeds a random per-launch password (bcrypt-hashed into the container's --password flag), returns the one-time plaintext credential in its response, and creates an EventBridge Scheduler one-time schedule that auto-stops the task after 2 hours; files.stop cancels that schedule.
DiscordControllerdiscord.getConfig, discord.putConfig, discord.listGuilds, discord.addGuild, discord.removeGuild, discord.registerCommands, discord.getAdmins, discord.putAdmins, discord.getPermissions, discord.putPermission, discord.deletePermissionRead-redacted config, save credentials, manage guild allowlist + commands, admins, per-game permissions.
EnvController, DiagnosticsController, DriftController, AuditControllerenv.get; diagnostics.tail/diagnostics.path/diagnostics.reportError/diagnostics.reportLog/diagnostics.exportBundle/diagnostics.showInFolder; drift.get; audit.listEnvironment info, log-tail diagnostics, config-drift detection, and the audit-log view. Two renderer-forwarding channels land in the same main-*.log file but stay distinguishable by line prefix: diagnostics.reportError forwards a renderer-side crash (from the top-level ErrorBoundary or a window.onerror/unhandledrejection listener) via DiagnosticsService.logRendererError, writing renderer error (${source}): ${message}; diagnostics.reportLog forwards batched console.log/info/warn/error calls (every call, not just crashes — see installConsoleForwarding() below) via DiagnosticsService.logRendererConsoleBatch, writing one renderer console (${level}): ${message} line per entry (level mapped logdebug, others 1:1) plus a combined renderer console: ${n} entries dropped (queue capacity exceeded) warning per flush when the renderer's own queue overflowed. diagnostics.exportBundle opens a native save dialog, then (on a chosen path) calls the new DiagnosticsBundleService to gather four sections — recent log text (regex-scrubbed), an allowlisted deployment-config summary, app/system metadata, and a best-effort AWS resource snapshot reusing ConfigService.getStackOutputs()/EcsService.getStatus() — via Promise.allSettled and streams them into a single .zip (archiver) written atomically (temp file + rename) to disk; any section that fails is recorded in the bundle's own errors.json, never surfaced as a thrown error. A dialog cancel resolves { status: 'cancelled' } with nothing written. diagnostics.showInFolder reveals a written bundle via shell.showItemInFolder, backing the Settings page's "Show in folder" action.
IacControlleriac.stack.initialize, iac.plan, iac.apply, iac.destroy.mintToken, iac.destroy, iac.output, iac.approve, iac.rollback.resolve, iac.rollback.confirm, iac.lock.clearDrives PulumiService (Automation API via LocalWorkspace, which launches the pinned @pulumi/pulumi engine as a child process through LocalWorkspaceOptions.pulumiCommand — the app downloads and verifies that engine itself, so no host-installed or PATH-discovered CLI is ever used) for the plan/apply/destroy/rollback pipeline. iac.destroy.mintToken issues the type-to-confirm token the UI requires before a destroy call is accepted; iac.lock.clear recovers a stale Pulumi backend lock.
IacRunsControlleriac.runs.get, iac.runs.logs, iac.runs.list, iac.runs.logUrl, iac.runs.lock.clear.mintToken, iac.runs.lock.clearRun history: fetch a record, stream/fetch its log, list/paginate, resolve an offloaded S3 log link. iac.runs.lock.clear.mintToken/iac.runs.lock.clear recover a wedged durable run lock (RunService's apply lock, distinct from IacController's iac.lock.clear Pulumi-backend-lock recovery) — mint-then-confirm the same as iac.destroy.mintToken, backing the /iac page's busy-banner "Clear lock and retry" action described in The workspace-busy banner.
IacSettingsControlleriac.settings.get, iac.settings.update, iac.settings.engineVersion, iac.settings.autoUpdate.get, iac.settings.autoUpdate.update, iac.settings.autoUpdate.checkReads/writes every top-level deployment-config.json field EXCEPT gameServers — backs the Settings page's General section. update validates via the shared validateDeploymentSettingsPatch (@hyveon/shared) before delegating to DeploymentConfigService.updateTopLevelSettings(); a stale expectedVersionId returns { code: 'conflict' } rather than silently overwriting a concurrent edit. engineVersion reads PulumiEngineService.getResolvedVersion() (null when not yet provisioned) — backs the Cloud Setup section's Pulumi engine version row. The autoUpdate.* pair reads/writes ElectronStoreService's enableAutoUpdate flag (a local install-level setting, not a deployment-config.json field) — backs the Updates section's toggle. autoUpdate.check triggers an on-demand checkForUpdatesNow() call in updater.ts, independent of the enableAutoUpdate flag (that flag only gates initUpdater's automatic boot-time check); it never downloads or installs, and resolves the discriminated ManualUpdateCheckResult (@hyveon/shared) — { ok: true, updateAvailable: true, version }, { ok: true, updateAvailable: false }, or { ok: false, message }. Both engine and store are optional constructor params so direct-construction test call sites keep compiling without stubbing them; a real AppModule bootstrap always resolves both.
WizardControllerfirst-run wizard channels (AWS profile/credentials, bootstrap, IAM check, guided-IAM CloudFormation bootstrap, progress)Backs the in-app setup wizard — see the setup guide.
WindowControllerwindow.minimize, window.toggleMaximize, window.close, window.isMaximized (invoke request/response) + window.maximizedChange (main → renderer push, not an invoke channel — see above)Minimize, maximize, close, and query the maximized state of the main window. window.maximizedChange is pushed whenever the main window's maximized state changes, for any reason (the app's own button, OS-level snap/restore, or macOS/Windows native window controls). Backs the app header's merged title bar, rendered with platform-specific window-control buttons: native traffic lights on macOS, native titleBarOverlay on Windows, and app-drawn buttons on Linux. macOS and Windows get double-click-to-maximize on the drag region for free from Electron/Chromium; the app does not add an equivalent handler for Linux's app-drawn header.

Key services

  • ConfigService — no longer parses any state file off disk. Its getStackOutputs() is a memoised delegate to PulumiService.getStackOutputs(), which reads the deployed Pulumi stack's outputs (StackOutputs from @hyveon/shared — cluster ARN, subnets, security groups, EFS access points, game names, hosted zone, Discord table + secret ARNs, interactions URL) via the Automation API against the S3 backend, not a local state file. The in-flight promise is cached so concurrent callers coalesce; a resolved null (infra not yet deployed) expires after 20 s, a resolved value is cached until invalidateCache() — called by the games controller on list/status so a fresh pulumi up is picked up without an app restart. The old local-file-parsing path was removed as dead code — nothing reads a local state file under the Pulumi engine. getConfigurationBucket() (the configuration S3 bucket name) is a different, unrelated resolution path — see DeploymentConfigModule / DeploymentConfigService below.
  • DiscordConfigService — persistence facade over DynamoDB (CONFIG#discord) + Secrets Manager. Concurrent reads are coalesced via an inflight-promise pattern. getRedacted() returns botTokenSet / publicKeySet booleans only. getEffectiveToken() is the single escape hatch — used only by the command registrar.
  • DiscordCommandRegistrar — calls PUT https://discord.com/api/v10/applications/{clientId}/guilds/{guildId}/commands. Validates guildId as a 17–20-digit Discord snowflake before calling out (no path traversal, no SSRF).
  • EcsService / Ec2Service / LogsService / SchedulerService / CostService / FileManagerService — cloud-facing services. EcsService routes ECS run/stop/status calls through the injected CLOUD_PROVIDER token (a CloudProvider implementation from @hyveon/cloud-aws) rather than instantiating an @aws-sdk/client-ecs client directly; Ec2Service / LogsService / SchedulerService still call the AWS SDK v3 clients (EC2, CloudWatch Logs, EventBridge Scheduler) directly, since those aren't yet behind a cloud-agnostic contract. FileManagerService composes EcsService/Ec2Service/SchedulerService rather than calling any SDK client itself. CostService is pure arithmetic — no AWS SDK client at all — since the Cost Explorer call chain was removed (see openspec/changes/remove-cost-explorer-calls). New cloud-facing code should prefer adding to (or consuming) the CLOUD_PROVIDER / SECRETS_STORE / REMOTE_FILE_STORE / DISCORD_RECEIVER / AUDIT_LOG_STORE / DISCORD_CONFIG_STORE / RUN_RECORD_STORE tokens over reaching for a new AWS SDK client directly — see the maintainer guide. LogsService.streamLogs(game, signal) is an AsyncGenerator that polls FilterLogEvents every 2 s; getRecentLogs remains the snapshot path. streamLambdaLogs(functionKey, signal) / getRecentLambdaLogs(functionKey) are the same two shapes against a resolved Lambda log group (/aws/lambda/{projectName}-{functionKey}), with projectName read from DeploymentConfig settings and falling back to the hyveon default.
  • DriftService — see Drift detection below.
  • DeploymentConfigService — see DeploymentConfigModule / DeploymentConfigService below.
  • GamesWriteService — backs GamesController's games.create/games.update/games.delete. On a health-check credential change, it validates the operator-submitted plaintext, runs a structural validateGameServer pass against a non-mutating preview before touching Secrets Manager (so a rejected save — a port collision, a bad cpu/memory pairing, anything unrelated to auth — never creates, mutates, or deletes a secret), and only then calls secretsStore.ts's upsertHealthCheckAuthSecret/deleteHealthCheckAuthSecret for a basic/bearer credential. Omitting healthCheck.auth on an update leaves the existing credential unchanged; submitting auth: null clears it (deleting the app-owned secret first, if the prior credential was basic/bearer); switching a credential away from basic/bearer deletes the now-orphaned app-owned secret; deleting the game deletes its app-owned secret too. Secret-deletion failures are logged and swallowed (cleanup must not block the write it's tidying up after); secret-creation/update failures are surfaced as a user-facing error.
  • GameWizardDraftService — owns the single in-progress add-game wizard draft slot on ElectronStoreService (addGameWizardDraft, see Credential storage at rest below). get() degrades to null on any corrupt/unrecognized stored shape rather than throwing, mirroring FirstRunWizardService's resume-file behaviour; save()/clear() log and swallow write failures rather than surfacing them, since autosave is a best-effort background write, not an operator-triggered action that needs its own error path.

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.

The winston log file is the only durable record of what happened in a given run — there's no HTTP transport and no NestJS exception filter to fall back on for tracing (see Auth above). Two conventions keep it useful, applied across every controller and every service method in desktop-main/src/services/*.ts that can fail (not just controllers): every @MessagePattern handler logs its pattern name on entry via logger.debug (pattern name only, never payload contents — a payload can carry pasted AWS credentials); and every service method that calls an AWS SDK operation or the Pulumi engine likewise logs entry via logger.debug (method name only, never payload contents) and catches the error, logging it via logger.warn (recoverable/expected) or logger.error (unexpected) with just err instanceof Error ? err.message : String(err), and either returns a modeled result or rethrows a plain Error — a raw AWS SDK/Node error object is never left to escape a service method uncaught, since it can carry non-plain fields (e.g. $metadata) that fail Electron's structured-clone when marshalled back to the renderer. Pure helpers with no external call and no possible failure mode (CostService.ts's arithmetic, sleep.ts, mergeGameLists.ts) are exempt — there's nothing to log.

Env vars

NameDefaultPurpose
AWS_DEFAULT_REGIONAWS SDK region hint, read by ConfigService.readEnvRegion().
CONFIG_CACHE_TTL_MS30000In-memory cache TTL for DeploymentConfigService's parsed configuration. Falls back to the default when unset, empty, non-numeric, or non-positive.
RUNS_DIR_PATH<userData>/runsDirectory PulumiService writes per-run plan/apply artifacts under.
HYVEON_CONFIG_BUCKETDev/CI override for the S3 configuration bucket name DeploymentConfigService/PulumiService read/write against — wins over the operator-configured value. Not how the packaged app resolves the bucket in normal use; see DeploymentConfigModule / DeploymentConfigService below for the real resolution order.
NODE_ENV'production' selects Winston's JSON-lines log format over the dev colourised format; read in logger.ts.
DIAGNOSTICS_LOG_DIRos.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's safeStorage API, which encrypts strings using the OS keychain (Keychain on macOS, libsecret on Linux, DPAPI on Windows). isAvailable() is true only inside an Electron process with an unlocked keychain; encrypt()/decrypt() degrade to passthrough (with a warning on encrypt()) outside Electron — unit tests and plain-Node CI never need environment branching of their own. The caller must ensure isAvailable() 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 over electron-store (an ESM-only package, loaded via dynamic import() gated on process.versions.electron so plain-Node test environments never hit an ERR_REQUIRE_ESM). Outside Electron it falls back to an in-memory Map with an identical public API, so reads/writes just don't persist across process restarts in tests/CI. Its AppStoreSchema holds wizardCompleted, the selected activeCloud/AWS profile/region, the bootstrap step's last-submitted resource names (state bucket, configuration bucket — so Settings' "Reconfigure" flow can rehydrate a non-default name), pasted-credentials profiles keyed by profile name, the single in-progress add-game wizard draft (addGameWizardDraft — the draft's field values, which wizard step the operator was on, and when it was last autosaved; see Draft autosave), and enableAutoUpdate (off by default — gates updater.ts's electron-updater checks; editable via the Updates section's toggle). Every secret field (aws.accessKeyId, aws.secretAccessKey, creds.aws.<profile>.accessKeyId/secretAccessKey) is encrypted via SafeStorageService on 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's useFactory providers) — never echoed back over IPC to the renderer. addGameWizardDraft is deliberately not encrypted this way at rest, even though its environment rows and file-seed rows can hold operator-entered values the operator might consider sensitive (e.g. a game server password) — the on-disk write matches the plaintext-at-rest posture of the deployment-config.json write the draft eventually becomes on submit, which also isn't field-level encrypted. Those specific fields (environment[].value, file_seeds[].content/content_base64, and — for a healthCheck credential — secretArn/username/password/ token) are, however, never returned to the renderer: GameWizardDraftService.get() blanks them out before the result crosses the IPC boundary, so a draft resumed via the games-page banner comes back with row names/paths and the credential's chosen type (authType) intact, but those value fields empty — the operator re-enters them. secretSet, populated from the redacted read-side game-server shape rather than the draft itself, is the only signal that a credential is already configured. Only save() (from the main process, in response to the wizard's own autosave) ever writes the unredacted values; no read path echoes them back.

DeploymentConfigModule / DeploymentConfigService

DeploymentConfigService is the S3-backed deployment-config JSON reader/parser backing the Games page's declared-config view, the add/edit/remove game flows, and drift detection — see openspec/specs/desktop-only-operator-surface's "No operator-editable configuration files" requirement. There is no local-file fallback: ConfigService.getConfigurationBucket() resolves the configured S3 bucket (the HYVEON_CONFIG_BUCKET env var as a dev/CI override, otherwise ElectronStoreService's bootstrap.configurationBucket — the value the First-Run Wizard's bootstrap step persisted), and every read/write goes through the injected REMOTE_FILE_STORE token keyed by the fixed CONFIGURATION_OBJECT_KEY constant (@hyveon/shared, 'deployment-config.json'). When no bucket is configured, getGameServers() resolves to [] (never rejects — its isConfigured() method lets a caller distinguish "unconfigured" from "genuinely zero games"), while the write paths and getRawConfig() throw a typed ConfigurationNotConfiguredError. Parsed results are cached in-memory for CONFIG_CACHE_TTL_MS (default 30 s) so repeated reads (e.g. drift checks) don't re-fetch from S3 on every call; invalidateCache() is called after any write. ConfigService.getConfigurationBucket() is the only backend-selection path in the app: it checks the HYVEON_CONFIG_BUCKET env var (a dev/CI override) before falling back to the wizard-persisted bootstrap.configurationBucket, and returns null — never a local-file path — when neither is set.

getTopLevelSettings()/updateTopLevelSettings() are the top-level-field counterpart to addGameServer()/updateGameServer()/removeGameServer() — same conditional-put/OptimisticLockError contract, but merging a patch onto every field except gameServers (which updateTopLevelSettings() always takes from the freshly-read document, never from the caller's patch, even if a caller's payload contains a gameServers key at runtime). Backs the IacSettingsController row above.

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 (DeploymentConfigService.getGameServers() — what's in the configuration bucket's deployment-config.json right now) and the applied config (ConfigService.getStackOutputs()'s appliedGameServers field — what the Pulumi stack 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, but image/cpu/memory/ ports/volumes differ from what was last applied, with changedFields listing exactly which. ports/volumes comparisons are order-insensitive (canonicalized before comparing), since JSON key order isn't guaranteed stable and reordering entries in the configuration 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 ConfigService's cached stack outputs and the DeploymentConfigService cache first, so a fresh Pulumi apply or configuration edit is reflected without an app restart.

@hyveon/cloud-aws

app/packages/cloud-aws — the AWS implementation of the seven cloud-agnostic contracts @hyveon/shared/cloud.js declares (CloudProvider, SecretsStore, RemoteFileStore, DiscordEventReceiver, AuditLogStore, DiscordConfigStore, 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 seven 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 desktop: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.tsxsrc/app.component.tsx, rendered inside an Electron BrowserWindow. main.tsx calls two forwarding installers from src/lib/report-renderer-error.utils.ts before rendering: installGlobalErrorReporting() (wires window.onerror/unhandledrejection to diagnostics.reportError) and installConsoleForwarding() (wraps console.log/info/warn/error so every call is both printed to devtools as normal and queued for batched delivery to diagnostics.reportLog — a no-op wherever the bridge doesn't implement diagnostics.reportLog, which includes the chromium Playwright project's HTTP-polyfilled window.hyveon stub as well as a genuinely absent bridge). For the Diagnostics panel UI this backs, see /app/settings.
  • Auth: none — there's no bearer token, no login prompt, and nothing in localStorage gating API access. The renderer's window.hyveon bridge 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 routes via HashRouter, not BrowserRouter: the packaged renderer loads via win.loadFile() (file:///C:/... on Windows, drive letter included), and BrowserRouter's absolute-path route matching breaks against that prefix. HashRouter keeps the route entirely after a #, unaffected by the underlying file:// path — so every path below is addressed at runtime as /#<path> (e.g. /#/costs), not the bare path:

PathPageSee
/Dashboard — game cards, KPI strip, start/stop/app/dashboard
/games, /games/:nameGames list + game detail/app/games
/iac, /iac/history, /iac/history/:runIdPlan/apply/destroy, run history, run detail/app/iac
/discordDiscord bot credentials, guilds, admins, per-game permissions/app/discord
/logsLive log viewer for one game server/app/logs
/logs/infrastructureLive log viewer for one of the app's 5 Lambda functions, reached via the sidebar's Infra Logs child link/app/logs
/costsPer-game Fargate cost estimates, AWS Cost Explorer link-out/app/costs
/auditAudit log entries/app/audit
/settingsWatchdog summary, cloud health, cloud setup, updates, deployment settings, 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.