Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/agents/discord-iam-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ tools: Bash, Read, Grep, Glob

You review changes to the Discord serverless path for security regressions. The architecture (from CLAUDE.md):

- `@gsd/lambda-interactions` is exposed via a public Function URL. It MUST verify the Ed25519 signature against the public key in Secrets Manager before doing anything else.
- `@hyveon/lambda-interactions` is exposed via a public Function URL. It MUST verify the Ed25519 signature against the public key in Secrets Manager before doing anything else.
- The interactions Lambda enforces `allowedGuilds` from `pk="CONFIG#discord"` in DynamoDB. This is the only allowlist gate.
- `canRun()` in `@gsd/shared/canRun` is the single permission resolver. Order: guild allowlist → admin user/role → per-game user/role + action gate.
- Slash commands are JSON descriptors in `@gsd/shared/commands.ts`. Adding one requires a new entry in `actionForCommand()` so `canRun()` gets the right bucket.
- `canRun()` in `@hyveon/shared/canRun` is the single permission resolver. Order: guild allowlist → admin user/role → per-game user/role + action gate.
- Slash commands are JSON descriptors in `@hyveon/shared/commands.ts`. Adding one requires a new entry in `actionForCommand()` so `canRun()` gets the right bucket.
- Per-guild command registration only — never global commands.
- Neither the bot token nor the public key is ever returned to the client; `getRedacted()` exposes booleans.
- The full deploy IAM policy `GameServerDeployAll` lives only in `docs/setup.md`.
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Thumbs.db

# Auto-generated at dev/build time — committed as null, real state must not
# be committed (contains AWS ARNs/account IDs).
app/packages/server/src/generated/tfstate.ts
app/packages/desktop-main/src/generated/tfstate.ts

# Playwright artifacts (traces, videos, screenshots) and HTML report.
app/packages/web/test-results/
Expand Down
40 changes: 20 additions & 20 deletions CLAUDE.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Component deep-dives:

