Files
TehRiehlDeal 4359955c51
CI / test (push) Has been skipped
CI / secrets-scan (push) Successful in 5s
CI / sast (push) Successful in 13s
CI / vuln-scan (push) Successful in 14s
CI / lint (push) Successful in 26s
CI / build-images (push) Has been skipped
CI / image-scan (push) Has been skipped
CI / push (push) Has been skipped
CI / secrets-scan (pull_request) Successful in 6s
CI / sast (pull_request) Successful in 14s
CI / vuln-scan (pull_request) Successful in 15s
CI / test (pull_request) Successful in 25s
CI / lint (pull_request) Successful in 27s
CI / build-images (pull_request) Successful in 5m7s
CI / image-scan (pull_request) Successful in 24s
CI / push (pull_request) Has been skipped
feat: OG/Twitter Card metadata for chat link previews; v0.1.18
index.html grows the Open Graph and Twitter Card meta tags (title,
description, type, site_name, url, image + dimensions, alt) plus a
plain `<meta name="description">` for SEO. Same copy as the README
one-liner so every surface stays in sync.

Absolute URLs are required for Facebook+Twitter and strongly preferred
elsewhere, but the deployment URL isn't known at build time. Solved with
the same pattern config.js already uses: a `__OG_BASE_URL__` token in
index.html gets sed-substituted by the nginx entrypoint from a new
`APP_PUBLIC_URL` env var. Trailing-slash trim and sed-meta escaping are
both handled. Unset env = relative URLs (Slack/Discord/iMessage still
render fine, Facebook won't).

README gains a paragraph under Runtime Config documenting the new var.
2026-05-18 15:49:38 -07:00

9.1 KiB
Raw Permalink Blame History

TehRiehlIncremental

A meta/dev-themed incremental game. You play a developer; resources are Lines of Code, Commits, Coffee, Closed Tickets, Releases, and Tech Debt. Built on a TMT-compatible (The Modding Tree) JSON content schema with a React + Redux frontend and a NestJS + Postgres backend.

Status: v0.1 — playable. Six day-1 layers, full auth + cloud saves with envelope encryption, Tailwind-themed IDE UI. See ProjectPlan.md for the original design plan.


Architecture

TehRiehlIncremental/
├── docker-compose.yml       Postgres for local dev
├── pnpm-workspace.yaml
├── packages/
│   ├── shared/              TS types, TMT-compatible schema, formula registry
│   ├── content/             JSON layer definitions for the day-1 content drop
│   ├── client/              Vite + React + Redux + Tailwind + ShadCN
│   └── server/              NestJS + Prisma + envelope encryption service
└── .github/workflows/       CI: lint, typecheck, test (90% coverage), build

The shared package is the contract between client and server: both import the same TS types and the same formula registry, so save validation and game logic stay aligned by construction.

Prerequisites

  • Node.js 20+
  • pnpm 9+ (corepack enable && corepack prepare pnpm@9.12.0 --activate)
  • Docker (for local Postgres) — or a locally-running Postgres if you prefer

Setup

# 1. Install dependencies
pnpm install

# 2. Generate local-dev secrets into .env. The example file ships with
#    REPLACE_ME placeholders; the snippet below picks fresh keys.
cp .env.example .env
node -e "
  const c=require('node:crypto'),fs=require('node:fs'),p='.env';
  let e=fs.readFileSync(p,'utf8')
    .replace(/^ENCRYPTION_MASTER_KEY=.*/m,'ENCRYPTION_MASTER_KEY=\"'+c.randomBytes(32).toString('base64')+'\"')
    .replace(/^EMAIL_HASH_SECRET=.*/m,'EMAIL_HASH_SECRET=\"'+c.randomBytes(32).toString('base64')+'\"')
    .replace(/^JWT_SECRET=.*/m,'JWT_SECRET=\"'+c.randomBytes(64).toString('base64')+'\"')
    .replace(/^JWT_REFRESH_SECRET=.*/m,'JWT_REFRESH_SECRET=\"'+c.randomBytes(64).toString('base64')+'\"');
  fs.writeFileSync(p,e);
"

# 3. Start Postgres
docker compose up -d

# 4. Generate Prisma client + apply migrations
pnpm --filter @teh-riehl/server prisma generate
pnpm --filter @teh-riehl/server prisma migrate dev

# 5. Start everything (server on :3000, client on :5173)
pnpm dev

Open http://localhost:5173. Sign up, then play. Saves sync to Postgres every 30s; localStorage holds the truth in between.

Useful scripts

Command What it does
pnpm dev Run client + server + watchers in parallel
pnpm build Build all packages
pnpm test Run all unit suites
pnpm test:coverage Unit tests + coverage gate (≥90% on shared formulas)
pnpm --filter @teh-riehl/server test:integration E2E against real Postgres — requires docker compose up -d
pnpm typecheck tsc --noEmit across packages
pnpm lint ESLint across packages
pnpm format / pnpm format:check Prettier write / verify
pnpm --filter @teh-riehl/server prisma migrate dev Apply / regenerate the DB schema

CI (.github/workflows/ci.yml) runs test, lint, secrets-scan, vuln-scan, and sast in parallel. On the main branch, it then builds + scans both Docker images (tehriehlincremental-server, tehriehlincremental-client), pushes signed (cosign) images to Harbor under harbor.tehriehldeal.com/tehriehlincremental/{server,client}, and back-pushes per-package git tags (server-vX.Y.Z, client-vX.Y.Z). Bump the version in packages/{server,client}/package.json before merging; the push job refuses to overwrite an existing Harbor tag.

Container images

# Build (from repo root — workspace deps need the full context)
docker build -f packages/server/Dockerfile -t teh-riehl-server .
docker build -f packages/client/Dockerfile -t teh-riehl-client .

Runtime config

The client is a static Vite build. Rather than baking the backend URL into the bundle at build time, the nginx entrypoint writes /config.js from env vars on container start, and index.html loads it before the React bundle. So one immutable image targets any backend:

docker run -p 8080:80 \
  -e APP_API_BASE_URL="https://api.example.com/api" \
  -e APP_PUBLIC_URL="https://tehriehl.example.com" \
  teh-riehl-client

APP_PUBLIC_URL is the site's absolute origin (no trailing slash). The entrypoint substitutes it into the Open Graph / Twitter Card meta tags in index.html so chat clients (Slack, Discord, iMessage, Twitter, etc.) can render link previews against fully-qualified URLs. Leave it unset for local Docker spin-ups; previews will fall back to relative paths, which most modern scrapers handle but Facebook does not.

Add a new runtime knob by extending the AppConfig interface in packages/client/src/lib/runtimeConfig.ts, adding the env var to packages/client/docker/40-app-config.sh, and documenting it here.

Security model

TL;DR: passwords are hashed (Argon2id), PII and save blobs are field-level encrypted (AES-256-GCM via envelope encryption), and disk-level encryption is expected at the infra layer in production.

  • Passwords: hashed with Argon2id. Never stored or logged.
  • Emails: stored as ciphertext (AES-256-GCM) + a separate HMAC-SHA256 blind index for login lookup. The DB never sees the plaintext.
  • Save blobs: full game-state JSON, encrypted in the server before insert.
  • Envelope encryption: every encrypted record has a per-record DEK (Data Encryption Key), itself encrypted with the master KEK (Key Encryption Key) from ENCRYPTION_MASTER_KEY. Rotating the master key only re-encrypts the DEKs, not all data.
  • Encryption at rest (disk-level): the local Docker Postgres volume is not encrypted at rest — this is acceptable for development because the app-layer encryption is the primary defense. Production deployments must use an encrypted block device (AWS EBS+KMS, GCP PD+CMEK, or LUKS).

Game content

Layer definitions live in packages/content/src/layers/*.json and conform to the TS schema in packages/shared/src/schema/. Adding a new layer = drop a new JSON file in, re-export it from layers.ts, and (if you reference a new formula) register it in packages/shared/src/formulas/builtins.ts. No frontend changes required for content-only additions — the React app reads the layer list at build time.

Day-1 layers (in unlock order):

Layer Resource Role Color
Code Lines of Code Base producer cyan
Commits Commits Prestige (resets Code) magenta
Coffee Cups Multiplier buyables amber
Tickets Closed Tickets Challenge layer (debuffs+goals) green
Releases Release Tags Hard prestige + expansion hook violet
Tech Debt Debt Antagonist mechanic red

Formulas are referenced by ID (e.g. { "fn": "polynomial", "args": [10, 1.15] }) instead of being evaluated from strings, so content stays data-only and there is no eval / new Function attack surface. The full registry (10 formulas, 9 predicates) is in packages/shared/src/formulas/builtins.ts and predicates/builtins.ts.

Visual system

The design commits to one concept: the game lives inside an IDE. Type pairing is Geist (UI) + JetBrains Mono (numbers and system text). Six layer accents drawn from Tailwind's -400 family so they read as one palette. Diff gutters on cards (+ affordable, ~ owned, locked). Hairline 1px borders, never CSS shadow blur. One signature element: the >_ blinking prompt, recurring at hero moments.

Tokens live in packages/client/tailwind.config.ts; the per-layer --accent CSS variable is set on data-layer="..." parents, so every component themes itself from one CSS var rather than needing layer-specific class variants.

License

GNU Affero General Public License v3.0 or later (SPDX: AGPL-3.0-or-later).

In plain terms: you may run, study, modify, and redistribute this code — but any fork, including a network-deployed one, must also be released under AGPL-3.0 and made available to its users. If you want to integrate this work into a project that can't comply with those terms, contact the author.