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.
25 KiB
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.
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:
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
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):
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>.tsas compacttarget | translation | notelines, 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.tsvalidates 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:
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 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
(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 byscripts/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.
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
and 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.
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:
- checks the image and that
DB_MIGRATE_URLis set in/etc/personal-website/prod.env; pg_dumpof prod (postgres:17-alpine) into the backup directory;bun scripts/db-migrate.ts(Drizzle migrations) and the catalog seeds;- 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:
https://stanrunge.dev/api/auth/callback/google
https://stanrunge.dev/api/auth/callback/osu
After registering, promote your account (ID shown on /account):
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:
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:
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:
{
"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.