Skip to content

Commit 821249b

Browse files
CoderCococlaude
andauthored
fix(server): restore DI, embed tfstate at build, fix error handling (#33)
## Summary - **Restore Nest.js DI**: replaced `tsx watch` with `tsc -b --watch` + `node --watch` in the server's dev script — tsx 4.x/esbuild silently drops `emitDecoratorMetadata`, breaking all constructor injection so every service injected as `undefined` - **Build-time tfstate embedding**: new `app/scripts/embed-tfstate.mjs` runs as `predev`/`prebuild` hook, pulling live state via `terraform state pull` (any backend) and writing it to `app/packages/server/src/generated/tfstate.ts`; `ConfigService` uses it as a fallback when the runtime `terraform.tfstate` file is absent - **API error handling**: `api.ts` now throws on non-2xx responses instead of resolving with the error body, preventing silent failures downstream; `CostPanel` gets defensive `?.` chaining on `daily`; `DiscordPanel` catches load errors and shows a user-friendly message instead of getting stuck on "Loading…" ## Test plan - [ ] `npm run dev` from `app/` runs `terraform state pull`, embeds real state, and Nest boots with all DI resolved (no `undefined` service errors) - [ ] Games list populates from embedded tfstate when `terraform/terraform.tfstate` is absent - [ ] API 500 responses surface as errors in the UI rather than silent empty state - [ ] Cost panel renders without crashing when cost data is unavailable - [ ] Discord panel shows "unavailable" message instead of spinning forever when infra isn't deployed - [ ] `npm test` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f53a39f commit 821249b

11 files changed

Lines changed: 200 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ When adding a game, only edit `terraform.tfvars`. Don't hand-write new resources
8383

8484
`ConfigService.getTfOutputs()` (in `app/packages/server/src/services/ConfigService.ts`) parses `terraform.tfstate` as JSON and caches it in-memory. `invalidateCache()` is called on `/api/games` and `/api/status` to pick up new deploys. The app's container mounts `./terraform:/app/terraform:ro` — this path coupling matters if directory structure changes. The parsed `TfOutputs` shape now also exposes `discord_table_name`, `discord_bot_token_secret_arn`, `discord_public_key_secret_arn`, and `interactions_invoke_url` so `DiscordConfigService` can reach the Discord stores without extra env-var plumbing.
8585

86+
**Build-time state embedding**: `app/scripts/embed-tfstate.mjs` runs automatically via `predev` and `prebuild` npm lifecycle hooks. It reads Terraform state (via `terraform state pull`, or a file path from `TF_STATE_PATH` / the first CLI arg) and writes `app/packages/server/src/generated/tfstate.ts` with the state serialized as a JSON literal. `ConfigService` imports this as `EMBEDDED_TFSTATE` and uses it as a fallback when the runtime `terraform.tfstate` file is absent — useful in Docker or CI environments where the Terraform directory isn't mounted. When neither source is available, `getTfOutputs()` returns `null` and callers degrade gracefully. The generated file is committed as `null` (no real state) and is overwritten at dev/build time.
87+
8688
### API authentication
8789

8890
Every `/api/*` route is gated behind a bearer token via `ApiTokenGuard` in `app/packages/server/src/guards/api-token.guard.ts`, registered globally in `AppModule` as an `APP_GUARD` provider so it applies to every controller automatically. The token comes from env `API_TOKEN` (wins, even when set to empty to deliberately disable) or `api_token` in `server_config.json`. In production (`NODE_ENV=production`), boot aborts in `main.ts` if no token is configured. In dev, a warning is logged and unauthenticated requests are allowed for convenience. The web client stores the token in `localStorage` under key `apiToken` and sends it as `Authorization: Bearer`. Don't remove the guard or bypass it on individual controllers — Copilot flagged the unauthenticated surface as a security issue and this is the fix.

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"packages/lambda/*"
1111
],
1212
"scripts": {
13+
"predev": "node scripts/embed-tfstate.mjs",
1314
"dev": "concurrently -n server,client -c cyan,magenta \"npm run dev -w @gsd/server\" \"npm run dev -w @gsd/web\"",
15+
"prebuild": "node scripts/embed-tfstate.mjs",
1416
"build": "npm run build -w @gsd/shared && npm run build -w @gsd/server && npm run build -w @gsd/web",
1517
"build:lambdas": "npm run build -w @gsd/shared && npm run build -w @gsd/lambda-interactions -w @gsd/lambda-followup -w @gsd/lambda-update-dns -w @gsd/lambda-watchdog",
1618
"start": "node packages/server/dist/main.js",

app/packages/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"main": "./dist/main.js",
77
"scripts": {
8-
"dev": "tsx watch src/main.ts",
8+
"dev": "tsc -b && concurrently -k -n tsc,node \"tsc -b --watch --preserveWatchOutput\" \"node --enable-source-maps --watch dist/main.js\"",
99
"build": "tsc -b",
1010
"start": "node dist/main.js"
1111
},
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Auto-generated by scripts/embed-tfstate.mjs — do not edit by hand
2+
export const EMBEDDED_TFSTATE: Record<string, unknown> | null = null;

app/packages/server/src/services/ConfigService.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ vi.mock('../logger.js', () => ({
1616
},
1717
}));
1818

