All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Lists contracts/pension docs with written summaries, an inline viewer and download at /trackers/money/documents. Files and summaries live on the host (MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in git or static/; the file endpoint is admin-only (401/403) and private, no-store.
478 lines
25 KiB
Markdown
478 lines
25 KiB
Markdown
# Stan Website
|
||
|
||
This is my personal website, including:
|
||
|
||
- Contact info / social links
|
||
- About me
|
||
- Blog posts + comments
|
||
- Projects like Animaldex, CatchEmAll
|
||
|
||
## House tracker
|
||
|
||
Available at `/projects/house-tracker` (admin only). Listings come from a worker
|
||
that runs at home; setup, settings and the ban-avoidance rules are in
|
||
[docs/house-tracker.md](docs/house-tracker.md).
|
||
|
||
## osu! Map Finder
|
||
|
||
Available at `/projects/spotify-beatmap-finder`. Built with the existing Svelte 5,
|
||
SvelteKit 3, Tailwind 4, and adapter-bun 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
|
||
`$app/env/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-bun's trusted proxy/client-address settings (`ADDRESS_HEADER`, `XFF_DEPTH`) 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
|
||
|
||
The schema is managed by Drizzle (`src/lib/server/db/schema/`, migrations in
|
||
`drizzle/`). Postgres schemas: `auth`, `money`, `house`, `ref`, `games`, `cs`,
|
||
`animaldex`, `site`, `meta` (Drizzle-managed) and `lists` (one real table per list,
|
||
created at runtime through the `meta.create_list` family of SQL functions, so it is
|
||
excluded from Drizzle via `schemaFilter`). See [Development](#development) for the
|
||
local database and migrations, and `db/reorg/README.md` for the one-time move of the
|
||
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||
upsert reference data and run on every production deploy.
|
||
|
||
## Book tracker
|
||
|
||
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
|
||
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
|
||
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
|
||
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
|
||
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
|
||
position per file, autosaves progress and sessions, and stores highlights with notes.
|
||
|
||
Files are never public. They are streamed (with Range support) by
|
||
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
|
||
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
|
||
`hooks.server.ts`).
|
||
|
||
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
|
||
|
||
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
||
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||
|
||
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
|
||
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
|
||
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||
be opened.
|
||
|
||
## Money
|
||
|
||
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) 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.
|
||
|
||
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
|
||
written summary, an inline viewer and a download. Nothing about them is in git: the
|
||
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
|
||
`/money-documents` when it exists, otherwise `~/money-documents`) as
|
||
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
|
||
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
|
||
`/home/stan/money-documents` read-only at `/money-documents` when it has a
|
||
`documents.json`. Files are only streamed to admins via
|
||
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
|
||
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
|
||
no redeploy is needed.
|
||
|
||
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).
|
||
|
||
## Development
|
||
|
||
There are two environments: local dev and production. Prod is the source of truth
|
||
for data; the dev database is a disposable copy of it.
|
||
|
||
```sh
|
||
cp .env.example .env # DB_URL (app role) + DB_MIGRATE_URL (owner role) -> local personal_dev
|
||
scripts/db-refresh-dev.sh # fresh prod dump -> personal_dev (sessions cleared)
|
||
bun run db:migrate # apply this branch's pending Drizzle migrations
|
||
bun run dev
|
||
```
|
||
|
||
`db-refresh-dev.sh` reads `PROD_DB_URL` (a role that can read everything, e.g.
|
||
`personal`) and `DEV_ADMIN_URL` (superuser on the dev cluster) from the environment
|
||
or `.env.dev-refresh` (git-ignored, never commit it). It refuses to overwrite the
|
||
source database or any database named `personal`. `PG_DOCKER=1` uses the
|
||
`postgres:17-alpine` image when local `pg_dump` 17 isn't installed.
|
||
|
||
Schema changes: edit `src/lib/server/db/schema/`, run `bun run db:generate --name
|
||
<change>`, review the SQL in `drizzle/`, then `bun run db:migrate`. Never edit an
|
||
applied migration; never use `db:push` against prod.
|
||
|
||
`scripts/preview.sh <branch> [port]` runs a branch against a throwaway copy of the
|
||
dev database (migrated with that branch's migrations) and cleans everything up on
|
||
Ctrl-C.
|
||
|
||
### Database roles
|
||
|
||
| env var | role | used for |
|
||
| ---------------- | -------------- | --------------------------------------------------------------- |
|
||
| `DB_URL` | `personal_app` | the app at runtime: DML only, calls the `meta.*` list functions |
|
||
| `DB_MIGRATE_URL` | `personal` | Drizzle migrations, seeds, backups (`pg_dump`) |
|
||
|
||
`personal_ddl` owns the `lists` schema (only reachable through the SECURITY DEFINER
|
||
`meta.*` functions); `personal_ro` is read-only. The deploy preflight fails if
|
||
`DB_URL` can create objects, i.e. if it isn't the app role.
|
||
|
||
## Production
|
||
|
||
Push or merge to `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) builds a
|
||
SHA-tagged image (unit tests + production build run inside the Docker build),
|
||
transfers it, and runs `scripts/deploy-remote.sh <sha> deploy` on the host:
|
||
|
||
1. checks the image and that `DB_MIGRATE_URL` is set in `/etc/personal-website/prod.env`;
|
||
2. `pg_dump` of prod (postgres:17-alpine) into the backup directory;
|
||
3. `bun scripts/db-migrate.ts` (Drizzle migrations) and the catalog seeds;
|
||
4. preflight (tables, column types, app-role privileges), switch the container, health checks.
|
||
|
||
A failed health check restores the previous image tag automatically. Manual
|
||
rollback: Gitea Actions → **Deploy production** → **Run workflow** with
|
||
`rollback_tag=<previous sha>`; this redeploys that image without migrating or
|
||
seeding. Database changes are not rolled back with the image; restore the
|
||
pre-deploy dump if a migration has to be undone. Only `main` deploys; other
|
||
branches never trigger the workflow. The runner needs Docker CLI/socket access
|
||
and the `DEPLOY_KEY` secret.
|
||
|
||
`/etc/personal-website/prod.env` needs `DB_URL`, `DB_MIGRATE_URL`, a unique random
|
||
`BETTER_AUTH_SECRET` (32+ characters), and `ORIGIN`/`BETTER_AUTH_URL` set to
|
||
`https://stanrunge.dev`, plus the Google and osu! client ID/secret pairs and the five
|
||
`R2_*` values from `.env.example`. Picture uploads (lists, Animaldex) go through the
|
||
app server to R2 (5 MB per image; the container sets `BODY_SIZE_LIMIT=6M`). OAuth
|
||
callback URLs:
|
||
|
||
```text
|
||
https://stanrunge.dev/api/auth/callback/google
|
||
https://stanrunge.dev/api/auth/callback/osu
|
||
```
|
||
|
||
After registering, promote your account (ID shown on `/account`):
|
||
|
||
```sh
|
||
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||
git.stanrunge.dev/stan/website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin
|
||
```
|
||
|
||
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 migrated database:
|
||
|
||
```sh
|
||
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.
|