- [**Architecture**](https://codercoco.github.io/Hyveon/architecture/) — full diagram + `/server-start` sequence.
- [**Terraform**](https://codercoco.github.io/Hyveon/components/terraform/) — every `.tf` file, variables, outputs, gotchas.
- [**Management app**](https://codercoco.github.io/Hyveon/components/management-app/) — Nest.js API, React dashboard, `@gsd/shared`.
- [**Management app**](https://codercoco.github.io/Hyveon/components/management-app/) — Nest.js API, React dashboard, `@hyveon/shared`.
- [**Lambdas**](https://codercoco.github.io/Hyveon/components/lambdas/) — interactions, followup, update-dns, watchdog.

## Quick start
Expand Down Expand Up @@ -117,9 +117,9 @@ pennies/month. Playing 4 hours/day, 5 days/week ≈ **$10–12/month**, vs.
Hyveon/
├── app/ # Nest.js + React monorepo (npm workspaces)
│ └── packages/
│ ├── shared/ # @gsd/shared
│ ├── server/ # @gsd/server (Nest.js API)
│ ├── web/ # @gsd/web (React + Vite)
│ ├── shared/ # @hyveon/shared
│ ├── desktop-main/ # @hyveon/desktop-main (Nest.js API)
│ ├── web/ # @hyveon/web (React + Vite)
│ └── lambda/
│ ├── interactions/ # Discord Function URL entry point
│ ├── followup/ # Async ECS work + Discord PATCH
Expand Down
8 changes: 4 additions & 4 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"type": "module",
"scripts": {
"predev": "node scripts/embed-tfstate.mjs",
"dev": "concurrently -n server,client -c cyan,magenta \"npm run dev -w @gsd/server\" \"npm run dev -w @gsd/web\"",
"dev": "concurrently -n server,client -c cyan,magenta \"npm run dev -w @hyveon/desktop-main\" \"npm run dev -w @hyveon/web\"",
"prebuild": "node scripts/embed-tfstate.mjs",
"build": "npm run build -w @gsd/shared && npm run build -w @gsd/server && npm run build -w @gsd/web",
"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 -w @gsd/lambda-efs-seeder",
"start": "node packages/server/dist/main.js",
"build": "npm run build -w @hyveon/shared && npm run build -w @hyveon/desktop-main && npm run build -w @hyveon/web",
"build:lambdas": "npm run build -w @hyveon/shared && npm run build -w @hyveon/lambda-interactions -w @hyveon/lambda-followup -w @hyveon/lambda-update-dns -w @hyveon/lambda-watchdog -w @hyveon/lambda-efs-seeder",
"start": "node packages/desktop-main/dist/main.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/server",
"name": "@hyveon/desktop-main",
"version": "1.0.0",
"private": true,
"type": "module",
Expand All @@ -17,7 +17,7 @@
"@aws-sdk/client-ecs": "^3.600.0",
"@aws-sdk/client-secrets-manager": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0",
"@gsd/shared": "*",
"@hyveon/shared": "*",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { Injectable } from '@nestjs/common';
import { logger } from '../logger.js';
import { DiscordConfigService } from './DiscordConfigService.js';
import { COMMAND_DESCRIPTORS } from '@gsd/shared';
import { COMMAND_DESCRIPTORS } from '@hyveon/shared';

const DISCORD_API = 'https://discord.com/api/v10';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Tests for the DynamoDB + Secrets Manager-backed DiscordConfigService.
*
* The service is a thin wrapper around `@gsd/shared/ddb/configStore` and
* `@gsd/shared/secrets/secretsStore` — the stores themselves have their own
* The service is a thin wrapper around `@hyveon/shared/ddb/configStore` and
* `@hyveon/shared/secrets/secretsStore` — the stores themselves have their own
* tests under the shared package. Here we validate the wiring: that the
* right stores get called with the right args, that the redacted view
* strips both secrets, and that the controller-facing contract (same method
Expand All @@ -21,8 +21,8 @@ const putBotTokenMock = vi.fn();
const putPublicKeyMock = vi.fn();
const invalidateSecretsCacheMock = vi.fn();

vi.mock('@gsd/shared', async () => {
const actual = await vi.importActual<typeof import('@gsd/shared')>('@gsd/shared');
vi.mock('@hyveon/shared', async () => {
const actual = await vi.importActual<typeof import('@hyveon/shared')>('@hyveon/shared');
return {
...actual,
getDiscordConfig: (...args: unknown[]) => getDiscordConfigMock(...args),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* `/api/discord/config`.
*
* The InteractionsLambda has its own copy of the read paths (via
* `@gsd/shared`), so this service only exists to back the management UI's
* `@hyveon/shared`), so this service only exists to back the management UI's
* configuration tab.
*/
import { Injectable } from '@nestjs/common';
Expand All @@ -30,10 +30,10 @@ import {
type DiscordAction,
type DiscordConfig,
type RedactedDiscordConfig,
} from '@gsd/shared';
} from '@hyveon/shared';

/** Slash-command action that can be gated via permissions. */
export type { DiscordAction } from '@gsd/shared';
export type { DiscordAction } from '@hyveon/shared';

function emptyConfig(): DiscordConfig {
return {
Expand All @@ -47,7 +47,7 @@ function emptyConfig(): DiscordConfig {
/**
* Management-side interface to the Discord DynamoDB row and the two Secrets
* Manager secrets. The interactions/followup Lambdas have their own read
* paths via `@gsd/shared`; this service backs the web UI's Credentials /
* paths via `@hyveon/shared`; this service backs the web UI's Credentials /
* Permissions tabs.
*
* Security invariant: the raw `botToken` and `publicKey` values are **never**
Expand Down
2 changes: 1 addition & 1 deletion app/packages/lambda/efs-seeder/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/lambda-efs-seeder",
"name": "@hyveon/lambda-efs-seeder",
"version": "1.0.0",
"private": true,
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/followup/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/lambda-followup",
"name": "@hyveon/lambda-followup",
"version": "1.0.0",
"private": true,
"type": "module",
Expand All @@ -9,7 +9,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@gsd/shared": "*"
"@hyveon/shared": "*"
},
"devDependencies": {
"@aws-sdk/client-ec2": "^3.600.0",
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/followup/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {

const getEffectiveDiscordConfigMock = vi.fn();
const putPendingMock = vi.fn();
vi.mock('@gsd/shared', async () => {
const actual = await vi.importActual<typeof import('@gsd/shared')>('@gsd/shared');
vi.mock('@hyveon/shared', async () => {
const actual = await vi.importActual<typeof import('@hyveon/shared')>('@hyveon/shared');
return {
...actual,
getEffectiveDiscordConfig: (...args: unknown[]) => getEffectiveDiscordConfigMock(...args),
Expand Down
6 changes: 3 additions & 3 deletions app/packages/lambda/followup/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
EC2Client,
DescribeNetworkInterfacesCommand,
} from '@aws-sdk/client-ec2';
import { canRun, formatGameStatus, getEffectiveDiscordConfig, putPending } from '@gsd/shared';
import type { DiscordAction, DiscordConfig, GameStatus } from '@gsd/shared';
import { canRun, formatGameStatus, getEffectiveDiscordConfig, putPending } from '@hyveon/shared';
import type { DiscordAction, DiscordConfig, GameStatus } from '@hyveon/shared';

interface FollowupEvent {
kind: 'start' | 'stop' | 'status' | 'list';
Expand Down Expand Up @@ -265,7 +265,7 @@ function recheck(event: FollowupEvent, cfg: DiscordConfig, action: DiscordAction
* (Discord's 3-second budget doesn't leave room for ECS calls). Does the slow work —
* `RunTask` / `StopTask` / `DescribeTasks` — then PATCHes the original interaction
* message via the webhook endpoint. For `start`, also writes a `PENDING#{taskArn}`
* row to DynamoDB so `@gsd/lambda-update-dns` can PATCH the same interaction once
* row to DynamoDB so `@hyveon/lambda-update-dns` can PATCH the same interaction once
* the task reaches RUNNING and an IP/hostname is resolved.
*/
export const handler = async (event: FollowupEvent): Promise<void> => {
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/interactions/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/lambda-interactions",
"name": "@hyveon/lambda-interactions",
"version": "1.0.0",
"private": true,
"type": "module",
Expand All @@ -9,7 +9,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@gsd/shared": "*",
"@hyveon/shared": "*",
"@noble/ed25519": "^2.1.0"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/interactions/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ vi.mock('@noble/ed25519', () => ({
// Mock the shared config + secrets stores so we never hit AWS.
const getPublicKeyMock = vi.fn();
const getEffectiveDiscordConfigMock = vi.fn();
vi.mock('@gsd/shared', async () => {
const actual = await vi.importActual<typeof import('@gsd/shared')>('@gsd/shared');
vi.mock('@hyveon/shared', async () => {
const actual = await vi.importActual<typeof import('@hyveon/shared')>('@hyveon/shared');
return {
...actual,
getPublicKey: (...args: unknown[]) => getPublicKeyMock(...args),
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/interactions/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import { verifyAsync } from '@noble/ed25519';
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { canRun, getEffectiveDiscordConfig, getPublicKey } from '@gsd/shared';
import type { DiscordAction, DiscordConfig } from '@gsd/shared';
import { canRun, getEffectiveDiscordConfig, getPublicKey } from '@hyveon/shared';
import type { DiscordAction, DiscordConfig } from '@hyveon/shared';

/** Discord interaction types we care about. Full list in discord-api-types. */
const INTERACTION_PING = 1;
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/update-dns/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/lambda-update-dns",
"name": "@hyveon/lambda-update-dns",
"version": "1.0.0",
"private": true,
"type": "module",
Expand All @@ -9,7 +9,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@gsd/shared": "*"
"@hyveon/shared": "*"
},
"devDependencies": {
"@aws-sdk/client-ec2": "^3.600.0",
Expand Down
4 changes: 2 additions & 2 deletions app/packages/lambda/update-dns/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import {

const getPendingMock = vi.fn();
const deletePendingMock = vi.fn();
vi.mock('@gsd/shared', async () => {
const actual = await vi.importActual<typeof import('@gsd/shared')>('@gsd/shared');
vi.mock('@hyveon/shared', async () => {
const actual = await vi.importActual<typeof import('@hyveon/shared')>('@hyveon/shared');
return {
...actual,
getPending: (...args: unknown[]) => getPendingMock(...args),
Expand Down
2 changes: 1 addition & 1 deletion app/packages/lambda/update-dns/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
ChangeResourceRecordSetsCommand,
ListResourceRecordSetsCommand,
} from '@aws-sdk/client-route-53';
import { deletePending, formatGameStatus, getPending } from '@gsd/shared';
import { deletePending, formatGameStatus, getPending } from '@hyveon/shared';

const HOSTED_ZONE_ID = requireEnv('HOSTED_ZONE_ID');
const DOMAIN_NAME = requireEnv('DOMAIN_NAME');
Expand Down
2 changes: 1 addition & 1 deletion app/packages/lambda/watchdog/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/lambda-watchdog",
"name": "@hyveon/lambda-watchdog",
"version": "1.0.0",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion app/packages/lambda/watchdog/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ async function listAllRunningTaskArns(): Promise<string[]> {
* `NetworkPacketsIn` on its ENI via CloudWatch and bumps a consecutive-idle counter
* stored as an ECS task tag (no DynamoDB/SSM state). After `watchdog_idle_checks`
* consecutive idle intervals, the task is stopped — which triggers the DNS-delete
* path in `@gsd/lambda-update-dns`.
* path in `@hyveon/lambda-update-dns`.
*/
export const handler = async (): Promise<{ checked: number }> => {
console.log(`Watchdog running — cluster: ${ECS_CLUSTER}, games: ${GAME_NAMES.join(',')}`);
Expand Down
2 changes: 1 addition & 1 deletion app/packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/shared",
"name": "@hyveon/shared",
"version": "1.0.0",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion app/packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@gsd/web",
"name": "@hyveon/web",
"version": "1.0.0",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion app/packages/web/playwright.integration.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from 'node:path';

const fixtureDir = fileURLToPath(new URL('e2e/fixtures', import.meta.url));
const tfstatePath = join(fixtureDir, 'tfstate.fixture.json');
const serverDist = fileURLToPath(new URL('../../packages/server/dist', import.meta.url));
const serverDist = fileURLToPath(new URL('../../packages/desktop-main/dist', import.meta.url));

export default defineConfig({
testDir: './e2e/integration-specs',
Expand Down
4 changes: 2 additions & 2 deletions app/scripts/embed-tfstate.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Reads Terraform state at build time and writes
// app/packages/server/src/generated/tfstate.ts with the parsed state
// app/packages/desktop-main/src/generated/tfstate.ts with the parsed state
// embedded as a JSON literal. ConfigService imports it as a fallback when the
// state file is not present at runtime (e.g. dev without a terraform apply).
//
Expand Down Expand Up @@ -35,7 +35,7 @@ function getRepoRoot() {
}
}

const outDir = join(__dirname, '../packages/server/src/generated');
const outDir = join(__dirname, '../packages/desktop-main/src/generated');
const outPath = join(outDir, 'tfstate.ts');

mkdirSync(outDir, { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/server" },
{ "path": "./packages/desktop-main" },
{ "path": "./packages/web" },
{ "path": "./packages/lambda/interactions" },
{ "path": "./packages/lambda/followup" },
Expand Down
22 changes: 11 additions & 11 deletions app/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
// Point @gsd/shared imports at the TypeScript source so `vitest run`
// works without first running `npm run build -w @gsd/shared`. Runtime
// Point @hyveon/shared imports at the TypeScript source so `vitest run`
// works without first running `npm run build -w @hyveon/shared`. Runtime
// (Nest server + Lambda bundles) still use the built dist/ via the
// package.json "main" field — this alias only applies inside Vitest.
'@gsd/shared': resolve(__dirname, 'packages/shared/src/index.ts'),
// The @gsd/web package uses `@/foo` as a shortcut for `./src/foo`
'@hyveon/shared': resolve(__dirname, 'packages/shared/src/index.ts'),
// The @hyveon/web package uses `@/foo` as a shortcut for `./src/foo`
// (matches its tsconfig + Vite config). Re-declare it here so the
// same imports resolve under Vitest.
'@': resolve(__dirname, 'packages/web/src'),
Expand All @@ -21,7 +21,7 @@ export default defineConfig({
test: {
include: ['packages/**/*.test.{ts,tsx}'],
// Default environment for server-side and shared tests is Node.
// React component tests under @gsd/web override this via
// React component tests under @hyveon/web override this via
// `environmentMatchGlobs` so they get a real DOM.
environment: 'node',
environmentMatchGlobs: [['packages/web/**', 'jsdom']],
Expand All @@ -40,17 +40,17 @@ export default defineConfig({
'packages/**/*.test.{ts,tsx}',
'packages/**/*.d.ts',
'packages/**/dist/**',
'packages/server/src/generated/**',
'packages/desktop-main/src/generated/**',
'packages/web/src/generated/**',
// Bootstrap / entry-point files — only exercised by e2e/integration tests.
'packages/server/src/main.ts',
'packages/server/src/test-main.ts',
'packages/desktop-main/src/main.ts',
'packages/desktop-main/src/test-main.ts',
'packages/web/src/main.tsx',
// NestJS DI module files — wiring config, not business logic.
'packages/server/src/app.module.ts',
'packages/server/src/modules/**',
'packages/desktop-main/src/app.module.ts',
'packages/desktop-main/src/modules/**',
// Test-only infrastructure — not production code.
'packages/server/src/test-mocks/**',
'packages/desktop-main/src/test-mocks/**',
// Pure type declarations — no executable statements.
'packages/shared/src/types.ts',
],
Expand Down
2 changes: 1 addition & 1 deletion app/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// `toHaveTextContent`, etc.) and runs RTL's cleanup after each test —
// this isn't auto-wired because we run with `globals: false`, which
// disables RTL's normal auto-cleanup hook. Only React component tests
// in @gsd/web run under the `jsdom` environment, so this is a no-op for
// in @hyveon/web run under the `jsdom` environment, so this is a no-op for
// server-side specs.
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
Expand Down
Loading
Loading