Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
473 lines
26 KiB
Markdown
473 lines
26 KiB
Markdown
# Stan Website
|
||
|
||
This is my personal website, including:
|
||
|
||
- Contact info / social links
|
||
- About me
|
||
- Blog posts + comments
|
||
- Projects like Animaldex, CatchEmAll
|
||
|
||
## osu! Map Finder
|
||
|
||
Available at `/projects/spotify-beatmap-finder`. Built with the existing Svelte 5,
|
||
SvelteKit, Tailwind 4, and adapter-node setup. Uses server-only direct `fetch`
|
||
calls; no Spotify/osu! SDK or additional services.
|
||
|
||
Set these private variables in your local `.env` or deployment environment:
|
||
|
||
```text
|
||
SPOTIFY_CLIENT_ID
|
||
SPOTIFY_CLIENT_SECRET
|
||
OSU_CLIENT_ID
|
||
OSU_CLIENT_SECRET
|
||
```
|
||
|
||
Never prefix these with `PUBLIC_` or commit `.env`. Providers read
|
||
`$env/dynamic/private`; the browser receives only display metadata and results.
|
||
The other projects on this personal site retain their own environment requirements.
|
||
|
||
Supported inputs: song text (`Deftones Change`), Spotify track, playlist, and album
|
||
URLs, and `spotify:track:ID`, `spotify:playlist:ID`, `spotify:album:ID` URIs.
|
||
URLs allow HTTP/HTTPS, `www`, locale prefixes, query parameters and trailing slashes.
|
||
Artist, episode, show and other resources receive a validation error.
|
||
|
||
Playlists and albums preserve order and process up to **50 usable unique tracks**
|
||
(`MAX_PLAYLIST_TRACKS`), scanning at most **10 pages** (`MAX_PLAYLIST_PAGES`).
|
||
Null, unavailable, local, malformed and episode entries are skipped. Truncation is
|
||
shown explicitly. Album tracks inherit the album artwork/name. Each match has its
|
||
own error state; an osu! failure cannot erase other completed tracks. The enhanced
|
||
form shows a pending state and renders the complete batch; native form submission
|
||
also works without JavaScript. Filters require JavaScript.
|
||
|
||
Spotify playlist availability depends on application quota/access. Both legacy
|
||
`tracks/track` and newer `items/item` payloads are supported. Spotify's
|
||
[2026 development-mode changes](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide)
|
||
restrict playlist access; client credentials cannot unlock private/user-authorized
|
||
playlists. Such responses produce an access error. This project intentionally does
|
||
not add user authentication.
|
||
|
||
Matching is pure and testable in `src/lib/map-finder/matching.ts`: Unicode and
|
||
punctuation normalization, diacritic removal, common annotation cleanup, title and
|
||
artist token overlap, Unicode metadata alternatives, and version penalties. Title
|
||
contributes 65 points and artist 35. High confidence starts at 88, medium at 72;
|
||
weak artist evidence is capped below medium, and a different version costs 24.
|
||
These are heuristics, not probabilities. Candidates below 40 are omitted; up to six
|
||
are sorted and deduplicated by beatmapset ID. Low-confidence candidates are collapsed.
|
||
Search first uses artist + title, then one cleaned-title fallback if no medium/high
|
||
match exists. Duration is omitted because osu! map length may reflect a song cut.
|
||
|
||
Tokens are reused until 30 seconds before expiry with a shared refresh promise.
|
||
Matching results cache for five minutes (maximum 500 entries), including no-match
|
||
results, and identical pending searches are coalesced. Three concurrent osu!
|
||
matching jobs are allowed per process. The site allows one active search per IP,
|
||
up to six active searches globally, and 12 budget units per IP per 10 minutes:
|
||
a single track costs one, a playlist/album six. Expired records are cleaned on
|
||
requests; the limiter has a 10,000-record ceiling. It uses `getClientAddress()`,
|
||
never arbitrary forwarded headers; unsupported adapters share a conservative key.
|
||
Configure adapter-node's trusted proxy/client-address settings correctly at deployment.
|
||
|
||
Requests have a 12-second timeout and at most two retries for network errors,
|
||
429s or server errors. Backoff includes jitter. `Retry-After` is honored; delays
|
||
above four seconds return a helpful error and retain a provider cooldown rather
|
||
than holding the request open. All caches, cooldowns, concurrency and rate limits
|
||
are per process and reset on restart; multiple instances do not share them.
|
||
|
||
Local commands (npm scripts and existing tooling; no new dependencies):
|
||
|
||
```sh
|
||
npm install
|
||
npm run dev
|
||
npm run test:unit -- --run
|
||
npm run test:integration
|
||
npm run check
|
||
npm run lint
|
||
npm run build
|
||
```
|
||
|
||
Finder unit tests mock upstream APIs. Browser tests cover initial layout and
|
||
validation with and without enhancement, and do not call Spotify or osu!.
|
||
|
||
## Language learner
|
||
|
||
`/projects/language-learner` teaches Dutch and Korean (explained in English) and
|
||
English (explained in Dutch), from A1 to C1. Korean starts with a Hangul level.
|
||
It runs entirely in the browser: no database tables, migrations or API keys.
|
||
|
||
- Course content lives in `src/lib/language-learner/courses/<lang>/<level>.ts` as
|
||
compact `target | translation | note` lines, plus markdown notes and
|
||
multiple-choice grammar questions (`prompt | answer | wrong; wrong | explanation`).
|
||
Exercises (choice, typing, sentence building, fill-in-the-blank, listening,
|
||
matching) are generated from that data. `content.test.ts` validates every lesson.
|
||
- Endless drills are generated by code in `src/lib/language-learner/drills/`:
|
||
number words, Dutch/English clock times, de/het, plurals, Dutch and Korean
|
||
conjugation (a rule-based Korean conjugator handling the ㅂ/ㄷ/르/ㅅ/ㅎ
|
||
irregulars), Korean particles and counters, Hangul reading, English irregular
|
||
verbs, comparatives and a/an.
|
||
- Challenges: a seeded daily challenge (first score of the day counts), 60-second
|
||
sprint, survival with three lives, mixed practice, level tests that mark a level
|
||
as done at 80%, and spaced-repetition reviews (simplified SM-2).
|
||
- Audio uses the browser's speech synthesis, so listening exercises appear only
|
||
when the device has a voice for the language. Romanization of Korean is
|
||
approximate Revised Romanization.
|
||
- Progress, streaks and settings are stored in `localStorage`
|
||
(`language-learner-v1`), per browser. Export/import on the overview page moves
|
||
them between devices.
|
||
|
||
## Shared accounts and permissions
|
||
|
||
`/login`, `/register`, and `/account` are the central account pages. Better Auth
|
||
handles email/password credentials and database-backed sessions for the entire
|
||
site. Google and osu! buttons appear when the corresponding client ID and secret
|
||
are configured. The header exposes account access everywhere.
|
||
|
||
Copy the auth settings from `.env.example`. `BETTER_AUTH_SECRET` must be at least
|
||
32 random characters; there is no runtime fallback secret. Set `ORIGIN` and
|
||
`BETTER_AUTH_URL` to the same public origin (for example,
|
||
`https://dev.stanrunge.dev` behind the local proxy). Register these exact callbacks:
|
||
|
||
- Google: `<BETTER_AUTH_URL>/api/auth/callback/google`
|
||
- osu!: `<BETTER_AUTH_URL>/api/auth/callback/osu`
|
||
|
||
osu! uses the `identify` scope and a stable numeric account ID. Its API does not
|
||
supply an email address, so these accounts use a reserved, unverified
|
||
`<id>@osu.invalid` internal identifier, hidden from the account UI. Credential
|
||
registration cannot claim that namespace. Provider accounts are linked explicitly
|
||
from `/account` while logged in; there is no automatic merging by email. Link a
|
||
provider **before** using it to create a separate account if you want shared
|
||
progress. Existing separate accounts are not automatically merged.
|
||
|
||
Every new user has role `user`. The shared server guards enforce administrator
|
||
access for blog drafts/writes, list creation/editing, Animaldex moderation, and
|
||
Progress edits. Legacy `ADMIN_SECRET`, `AUTH_TOKEN`, `admin_token`, and `token`
|
||
logins no longer authorize anything. To grant yourself admin access, register,
|
||
copy the account ID shown on `/account`, then run on the server:
|
||
|
||
```sh
|
||
npm run auth:role -- <user-id> admin
|
||
# To revoke access:
|
||
npm run auth:role -- <user-id> user
|
||
```
|
||
|
||
Role changes take effect on the next request; roles cannot be set through signup
|
||
or profile updates. Public registrations are never made admin automatically.
|
||
Future projects should reference the shared `user.id`, use `requireUser(event)`,
|
||
and scope **both reads and writes** to the session user's ID. Use
|
||
`requireAdmin(event)` for site-wide administration; never accept an owner ID from
|
||
a form or infer permission from the presence of a cookie.
|
||
|
||
Email/password registration works without a mail service. Email verification and
|
||
password recovery emails are **not configured** yet. Connect a transactional mail
|
||
sender and Better Auth's verification/reset hooks before offering those flows.
|
||
Better Auth's rate limiting is enabled and currently uses per-process memory;
|
||
a multi-instance deployment needs shared rate-limit storage. Account pages and
|
||
saved progress require a working database. Authentication forms require JavaScript.
|
||
|
||
## Game tracker
|
||
|
||
`/projects/game-tracker` is the hub for completionist progress. Catchemall lives at
|
||
`/projects/game-tracker/pokemon` (the old `/projects/catch-em-all` URL redirects),
|
||
with location pages under `/projects/game-tracker/pokemon/locations`. Call of Duty
|
||
games live at `/projects/game-tracker/call-of-duty/<slug>`. All catalog data is
|
||
owned by the site: it is checked in under `data/` and seeded into Postgres, and
|
||
nothing calls PokéAPI or Steam at runtime.
|
||
|
||
### Pokémon catalog
|
||
|
||
`npm run catalog:build` rebuilds two files from the pinned PokéAPI CSV snapshot
|
||
(revision recorded inside them):
|
||
|
||
- `data/pokemon-catalog.json`: **1,025 species, 53 game versions/expansions and
|
||
27,267 species/game entries**, plus each species' evolution chain and the
|
||
evolution method that applies **in each game** (Magnezone needs Mt. Coronet in
|
||
Platinum but a Thunder Stone in Sword).
|
||
- `data/pokemon-locations.json`: 1,104 locations and about 67,000 encounters
|
||
(area, method, levels, rarity, form and conditions such as time of day, season
|
||
or swarm). Max Raid dens are folded per location.
|
||
|
||
Each entry has one availability:
|
||
|
||
- `catchable`: a documented encounter (wild, gift, static, in-game trade).
|
||
- `evolve`: a pre-evolution is obtainable in that game and the method exists
|
||
there, e.g. Blastoise in Red is “Evolve Wartortle: Level 36+”.
|
||
- `breed`: an obtainable relative can breed it (not in Gen 1, Let’s Go,
|
||
Colosseum/XD or the Legends games); incense babies name their incense.
|
||
- `event_only`: a mythical in the game with no other route.
|
||
- `unknown`: in the game's Pokédex data, but no confirmed route.
|
||
|
||
`catchable`, `evolve` and `breed` can be ticked and count toward progress.
|
||
PokéAPI has **no encounter data** for Brilliant Diamond/Shining Pearl, Legends:
|
||
Arceus, Scarlet/Violet and their DLC, or Legends: Z-A, so those games stay
|
||
`unknown` until another source is added. Missing pairs are not proof of absence.
|
||
|
||
Administrators can correct any entry from the Pokémon detail dialog and add notes
|
||
and a picture (JPEG/PNG/WebP, 5 MB, stored in R2 under `locations/`) on any
|
||
location page. Rows carry a `source`: seeding refreshes `pokeapi` and `derived`
|
||
rows and never touches `manual` ones. Location notes and pictures always survive
|
||
reseeding, and user progress is keyed by entry, so it survives too.
|
||
|
||
### Call of Duty catalog
|
||
|
||
`data/call-of-duty.json` covers Black Ops, Black Ops II, Ghosts, Advanced
|
||
Warfare, Black Ops III, Infinite Warfare and WWII. Weapon classes, post-launch
|
||
(DLC/update) weapons and camo rules are **hand-maintained** from the Call of Duty
|
||
Wiki (CC BY-SA). `npm run catalog:cod` refreshes only the achievement lists from
|
||
the public Steam Community pages. Users tick per-weapon milestones (bought or
|
||
unlocked, max level, Gold, Gold Cheetah); class and overall camos (Diamond, Dark
|
||
Matter, Solar, Black Sky, Chrome, Chrome Tiger) are derived in
|
||
`src/lib/game-tracker/mastery.ts`, and post-launch weapons never gate them.
|
||
Weapon progress is keyed by a slug of the weapon name, so renaming a weapon in
|
||
the JSON starts its progress over; the seed keeps removed items that already have
|
||
progress and warns about them.
|
||
|
||
### Database
|
||
|
||
For an existing database using the current domain schemas:
|
||
|
||
```sh
|
||
npm run db:migrate:auth
|
||
npm run db:migrate:foundation
|
||
npm run db:migrate:money-tracker
|
||
npm run db:migrate:game-tracker
|
||
npm run db:seed
|
||
npm run db:seed:cod
|
||
```
|
||
|
||
For a fresh database, run `npm run db:push` followed by both seeds.
|
||
The migrations are additive and repeatable; they do not reset tables. The
|
||
game-tracker migration adds the `evolve`/`breed` availability values, the entry
|
||
`source` column (marking pre-existing hand-edited notes as `manual`), and the
|
||
location, encounter, evolution and `tracker_*` tables. Unknown legacy OAuth
|
||
providers cause the auth migration to fail for explicit review instead of
|
||
guessing an issuer. Back up a production database before any schema migration.
|
||
The scripts use `DB_URL` from `.env` via Bun.
|
||
|
||
## Money tracker
|
||
|
||
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
||
are created by `npm run db:migrate:money-tracker`; apply that migration to each
|
||
environment before deploying the new route. The deployment preflight checks for
|
||
all nine tracker tables and the projection columns.
|
||
|
||
Transactions can be entered manually or imported from a CSV or tab-separated
|
||
export with `date,account,description,amount` headers. Dates accept
|
||
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
||
such as `-€1,234.50`. Quoted fields and comma, semicolon, or tab delimiters
|
||
are supported, and empty rows are skipped. Imported entries are stored in date
|
||
order. `Start balance` rows are imported as ordinary transactions, so leave
|
||
the account opening balance at zero for those accounts. Import creates
|
||
missing accounts and adds every row in one database transaction. It does not
|
||
deduplicate repeat uploads, so import each file once. Accounts have an editable
|
||
opening balance for money held before the first recorded transaction.
|
||
The current balance and history stop at today; future dated transactions feed the
|
||
wealth projection. The projection invests spendable money above the configured
|
||
reserve in a modeled balance, even without an existing investment account.
|
||
An existing investment account can optionally be selected as the starting
|
||
balance for growth. The projection treats recurring entries as monthly
|
||
equivalents from next month and can disable them to avoid double counting entries
|
||
already recorded as future transactions. Its reserve, hypothetical return,
|
||
contribution rule, and end date are editable. Wish list priority runs from tier 5
|
||
(highest) to tier 1 (lowest), with cumulative estimated costs on the overview.
|
||
|
||
The utility comparison uses meter readings within the configured billing period,
|
||
the current per-unit rate, and prorated monthly advances. It excludes fixed
|
||
charges, taxes, and past rate changes. Set the billing start to a recorded opening
|
||
meter reading for a meaningful annual comparison.
|
||
|
||
Seeding runs in one transaction and is repeatable. It preserves row IDs, existing
|
||
catches, selected games, and hand-maintained availability notes. Pokémon are keyed
|
||
by national number, games by PokéAPI version ID, and entries by Pokémon/game pair.
|
||
Existing games with exactly matching names are adopted to preserve their IDs.
|
||
The old broken reset and auth-schema overwrite commands have been removed.
|
||
|
||
Availability is deliberately conservative:
|
||
|
||
- `catchable`: PokéAPI supplies an encounter record. Conditions or a particular
|
||
form may be required. These entries contribute to the catch checklist.
|
||
- `unknown` (`?`): a Pokédex or game-index record exists, but the acquisition method
|
||
has not been confirmed. Membership alone never becomes “catchable”.
|
||
- A dash: no source record; this does **not** prove the Pokémon is unavailable.
|
||
- Manually maintained trade/transfer/event entries remain supported.
|
||
|
||
Encounter coverage varies by game and is especially incomplete for newer titles.
|
||
The import does not invent evolution, breeding, gift, trade, transfer, or event
|
||
rules from missing data. The page explains these limits. Catalog refreshes are
|
||
explicit: update `REVISION` in `scripts/build-pokemon-catalog.py`, run
|
||
`npm run catalog:build`, review the snapshot, and rerun the seed. Source:
|
||
[PokéAPI data](https://github.com/PokeAPI/pokeapi/tree/master/data/v2/csv).
|
||
|
||
Catches and selected games are stored server-side under the authenticated user's
|
||
ID. Repeated catch requests are idempotent, writes validate catalog IDs, and an
|
||
explicit empty game selection survives reloads. The UI rolls back failed saves
|
||
and prevents overlapping changes to the same catch.
|
||
|
||
`npm run test:unit -- --run` covers authorization, safe return paths, osu! identity
|
||
mapping, and catalog integrity alongside existing tests. `npm run test:auth` runs
|
||
browser/database checks against a migrated, seeded **development** database; it
|
||
creates temporary test accounts/content and removes them afterward. It checks
|
||
registration, login/logout, cookie replay, role escalation attempts, cross-user
|
||
progress isolation, admin routes, game selection, and OAuth authorization startup.
|
||
Completing Google/osu! consent still requires real provider credentials and the
|
||
registered callback URL. The integration follows the installed Better Auth APIs;
|
||
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
|
||
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
|
||
|
||
## Staging and production rollout
|
||
|
||
Use `staging` as the working branch and set it as the repository's default branch
|
||
in Gitea after the first push. A direct push to `staging` runs unit tests, builds
|
||
the site and a SHA-tagged Docker image, and deploys that image to
|
||
`https://staging.stanrunge.dev`. After checking staging, use Gitea Actions →
|
||
**Deploy production** → **Run workflow**. That action retests the current staging
|
||
commit, verifies that staging runs its exact image, and starts the **same image**
|
||
at `https://stanrunge.dev`. It does not rebuild the image or need a PR. Pushes to
|
||
`main` no longer deploy production; once `staging` is the default branch, its
|
||
current manual workflow is also the one shown in Gitea's Actions UI.
|
||
|
||
The staging Docker build runs `bun install --frozen-lockfile`, unit tests, and
|
||
the production build, so a failed test never produces a deployable image. The
|
||
production button verifies the exact tested staging image before switching
|
||
production. Browser/database tests need a
|
||
dedicated test database and are not part of this gate yet. `npm run check` and
|
||
full lint are also not gates while the repository has existing unrelated
|
||
diagnostics; add them after those are fixed. A failed test, build, or preflight
|
||
prevents the switch; a failed post-deploy health check attempts to restore the
|
||
previous image tag. Deployment never migrates or seeds either database. The
|
||
Gitea runner needs Docker CLI/socket access and the existing `DEPLOY_KEY` secret.
|
||
|
||
Before either push, configure the existing private environment file on that
|
||
host. Staging uses `/etc/personal-website-staging/staging.env`; production uses
|
||
`/etc/personal-website/prod.env`. Each file needs `DB_URL`, a unique random
|
||
`BETTER_AUTH_SECRET` of at least 32 characters, and both `ORIGIN` and
|
||
`BETTER_AUTH_URL` set exactly to its public HTTPS origin. Add the Google and
|
||
osu! client ID/secret pairs for whichever sign-in buttons should be enabled.
|
||
List and Animaldex picture uploads reuse the existing R2 bucket; set the five
|
||
`R2_*` values from `.env.example`. The app server uploads images to R2, so the
|
||
bucket does not need browser CORS rules for these forms. Each image is limited
|
||
to 5 MB; the container sets `BODY_SIZE_LIMIT=6M` for multipart requests.
|
||
Their provider console callback URLs must use that environment's origin:
|
||
|
||
```text
|
||
https://staging.stanrunge.dev/api/auth/callback/google
|
||
https://staging.stanrunge.dev/api/auth/callback/osu
|
||
https://stanrunge.dev/api/auth/callback/google
|
||
https://stanrunge.dev/api/auth/callback/osu
|
||
```
|
||
|
||
For this first rollout, review the SQL and back up each database. The first push
|
||
to `staging` may pass **Transfer tested image** but fail **Deploy staging** at
|
||
the database preflight. That is expected if the database has not been migrated:
|
||
the image is then available on the deployment host for you to run the migrations
|
||
yourself. If `animals`, `sightings`, and `photos` still have UUID IDs, first apply
|
||
`scripts/animaldex-uuid-to-serial.sql` with `psql` once. If the database has the
|
||
legacy singular tables `game`, `pokemon_game_entry`, `user_entry`, and `user_game`,
|
||
then apply `scripts/legacy-schema-migration.sql` with `psql` once. That migration
|
||
renames those tables without discarding their rows and creates the empty base
|
||
`lists` and `list_items` tables. Run both reviewed SQL files before the container
|
||
commands below. On the deployment host, use the SHA from the staging Actions run:
|
||
|
||
```sh
|
||
release_image=git.stanrunge.dev/stan/personal-website:<staging-sha>
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-auth-catalog.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-foundation.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-money-tracker.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-game-tracker.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-tracker-journal.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-catalog.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-catalog.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-pokemon-research.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-challenges.ts
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-art.ts
|
||
```
|
||
|
||
These catalog seeds use the reviewed files in `data/` and upsert catalog rows;
|
||
they preserve user accounts, catches, ranks, and saved challenge progress. Do not
|
||
copy or restore the entire development database over staging or production.
|
||
Repeat the same migration and seed commands for staging first, replacing the
|
||
environment file with `/etc/personal-website/prod.env` only after staging has
|
||
passed its preflight and been reviewed. Back up production before applying its
|
||
migrations. Rerun the same staging Actions run after the staging migration and
|
||
seed complete. Click the production button only after production migration and
|
||
seed complete. Future releases without database changes need only a staging push
|
||
and the production button.
|
||
|
||
The foundation migration gives lists slugs, progress, pictures, and blog tags;
|
||
gives site-owned join/settings rows numeric IDs; and adds `created_at`/
|
||
`updated_at` with a database update trigger. Blog posts already had numeric IDs
|
||
and timestamps. Better Auth's account/session IDs stay as text because they are
|
||
provider-managed identities. The deploy script verifies the migrations and
|
||
catalog before switching the image, then checks `/login` and Catchemall. A
|
||
failed app health check attempts to restore the previous image tag. Database
|
||
changes are not rolled back with the app image.
|
||
|
||
The release image contains `scripts/`, `data/`, and the schema files needed by
|
||
the migration tools. After registration in each environment, promote your own
|
||
account using the host's matching environment file, for example:
|
||
|
||
```sh
|
||
sudo docker run --rm --network backend \
|
||
--env-file /etc/personal-website-staging/staging.env \
|
||
git.stanrunge.dev/stan/personal-website:<deployed-sha> \
|
||
bun scripts/set-admin.ts <account-id> admin
|
||
```
|
||
|
||
Use `/etc/personal-website/prod.env` and its deployed image SHA for production.
|
||
The account ID is shown on `/account`. No public signup grants admin privileges.
|
||
|
||
# CS prices
|
||
|
||
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
|
||
individual listing. The separate `cs_item_dimensions` table makes it possible to
|
||
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
|
||
additional imported dimensions such as collection or rarity. Group chart points
|
||
average the available per-listing prices equally within each time bucket. Raw
|
||
observations retain their source, currency, timestamp, kind, price in cents, and
|
||
volume. Chart responses use up to roughly 240 time buckets per series and allow
|
||
up to 1,000 selected series; actual response time still depends on database size
|
||
and hardware.
|
||
|
||
With `DB_URL` pointed at a running Postgres database:
|
||
|
||
```sh
|
||
npm run db:migrate:cs-prices
|
||
npm run cs-prices:ingest -- catalog
|
||
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
|
||
```
|
||
|
||
The catalog pass pages through Counter-Strike market listings and saves a current
|
||
lowest-listing snapshot. The history pass visits each catalog item and stores
|
||
Steam's available historical sale-price and volume points. It needs a Steam web
|
||
session, may take many hours for the full catalog, and can be resumed with
|
||
`--start=<item offset>` and bounded with `--limit=<items>`. Requests pause for two
|
||
seconds by default (`--delay-ms=...`, minimum one second), retry temporary
|
||
failures, and upsert observations safely. Run catalog regularly for new listings
|
||
and snapshots; run history regularly for new sale points. Steam's market web
|
||
endpoints are undocumented and may change or limit access. The historical
|
||
granularity and completeness are bounded by what Steam returns; running this
|
||
collector cannot reconstruct older high-frequency prices that Steam no longer
|
||
exposes.
|
||
|
||
For another provider or a prepared archive, import JSON Lines instead:
|
||
|
||
```sh
|
||
npm run cs-prices:ingest -- import ./prices.jsonl
|
||
```
|
||
|
||
Each line needs `marketHashName`, `source`, `currency` (three-letter ISO code),
|
||
`observedAt` (ISO timestamp), `priceCents`, and `kind`. Optional `name`, `volume`,
|
||
and `dimensions` add detail. For example:
|
||
|
||
```json
|
||
{
|
||
"marketHashName": "AK-47 | Redline (Field-Tested)",
|
||
"source": "archive",
|
||
"currency": "USD",
|
||
"observedAt": "2026-09-24T12:00:00Z",
|
||
"priceCents": 1850,
|
||
"volume": 42,
|
||
"kind": "sale-median",
|
||
"dimensions": { "collection": "Phoenix", "rarity": "classified" }
|
||
}
|
||
```
|
||
|
||
Imported sources and price kinds appear in the page's source and kind controls.
|