- TypeScript 94.1%
- SCSS 4.7%
- Dockerfile 0.8%
- JavaScript 0.4%
|
Some checks failed
Build Docker image / Build Docker container (push) Failing after 27s
|
||
|---|---|---|
| .forgejo/workflows | ||
| app | ||
| components | ||
| lib | ||
| types | ||
| .dockerignore | ||
| .env.local.example | ||
| .gitignore | ||
| bun.lock | ||
| Dockerfile | ||
| next-env.d.ts | ||
| next.config.mjs | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.js | ||
| vitest.setup.ts | ||
Steam Login Demo (Next.js)
Signs a user in with Steam (OpenID 2.0), then lets them browse their owned games and per-game achievements via the Steam Web API.
Why a backend is required
Steam does not support OAuth or any purely client-side login — it uses
OpenID 2.0, and the verification step (confirming the callback really came
from Steam) has to happen server-to-server. The Steam Web API also has no
CORS headers, so it can't be called directly from the browser either. Next.js
Route Handlers (app/api/.../route.js) act as that backend here.
Setup
- Get a Steam Web API key: https://steamcommunity.com/dev/apikey
(any domain works for local dev, e.g.
localhost) cp .env.local.example .env.localand fill inSTEAM_API_KEYnpm installnpm run dev→ open http://localhost:3000
How it works
GET /api/auth/steam— redirects the browser to Steam's login page (lib/steamAuth.js: buildSteamLoginUrl)- Steam redirects back to
GET /api/auth/steam/callbackwithopenid.*query params - The callback route POSTs those params back to Steam with
openid.mode=check_authenticationto verify they're genuine (lib/steamAuth.js: verifySteamAssertion), extracts the steamid64, and sets it in a cookie GET /api/games?steamid=...— proxiesIPlayerService/GetOwnedGamesGET /api/achievements?steamid=...&appid=...— proxiesISteamUserStats/GetPlayerAchievementsapp/page.jsis a client component that reads the cookie, lists games, and fetches achievements when a game is clicked
Schema caching (GetSchemaForGame)
Achievement display names, descriptions, and icons come from a separate
Steam endpoint, ISteamUserStats/GetSchemaForGame, which barely changes for
a given game -- so it's a perfect candidate for caching instead of calling
Steam on every request.
lib/db.js— a tiny DB abstraction. IfDATABASE_URLis set, it uses Postgres (viapg) against that existing database. If it's not set, it automatically creates and uses a local SQLite file at./data/dev.dbusing Node's own built-innode:sqlitemodule — no native compilation, no extra dependency, nothing to install for local dev (requires Node 22.13+ / 23.4+, where the flag requirement was dropped).lib/schemaCache.js—getSchemaForGame(appid): checks the DB first; on a miss it calls Steam'sGetSchemaForGame, stores the result, and returns it. On a hit it returns straight from the DB and never touches the network.app/api/achievements/route.js— uses the cached schema to attachdisplayName/description/iconto each of the player's achievements, and returnsschemaSource: 'cache' | 'steam'so you can see which one happened.app/api/schema/route.js— a standalone endpoint for testing this directly: callGET /api/schema?appid=440twice — the first response says"source": "steam", the second says"source": "cache".
The game_schemas table is keyed by appid, so it's shared across all
users — once one person loads a game's achievements, everyone benefits from
the cache for that game.
To switch to Postgres locally, set DATABASE_URL in .env.local; the table
is created automatically on first use (CREATE TABLE IF NOT EXISTS), so no
separate migration step is needed either way.
Deployment
This app is deployed to the daglesia.com cluster via
domain-infra
(charts/achievement-huntress, apps/generic/achievement-huntress.yaml),
using the same ArgoCD + Helm + SOPS pattern as the rest of that cluster's
apps.
- Container image:
Dockerfilebuilds a multi-stage, standalone Next.js image (output: 'standalone'innext.config.mjs), pushed togit.daglesia.com/magdalena/achievement-huntressby.forgejo/workflows/build-and-push.ymlon every push tomain, tagged with the commit SHA. The Helm chart'sweb.image.tagis bumped to match. - Database: production does not use the SQLite fallback described
above. The chart injects
DATABASE_URLfrom a Kubernetes Secret pointing at the cluster's single shared PostgreSQL instance (domain-infra/charts/cnpg/postgresql, see ADR 26-06-15), with its own dedicatedachievement_huntressdatabase/role — the same approach already used there forforgejo,penpot, andwikijs. No code changes were needed for this:lib/db.jsalready switches to Postgres automatically wheneverDATABASE_URLis set. - Secrets:
STEAM_API_KEYandDATABASE_URLare stored incharts/achievement-huntress/secrets.yamlin domain-infra, encrypted with SOPS/age, and decrypted in-cluster by ArgoCD.
Notes / gotchas
- Profile privacy: a user's games/achievements only come back if their
Steam profile's "Game details" setting is Public. Otherwise you'll get an
empty list or a
success: falseresponse — the UI shows a friendly message for this instead of crashing. - Achievement display names:
GetPlayerAchievementsonly returnsapiname+achieved/unlocktime, not a human-readable title. To show real names/icons, also callISteamUserStats/GetSchemaForGameper app and merge onapiname— left out here to keep the demo small. - Cookie security: the
steamidcookie is intentionally nothttpOnlyso the demo page can read it client-side. For a real app, make ithttpOnlyand add an authenticated/api/meroute instead, so JavaScript in the page never sees/needs the raw id. - Realm/return_to:
BASE_URLmust exactly match the domain you deploy to (Steam checks this). Update it for production and use HTTPS. - Rate limits: the Steam Web API is rate-limited; if you loop over many games fetching achievements, add delays/batching.