Practices and exposure¶
The rules the project actually holds to, and an honest account of what is exposed right now.
Default-deny is the posture¶
Nothing is public unless a line of config says it is. Caddy's site block
ends with a catch-all that answers 404, and every public route needs its own
handle block added deliberately.
Two things follow from this, and both are intentional:
- Adding a public route is a visible diff in
caddy/Caddyfile, which someone has to review. - Forgetting to add one fails closed. The worst case is "my new endpoint 404s in production", not "my new endpoint is on the internet".
Why 404 and not 403?
A 403 confirms the path exists. A 404 with an empty body is
indistinguishable from a route that was never written, so scanning the
public domain tells an attacker nothing about the admin panel — not even
that there is one.
What is exposed, right now¶
| Surface | Public? | Reachable how |
|---|---|---|
GET /Health |
Yes | https://51.79.242.169.nip.io/Health |
| The admin UI | No | SSH tunnel only |
/api/admin/* |
No | SSH tunnel only |
/api/players, /api/characters, /api/events |
No | SSH tunnel only, and admin-gated |
| Postgres | No | Docker network only |
| Jenkins | No | SSH tunnel on a separate port |
| This wiki | Yes | https://docs.51.79.242.169.nip.io |
So there are exactly two public surfaces, and each has a stated reason:
/Health— the Jenkins smoke test curls it over HTTPS after every deploy.- The wiki — onboarding is useless if reading it requires an SSH tunnel.
The wiki describes the system's internals
Internal ports, the tunnel, the deploy mechanism, and the fact that
/Health is the only public API route. That is a deliberate trade: the team
can read it without setup. If it stops being an acceptable trade, Caddy
basic_auth on the docs site block is the whole fix — see
Caddy.
The game-data endpoints are a stopgap¶
/api/players, /api/characters and /api/events currently sit behind
.RequireAdmin(). That is not the intended end state — game servers and
players are meant to reach them without an admin session. It is there because
the alternative was leaving them open to the internet while no player-facing
auth exists.
Two changes are planned and have not happened yet:
- The paths become singular.
/api/players→/api/player, and likewise for the others. - They become public, with their own auth — a server key for game servers, player credentials for players. Neither exists.
Build against reality, document the intent
Write code against the paths as they are today. When they change, it will be a deliberate migration with the API reference updated in the same commit.
Secrets¶
| Secret | Where it lives | In git? |
|---|---|---|
| Production Postgres password | .env on the VM, and /var/lib/jenkins/hakutaku.env |
No — .env is gitignored |
| Production domain | same | No |
| First owner password | HAKUTAKU_ADMIN_PASSWORD, or generated and logged once |
No |
| Dev Postgres password | hakutaku, hardcoded in compose.dev.yaml and appsettings.Development.json |
Yes, on purpose |
The dev credentials are in source control deliberately: they only ever reach a
container bound to 127.0.0.1 on your own machine, and the alternative is every
new teammate hitting a connection error on their first run.
compose.dev.yaml must never be deployed
Hardcoded credentials, no TLS, and Postgres published to the host. The file
header says so, and so does TODO.md. Production is compose.yaml.
Rules for secrets¶
- Never commit a real
.env. Copy.env.exampleand edit the copy. - Generate them properly:
openssl rand -base64 32. - A secret that reaches a log is burned. Rotate it rather than hoping.
Credentials and sessions¶
These are settled decisions, not preferences. Changing any of them is a security change and should be discussed first.
| Rule | Why |
|---|---|
| Passwords are Argon2id at OWASP minimums (19 MiB, t=2, p=1) | Current best practice; the salt and parameters are inside the encoded hash, so there is nothing separate to store |
| Session tokens are 32 random bytes; only their SHA-256 is stored | A database dump yields no usable sessions |
The cookie is HttpOnly + SameSite=Strict |
JS cannot read it, so XSS cannot exfiltrate it; strict same-site blocks CSRF |
Login answers one identical 401 for wrong password, unknown user and disabled account |
Does not leak which usernames exist |
| An unknown username still runs a hash verification against a dummy | A miss costs the same time as a wrong password, so timing does not leak either |
| Two expiries: 30 min idle, 12 h absolute | A forgotten tab dies; a stolen cookie cannot be kept alive forever |
Full detail in Auth and sessions.
Nothing is hard-deleted¶
The convention across the admin tables: deactivate, do not delete.
- Deactivating an admin sets
disabled_atand revokes their sessions. The row stays. - Logging out sets
revoked_aton the session. The row stays. - There is no restore endpoint, and no
DELETEon game data at all.
Deactivation burns the username for good
The unique index on lower(username) covers disabled rows too, so a
deactivated admin's username can never be reused. The UI says so in its
confirmation prompt. This is a consequence of soft delete, not a separate
decision — worth knowing before you deactivate admin2.
The owner account can never be deactivated through the API, whoever asks. That guard is what stops someone locking the whole team out: the seeder only runs when there are zero admins, so a disabled sole owner could not be recovered over HTTP at all.
Treat IDs as opaque¶
Player, character and event IDs are GUIDs today. Planning documents target
bigint keys instead.
So never parse, format, pad or assume the length of an ID — in the frontend, in the SDK, or in the simulator. Pass them through as strings. That way the eventual key migration is a server-side change rather than a hunt through every client.
Code conventions¶
Comments explain why¶
The codebase is commented at a specific density: enough to explain a decision that would otherwise look arbitrary, not a narration of the code. Match it.
// Materialised before projecting so RoleName stays reusable -- EF can't
// translate a local method into SQL. The table is tiny, so this is fine.
var admins = await db.AdminUsers.OrderBy(a => a.Id).ToListAsync();
That comment is worth keeping, because the next reader's first instinct is to "optimise" it back into a single SQL projection, which does not compile.
Everything else¶
- Migrations are EF-generated and keep their timestamp names. Never hand-edit one that has been applied anywhere.
- Admin tables are snake_case with
bigintkeys. Follow that for new tables, not the PascalCase of the older game tables. - The frontend has one fetch wrapper. All HTTP goes through
api.tsso a dead session is handled in one place. Do not callfetchfrom a page. - One route table. The nav menu and the auth guard are both derived from the
array in
router.ts. Adding a page is one entry there. - Server-side validation is the real validation. The UI mirrors the server's rules to keep the forms honest, never as the enforcement point.
Known gaps¶
Deliberately listed rather than quietly omitted:
- No player-facing auth, so game data is admin-gated as a stopgap.
- No audit log table, despite the concept appearing in planning docs.
admin_sessions.ipis recorded but never compared against anything.admin_users.totp_secretexists in the schema and is unused by any code.- Deactivation records no
disabled_by, and re-deactivating overwritesdisabled_at. - Deactivation's two writes are not in one transaction: sessions are revoked, then the disable is saved.
- Migrations run on startup, so several app instances against one database would race.
- No test suite anywhere, so CI has no test stage.