19+
/**
20+
* Mutable holder for the build-time embedded Terraform state.
21+
* Tests that exercise the EMBEDDED_TFSTATE fallback path set this before
22+
* calling `getTfOutputs()`; all other tests leave it as `null` so they
23+
* don't accidentally exercise the fallback.
24+
*/
25+
let mockEmbeddedState: Record<string, unknown> | null = null;
26+
27+
vi.mock('../generated/tfstate.js', () => ({
28+
get EMBEDDED_TFSTATE() {
29+
return mockEmbeddedState;
30+
},
31+
}));
32+
1933
import { readFileSync, writeFileSync, existsSync } from 'fs';
2034
import { ConfigService } from './ConfigService.js';
2135

@@ -38,14 +52,37 @@ describe('ConfigService', () => {
3852

3953
beforeEach(() => {
4054
service = new ConfigService();
55+
mockEmbeddedState = null;
4156
});
4257

4358
describe('getTfOutputs', () => {
44-
it('should return null and warn when the state file does not exist', () => {
59+
it('should return null when both the state file and embedded state are absent', () => {
4560
mockExists.mockReturnValue(false);
4661
expect(service.getTfOutputs()).toBeNull();
4762
});
4863

64+
it('should use EMBEDDED_TFSTATE as fallback when the state file is absent', () => {
65+
mockEmbeddedState = {
66+
outputs: {
67+
aws_region: { value: 'us-west-1' },
68+
game_names: { value: ['minecraft'] },
69+
},
70+
};
71+
mockExists.mockReturnValue(false);
72+
const outputs = service.getTfOutputs();
73+
expect(outputs).not.toBeNull();
74+
expect(outputs!.aws_region).toBe('us-west-1');
75+
expect(outputs!.game_names).toEqual(['minecraft']);
76+
expect(outputs!.subnet_ids).toBe('');
77+
});
78+
79+
it('should prefer the runtime state file over EMBEDDED_TFSTATE when both are present', () => {
80+
mockEmbeddedState = { outputs: { aws_region: { value: 'embedded-region' } } };
81+
mockExists.mockReturnValue(true);
82+
mockRead.mockReturnValue(makeState({ aws_region: { value: 'runtime-region' } }));
83+
expect(service.getTfOutputs()!.aws_region).toBe('runtime-region');
84+
});
85+
4986
it('should parse outputs and fill defaults for missing keys', () => {
5087
mockExists.mockReturnValue(true);
5188
mockRead.mockReturnValue(

app/packages/server/src/services/ConfigService.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,37 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
33
import { join, dirname } from 'path';
44
import { fileURLToPath } from 'url';
55
import { logger } from '../logger.js';
6+
import { EMBEDDED_TFSTATE } from '../generated/tfstate.js';
67

7-
// Workspace move: ConfigService now lives one extra directory level deep
8-
// (app/packages/server/src/services/ instead of app/src/server/services/),
9-
// so the relative walk to the repo's `terraform/` folder needs one more `..`.
108
const __dirname = dirname(fileURLToPath(import.meta.url));
11-
const TF_STATE_PATH = join(__dirname, '../../../../../../terraform/terraform.tfstate');
12-
const CONFIG_PATH = join(__dirname, '../../../../../../app/server_config.json');
9+
10+
/**
11+
* Probes candidate relative paths from __dirname in order and returns the
12+
* first that exists on disk. Falls back to the first candidate when none
13+
* exist so callers get a deterministic (missing-file) path rather than
14+
* undefined behaviour.
15+
*
16+
* Needed because the depth from __dirname to the repo/workspace root differs
17+
* between environments:
18+
* - local source tree (src/services or dist/services inside repo): 5 levels up → repo root
19+
* - Docker image (dist/services inside /app): 4 levels up → /app
20+
*/
21+
function resolveRuntimePath(...relativeCandidates: string[]): string {
22+
for (const rel of relativeCandidates) {
23+
const resolved = join(__dirname, rel);
24+
if (existsSync(resolved)) return resolved;
25+
}
26+
return join(__dirname, relativeCandidates[0]);
27+
}
28+
29+
const TF_STATE_PATH = resolveRuntimePath(
30+
'../../../../../terraform/terraform.tfstate',
31+
'../../../../terraform/terraform.tfstate',
32+
);
33+
const CONFIG_PATH = resolveRuntimePath(
34+
'../../../../../app/server_config.json',
35+
'../../../../server_config.json',
36+
);
1337

1438
/**
1539
* Shape of the subset of Terraform root outputs the management app consumes.
@@ -79,22 +103,33 @@ export class ConfigService {
79103

80104
/**
81105
* Parse `terraform/terraform.tfstate` (once, then memoised) and project the
82-
* pieces the app cares about. Returns `null` when the state file is absent
83-
* (pre-`terraform apply`) or unparseable — callers treat that as "infra
84-
* not deployed yet" and degrade gracefully.
106+
* pieces the app cares about. Falls back to the state embedded at build time
107+
* by `scripts/embed-tfstate.mjs` when the runtime file is absent. Returns
108+
* `null` when neither source is available — callers treat that as "infra not
109+
* deployed yet" and degrade gracefully.
85110
*/
86111
getTfOutputs(): TfOutputs | null {
87112
if (this.tfCache) return this.tfCache;
88113

89-
if (!existsSync(TF_STATE_PATH)) {
114+
type RawState = { outputs?: Record<string, { value: unknown }> };
115+
let raw: RawState;
116+
117+
if (existsSync(TF_STATE_PATH)) {
118+
try {
119+
raw = JSON.parse(readFileSync(TF_STATE_PATH, 'utf-8')) as RawState;
120+
} catch (err) {
121+
logger.error('Failed to parse Terraform state', { err, path: TF_STATE_PATH });
122+
return null;
123+
}
124+
} else if (EMBEDDED_TFSTATE) {
125+
logger.debug('Using build-time embedded Terraform state');
126+
raw = EMBEDDED_TFSTATE as unknown as RawState;
127+
} else {
90128
logger.warn('Terraform state not found', { path: TF_STATE_PATH });
91129
return null;
92130
}
93131

94132
try {
95-
const raw = JSON.parse(readFileSync(TF_STATE_PATH, 'utf-8')) as {
96-
outputs?: Record<string, { value: unknown }>;
97-
};
98133
const out = raw.outputs ?? {};
99134
const get = <T>(key: string, fallback: T): T =>
100135
key in out ? (out[key]!.value as T) : fallback;
@@ -121,7 +156,7 @@ export class ConfigService {
121156
logger.debug('Loaded Terraform outputs', { games: this.tfCache.game_names });
122157
return this.tfCache;
123158
} catch (err) {
124-
logger.error('Failed to parse Terraform state', { err, path: TF_STATE_PATH });
159+
logger.error('Failed to parse Terraform state', { err });
125160
return null;
126161
}
127162
}

app/packages/web/src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
133133
unauthorizedHandler?.();
134134
return new Promise<T>(() => undefined);
135135
}
136+
if (!res.ok) throw new Error(`API error ${res.status}`);
136137
return res.json() as Promise<T>;
137138
}
138139

app/packages/web/src/components/CostPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ export function CostPanel({ estimates }: Props) {
1717
void api.costsActual().then(setActual);
1818
}, []);
1919

20-
const maxCost = Math.max(...(actual?.daily.map((d) => d.cost) ?? [0]), 0.001);
20+
const maxCost = Math.max(...(actual?.daily?.map((d) => d.cost) ?? [0]), 0.001);
2121

2222
return (
2323
<div style={panelStyle}>
2424
<h2 style={headingStyle}>Actual Costs (7 days)</h2>
2525

26-
{actual?.daily.length ? (
26+
{actual?.daily?.length ? (
2727
<>
2828
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '3px', height: '72px', marginBottom: '0.5rem' }}>
2929
{actual.daily.map((d) => (

app/packages/web/src/components/DiscordPanel.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,18 @@ const ALL_ACTIONS: DiscordAction[] = ['start', 'stop', 'status'];
2424
*/
2525
export function DiscordPanel({ games }: { games: string[] }) {
2626
const [cfg, setCfg] = useState<DiscordConfigRedacted | null>(null);
27+
const [loadError, setLoadError] = useState(false);
2728
const [tab, setTab] = useState<'bot' | 'guilds' | 'admins' | 'perms'>('bot');
2829
const [busy, setBusy] = useState(false);
2930

3031
/** Re-fetch the (redacted) Discord config from the API after mutations. */
3132
async function refresh() {
32-
setCfg(await api.discordConfig());
33+
try {
34+
setCfg(await api.discordConfig());
35+
setLoadError(false);
36+
} catch {
37+
setLoadError(true);
38+
}
3339
}
3440
useEffect(() => {
3541
void refresh();
@@ -39,7 +45,9 @@ export function DiscordPanel({ games }: { games: string[] }) {
3945
return (
4046
<div style={panelStyle}>
4147
<h2 style={headingStyle}>Discord Bot</h2>
42-
<div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>Loading…</div>
48+
<div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>
49+
{loadError ? 'Discord config unavailable — infrastructure not deployed yet.' : 'Loading…'}
50+
</div>
4351
</div>
4452
);
4553
}

app/scripts/embed-tfstate.mjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env node
2+
// Reads Terraform state at build time and writes
3+
// app/packages/server/src/generated/tfstate.ts with the parsed state
4+
// embedded as a JSON literal. ConfigService imports it as a fallback when the
5+
// state file is not present at runtime (e.g. dev without a terraform apply).
6+
//
7+
// State resolution order:
8+
// 1. First CLI argument (path to a local tfstate file)
9+
// node scripts/embed-tfstate.mjs /path/to/terraform.tfstate
10+
// 2. TF_STATE_PATH env var (path to a local tfstate file)
11+
// TF_STATE_PATH=/path/to/terraform.tfstate npm run build
12+
// 3. Default: run `terraform state pull` from <repo-root>/terraform/
13+
// This works for any backend (local file or remote S3/GCS/etc.) as long
14+
// as `terraform` is on PATH and AWS credentials are available.
15+
16+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
17+
import { join, resolve, dirname } from 'path';
18+
import { fileURLToPath } from 'url';
19+
import { execSync } from 'child_process';
20+
21+
const __dirname = dirname(fileURLToPath(import.meta.url));
22+
23+
function getRepoRoot() {
24+
try {
25+
// --git-common-dir returns the main .git directory in both the main
26+
// working tree (as a relative path) and git worktrees (as an absolute
27+
// path). Resolving it against __dirname makes it absolute in both cases.
28+
const commonDir = execSync('git rev-parse --git-common-dir', {
29+
encoding: 'utf-8',
30+
cwd: __dirname,
31+
}).trim();
32+
return resolve(__dirname, commonDir, '..');
33+
} catch {
34+
return resolve(__dirname, '../..');
35+
}
36+
}
37+
38+
const outDir = join(__dirname, '../packages/server/src/generated');
39+
const outPath = join(outDir, 'tfstate.ts');
40+
41+
mkdirSync(outDir, { recursive: true });
42+
43+
function writeStub(json) {
44+
writeFileSync(
45+
outPath,
46+
`// Auto-generated by scripts/embed-tfstate.mjs — do not edit by hand\n` +
47+
`export const EMBEDDED_TFSTATE: Record<string, unknown> | null = ${json};\n`,
48+
);
49+
}
50+
51+
function embedFromFile(filePath) {
52+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
53+
writeStub(JSON.stringify(raw));
54+
console.log(`[embed-tfstate] Embedded state from ${filePath}`);
55+
}
56+
57+
// --- Resolution ---
58+
59+
if (process.argv[2] || process.env.TF_STATE_PATH) {
60+
const filePath = resolve(process.argv[2] ?? process.env.TF_STATE_PATH);
61+
try {
62+
embedFromFile(filePath);
63+
} catch (err) {
64+
console.error(`[embed-tfstate] Failed to read ${filePath}:`, err.message);
65+
writeStub('null');
66+
process.exit(1);
67+
}
68+
process.exit(0);
69+
}
70+
71+
// Default: pull live state from the configured backend via `terraform state pull`
72+
const tfDir = join(getRepoRoot(), 'terraform');
73+
console.log(`[embed-tfstate] Running 'terraform state pull' in ${tfDir} ...`);
74+
try {
75+
const raw = execSync('terraform state pull', {
76+
encoding: 'utf-8',
77+
cwd: tfDir,
78+
stdio: ['inherit', 'pipe', 'inherit'],
79+
});
80+
const parsed = JSON.parse(raw);
81+
writeStub(JSON.stringify(parsed));
82+
console.log('[embed-tfstate] Embedded live Terraform state');
83+
} catch (err) {
84+
console.warn(`[embed-tfstate] 'terraform state pull' failed — embedding null. (${err.message})`);
85+
writeStub('null');
86+
// Non-fatal: the server will degrade gracefully without state
87+
}

0 commit comments

Comments
 (0)