chore: stop tracking the docs/changes ledger

docs/changes held 26 per-feature change notes and their index,
written while each feature was built. They were working records, not
documentation: they cite internal milestone numbers and plan steps,
several describe designs that changed before they shipped (the nano
note's config schema and a proxy plugin that was never built), and
nothing in the code, the build or the other docs refers to them. New
notes stopped being added a while ago; the commit messages carry that
record now.

The directory leaves the tree in this commit. Its contents stay
reachable in history, and the files were kept outside the repository
before removal. No code, build or test changes.
This commit is contained in:
flyemoji committed 2026-09-22 15:02:47 +09:00
1 parent 7b5b28c587
commit a56c326518
27 files changed
-1881

No files matched your search

@@ -1,41 +0,0 @@
# Foundational subsystems: the initial Felis import (ledger backfill)
- **Type:** feature (initial import) — retroactive ledger entry
- **Date:** 2026-06-26
- **Area:** `apis/`, `internal/` (naming, rcon, store, config, build, backup, operator,
submit, api, platform), `cmd/felis`, `plugins/`
- **Commits:**
- `7fbebfe` feat(apis): MinecraftServer CRD types (v1alpha1) — the lifecycle source of truth (§1)
- `708cdfc` feat(core): naming, RCON, store (Postgres + embedded migrations), config, image-build libraries
- `43ab921` feat(backup): archive-based world backup/restore + the retention/idle reaper
- `78b8cf6` feat(operator): MinecraftServer controller and reconcilers
- `d39605e` feat(submit): user modpack build + admin-approval pipeline (see [modpack-submission-lane](2026-06-26-modpack-submission-lane.md))
- `b508fcc` feat(api): dual-faced felis-api — permissions/LuckPerms, modpack lane, admin fleet read
- `47fcd90` feat(platform): node orchestration + the `cmd/felis` single-binary entrypoint
- `93f143f` feat(plugins): Velocity proxy + Fabric/Forge/NeoForge/Paper integration mods
- **Tasks:** #23 (permissions), #24 (modpack lane), #25 (fleet read)
## What it did
Stood up the whole backend spine in one build-order sweep: the Kubernetes CRD that is
the lifecycle source of truth, the core libraries (deterministic resource naming, the
RCON client, the Postgres store with embedded SQL migrations, config loading, container
image-build helpers), the backup/restore/reaper subsystems, the operator controller
that drives `MinecraftServer` resources, the user-modpack submit+approval pipeline, the
dual-faced (internal/external) felis-api behind a Zero-Trust guard, the platform
orchestrator that wires it all together under `cmd/felis`, and the server-side
integration plugins.
## Why
This is the project's first functional import — the substrate every later change edits.
It predates the change-ledger convention (established `fad48ff`, 2026-07-06), so it never
got a contemporaneous detail doc; this entry backfills one.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history to close the
> change-ledger's detail-doc axis (§ Convention). This entry deliberately describes only
> what these eight commits **introduced** on 2026-06-26 — the named subsystems have been
> extended and reworked many times since (auth, passkey, metrics, quotas, updates), and
> that later work lives in its own dated detail docs, not here. Not independently
> re-verified for this doc; each subsystem was verified at its original commit and the
> current tree builds green at `9911b8c` (WSL oracle, go1.26.4).
@@ -1,30 +0,0 @@
# Modpack submission lane: build/approval pipeline + storage backends (ledger backfill)
- **Type:** feature — retroactive ledger entry
- **Date:** 2026-06-26 – 2026-07-02
- **Area:** `internal/submit` (build/approval pipeline, storage backends), `internal/api` (submission endpoints)
- **Commits:**
- `d39605e` feat(submit): user modpack build + approval pipeline — an uploaded modpack stays `pending_review` and is never built until an admin approves; approval is a single-winner compare-and-swap handing off to the image-build Job, keeping the mandatory vulnerability scan in front of any push
- `598f3d3` feat(submit): local + S3 backends for modpack upload contexts, installer-selectable
- **Tasks:** #24 (§8 user-submitted modpack approval lane)
## What it did
Built the user-directed extension over the image-build subsystem: a player uploads a
modpack context, it sits in `pending_review`, and an admin's approval is the single-winner
gate that hands off to the build Job — with the vulnerability scan always ahead of any
registry push. `598f3d3` makes the upload-context store pluggable (local filesystem or S3),
selectable at install time.
## Why
Untrusted user content must never build or push unreviewed, and the compare-and-swap
approval guarantees exactly one build per submission even under a double-click or retry.
The storage-backend choice lets a single-node demo use local disk while a real deployment
uses S3, without a code change.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The approval
> compare-and-swap and endpoints were unit-tested at their commits; the S3 path is
> integration-configurable. The panel-side submission/approval UI is the collaborator's
> frontend work and is tracked only by its INDEX rows. Not independently re-verified for
> this doc; current tree green at `9911b8c`.
@@ -1,38 +0,0 @@
# Cloudflare Tunnel + Access edge (cfsetup) + NodePort fencing (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-06-27 – 2026-07-01
- **Area:** `internal/cfsetup` (pure core + integration runner), `cmd/felis` (TUI edge flow), edge nftables fence
- **Commits:**
- `53a7664` feat(cfsetup): recommended Cloudflare Tunnel + Access edge (§14) — domain- and IdP-agnostic; the load-bearing `validateFailClosed` allowlist refuses any policy that could be public; fail-shut 404 catch-all; the raw game host is never proxied
- `ba13839` feat(breakglass): optional Tunnel + Access setup in the sudo TUI, an independent peer of Owner provisioning
- `a531f5e` fix(cfsetup): keep the connector install in the host apply layer only (drop the duplicate `StartConnector`)
- `2810fe8` fix(cfsetup): repoint a stale DNS record when routing a tunnel hostname
- `7d3be64` feat(cfsetup): start the tunnel connector as a setup step
- `346ec68` refactor(deploy): rework the cloudflare-edge walkthrough — restructured the edge TUI flow and added a tested `cfsetup` integration-runner path (with TUI height-measure/root tests)
- `e058a64` feat(edge): close the panel NodePort to the public after the tunnel is up — nftables at prerouting `raw` (-300), before kube-proxy's NodePort DNAT, gated on the connector actually serving; loopback accepted first so the connector origin hop is untouched
- **Tasks:** #37 (fence panel NodePort to public after tunnel)
## What it did
Stood up the optional one-click Zero-Trust edge: a Cloudflare Tunnel routing only the web
hostnames plus a fail-closed Access application, provisioned from the sudo TUI against the
operator's own Cloudflare account. `e058a64` then closes the Access-bypass hole where a
direct `https://<node-ip>:<nodeport>/` with the right Host header reached the origin
behind Access, by fencing the NodePort at the nftables raw hook so the packet is caught on
its original destination port — but only once the connector is confirmed serving, so
fencing never severs the only web path to a live origin.
## Why
Access is only a security boundary if the origin cannot be reached around it. The
fail-closed policy guard (`validateFailClosed`) and the NodePort fence are the two
load-bearing safety properties: a policy that could be public aborts the run with nothing
created, and a routable-but-unfenced NodePort would defeat the whole edge.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The policy guard,
> ingress generation, request bodies, gating, and the nftables ruleset shape / conn-count
> gate are unit-tested; the live cloudflared/Cloudflare-API and `nft` calls are
> INTEGRATION-ONLY (need a real account). KNOWN-LIMITATION: the fence targets nftables;
> firewalld-native coordination is deferred. Not independently re-verified for this doc;
> current tree green at `9911b8c`.
@@ -1,32 +0,0 @@
# Console auth: local-password login → passwordless migration (ledger backfill)
- **Type:** feature + refactor — retroactive ledger entry
- **Date:** 2026-06-27 – 2026-07-04
- **Area:** `internal/api` (auth handlers, sessions), `internal/store` (users schema)
- **Commits:**
- `af14f02` feat(api): local-password authentication backend — login/logout/change-password on `op.console`; HttpOnly+Secure+SameSite=Lax host-only server-side sessions (SHA-256, 12h TTL); anti-enumeration uniform bcrypt; JSON-only credential writes (415 otherwise); fails closed unless `local_auth_enabled`
- `0c1cc59` feat(auth): migrate console login to passwordless
- `3b43f05` refactor(api): drop the dead login concurrency limiter and reconcile passwordless comments
- `c20b12c` refactor(api): drop the dead password-era `ResetMailer`, reconcile passkey-unbind docs
- **Tasks:** #27 (B1 thin thread), #79/#80/#81 (residue sweep + primitive adjudication)
## What it did
Shipped the staff local-password door (`af14f02`) as the primary web login when
Zero Trust is not in front of the API, then migrated the console to passwordless
(`0c1cc59`) once email-OTP + passkey were the intended factors. The two refactors
(`3b43f05`, `c20b12c`) then swept the password-era residue — the now-dead login
concurrency limiter and the `ResetMailer` — so no unused password machinery lingered in
the compile path, and reconciled the stale comments that referenced it.
## Why
`op.console` needs a real login even in deployments without a Cloudflare-Access edge; the
password backend was that. Once the passwordless factors landed, keeping the old password
scaffolding around was a bug farm — the sweep is the closeout evidence that the migration
was complete, not half-done.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. `af14f02` was
> covered by Go unit tests (content-type guard, anti-enumeration, forced-change lockdown)
> at its commit. Not independently re-verified for this doc; current tree green at
> `9911b8c` (WSL oracle, go1.26.4).
@@ -1,38 +0,0 @@
# Deploy: one-line bootstrap installer + demo bring-up (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-06-27 – 2026-07-03
- **Area:** `deploy/` (bootstrap.sh, Dockerfiles, demo-up.sh), image build context
- **Commits:**
- `58fa4b0` feat(deploy): one-line bootstrap installer + distroless felis image (auto-detects apt/dnf, installs Docker/k3s/PostgreSQL, opens pg_hba to the pod CIDR, runs migrations, applies the control-plane bundle, leaves Web disabled pending `felis setup`)
- `94a3b7b` fix(deploy): harden bootstrap for RHEL-family Linux
- `deaa2f8` feat(deploy): zypper support (openSUSE/SLES)
- `318a724` feat(deploy): pacman support (Arch)
- `e5f1682` refactor(deploy)!: TUI (breaking walkthrough restructure)
- `28c3eee` refactor(deploy): improved TUI walkthrough
- `c14ed17` fix(docker): keep embedded `panel/` and `deploy/` in the image build context
- `d9e866f` fix(deploy): make the lobby image actually build (re-include `plugins/paper`, build on `gradle:8.14-jdk21`)
- `b84debf` feat(deploy): one-shot `demo-up.sh` — bootstrap → build/import limbo+lobby images → wire `[velocity]` image refs → `felis setup`, ending in the interactive Owner TUI
- **Tasks:** #26 (Phase A bootstrap verified end-to-end on the Demo VM)
## What it did
Made a bare Linux box a running Felis with one command. `bootstrap.sh` auto-detects the
host package manager across the four major families (apt/dnf/zypper/pacman), installs
whatever is missing (Docker, k3s, PostgreSQL, cloudflared), builds+imports the distroless
felis image, opens `pg_hba` to the pod CIDR, runs migrations, and applies the rendered
control-plane bundle. `demo-up.sh` wraps that plus the login-limbo/lobby image build and
`felis setup` into a single command, stopping only at the Owner-creation TUI it cannot
automate.
## Why
The spec calls for a self-hostable single-node deployment a SysAdmin can stand up without
a Kubernetes background. The package-manager fan-out and the demo wrapper are what make
"one line" true across real distros rather than only on the author's box.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history to close the
> change-ledger's detail-doc axis. `deploy/` is shell + Dockerfiles (not Go-oracle
> verifiable); `d9e866f` records a real build+boot check (limbo `/healthz` 200, lobby
> reaches "Done"). Not independently re-verified for this doc; current tree green at
> `9911b8c`.
@@ -1,35 +0,0 @@
# felis CLI: break-glass recovery console + first-run setup (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-06-27 – 2026-06-30
- **Area:** `cmd/felis` (break-glass/setup TUI, apply, migrate), `internal/api` (audit, owner store), `deploy/`
- **Commits:**
- `e108a37` feat(cli): break-glass emergency console TUI — root-only (`euid==0`), provisions/resets the Owner directly against Postgres, enables local login, prints a durable one-time-password summary
- `2d0bbb0` feat(cli): attribute break-glass recovery to the SysAdmin who runs it — bootstrap / recovery (bcrypt) / root-override, each audited with an honest `verified` flag and payload
- `a94b001` feat(deploy): break-glass Operator account provisioning
- `eb5875a` feat(felis): Operator break-glass op behind an operation menu
- `f5d00f3` feat(cli): `felis apply` for direct CRD creation
- `9c46632` feat(cli): `felis setup` first-run console (shared `runConsoleTUI` model, reclaim protection, cfsetup idempotency, `[auth].admin_hostname` respect)
- `7d91373` fix(migrate): honor `-config` placed after the `up` verb (flag.Parse stops at the first non-flag token)
- **Tasks:** #27 (B1 login→change-pw→TUI reset)
## What it did
Built the local-root recovery and first-run surface that bypasses web Zero Trust by
design. `felis breakGlass` mints or resets the Owner when the web login is unreachable;
`2d0bbb0` makes it accountable by recording *which* SysAdmin broke the glass across three
audited modes. `felis setup` is the non-emergency first-run twin sharing the same console
model. `felis apply` writes a `MinecraftServer` CRD directly, and `7d91373` fixes the
`migrate` flag parse so a configured DB path after `up` is honored.
## Why
An operator with root on the node and a kubeconfig must always be able to recover the
platform — that is break-glass's whole job, so it never refuses. Attribution
(`2d0bbb0`) closes the gap that root is machine authority, not a human identity: the root
gate is necessary but not sufficient for the audit trail.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The core logic was
> covered by Go unit tests over a fake owner store at each commit (auth match/non-match,
> the three audit modes, headless TUI drive). The bubbletea TUI glue is untested by house
> convention. Not independently re-verified for this doc; current tree green at `9911b8c`.
@@ -1,36 +0,0 @@
# Player onboarding data layer §B2: email-OTP, account-link, QR, Bind-Code (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-06-27 – 2026-07-03
- **Area:** `internal/api` (onboarding/auth-bind handlers), `internal/store` (migrations 0004–0006)
- **Commits:**
- `dbe34a1` feat(api): player email-OTP verification (§B2) — `POST /account/email/{start,verify}`; 6-digit code, SHA-256-at-rest, 10-min TTL, 5-attempt cap enforced in the repo
- `1f8b9bb` feat(api): record account-link auth source (`mojang|thirdparty`) for the dual-Yggdrasil split (§10)
- `116595f` feat(api): QR scan-login completion poll on the internal face (`GET /internal/account/link/status/{mc_uuid}`) — read-only, reuses `UserByMCUUID`, no migration
- `fe2ece0` feat(api): public Bind-Code onboarding (`POST /auth/bind`) — the one pre-account entrypoint of `console.<root_domain>`; refuses a staff-UUID code with 403 without consuming it, so the public door provably never yields an admin principal
- `55592ed` feat(auth): public auth-bind endpoint wiring
- `6c3999a` fix(api): rate-limit email-OTP sends to close the email-bomb vector
- `879b177` fix(api): make OTP-start throttle atomic to close the concurrent-burst bypass
- **Tasks:** #29 (B2 data layer), #32 (OTP rate-limit), #35 (atomic throttle), #39 (console access model)
## What it did
Built the Go-verifiable data layer of forced web onboarding: prove control of an email
(OTP), record which Yggdrasil authenticated an in-game UUID, let a phone already signed in
to the panel complete a QR device-code link, and let an account-less player redeem a
one-time Bind Code minted in the Login Lobby to create+link+session in one public step.
The two fixes bound the OTP abuse surface — a per-target send rate limit and an atomic
reserve that closes the check-then-act race on the attempt counter.
## Why
The spec forces onboarding through the web so every account is provably email-controlled
and UUID-linked before it can operate anything. The `op.console` redline in `fe2ece0` — a
staff-UUID code is refused without being consumed — is what keeps the public console door
from ever minting an admin principal.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The account/session
> logic, single-use codes, and the op.console redline were covered by handler tests + the
> OpenAPI parity gate at each commit; the identity guarantee behind a Bind Code lives in
> velocity/Java (CODE-ONLY) and is not verifiable from this repo. Not independently
> re-verified for this doc; current tree green at `9911b8c`.
@@ -1,32 +0,0 @@
# §B3 username-collision reclaim + account migration (ledger backfill)
- **Type:** feature — retroactive ledger entry
- **Date:** 2026-06-27 – 2026-07-05
- **Area:** `internal/api` (internal-face reclaim/blacklist, account migrate), `internal/store` (migration 0006)
- **Commits:**
- `a29571d` feat(api): reclaim squatted usernames for Mojang-priority players (§B3, 正版优先) — `POST /internal/player/reclaim` bars the squatter UUID + stashes its data (30-day hold) in one transaction, idempotent, returns the *first* reclaim's expiry; `GET /internal/player/blacklist/{mc_uuid}` is the login-gate check
- `fdb6efb` feat(account): migrate a live account's owned servers to a new account (§B3 inherit)
- **Tasks:** #30 (B3 game-login + username-collision reclaim)
## What it did
Built the data layer of the Mojang-priority collision flow: when the configured
third-party Yggdrasil and official Mojang issue the same username under different UUIDs,
the non-genuine squatter is displaced in favour of the real Mojang owner. Both tables are
keyed by `mc_uuid`, so the genuine player — identical username, *different* UUID — is
never caught by the bar. `fdb6efb` adds the inherit half: migrating an existing account's
owned servers onto a new account.
## Why
Two players cannot hold one username across two Yggdrasils; the spec resolves it in the
genuine Mojang owner's favour with a 30-day data hold for the displaced squatter, told the
truth about how long their data is kept (the first hold's window, never a fresh `now()+30d`
on retry).
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. Handlers + the
> in-memory repo contract were unit-tested at each commit; the Postgres SQL path is
> integration-only, and the velocity collision-routing / limbo prompt / authlib
> dual-backend are code-only (Java) and out of this data-layer slice. Not independently
> re-verified for this doc; current tree green at `9911b8c`. Related: the operator-facing
> `/felis migrate` command has its own doc ([felis-migrate-command](2026-07-05-felis-migrate-command.md)).
@@ -1,39 +0,0 @@
# felis-api security + robustness hardening (audit sweep) (ledger backfill)
- **Type:** fix — retroactive ledger entry
- **Date:** 2026-06-30 – 2026-07-01
- **Area:** `internal/api` (login, request-id, listeners, SSE relays, quota/claim, MyServers), `internal/operator`
- **Commits:**
- `7a51c1d` fix(api): bound concurrent login bcrypt to shed CPU-pin floods (429 `auth_busy` before the compare; a cap, not a per-account lockout) — *audit #2*
- `164ac44` fix(api): validate inbound `X-Request-Id` before echo + audit persist (≤64 bytes, log-safe charset) — *audit-integrity*
- `c6c0772` fix(api): read/idle timeouts on all three listeners via a `newAPIServer` factory (closes Slowloris via `ReadHeaderTimeout`; `WriteTimeout` left unset so SSE isn't severed) — *audit #3*
- `3c1d647` fix(api): per-principal SSE stream cap (429 `too_many_streams`) — *audit #1, blast-radius bound*
- `d6e3189` fix(api): per-write deadline on SSE relay to sever a stalled reader (the real leak close behind the cap) — *audit #1*
- `8f41a00` fix(api): clear the SSE write deadline on return so it can't leak onto a reused keep-alive connection — *audit #1*
- `6368ab1` fix(api): `COALESCE` the MyServers `owned` flag so an ownerless row doesn't 500 the listing
- `2a4a81b` fix(api): don't burn the wake cooldown when refused at capacity
- `9873904` fix(operator): populate `Status.Players` from an RCON `list` probe (so the panel doesn't report 0/0)
- **Tasks:** #33 (wake cooldown), #34 (Status.Players), #41–#46 (audit #1–#4)
## What it did
A hardening sweep across the API's abuse and robustness surface: bound the two unbounded
CPU/goroutine amplifiers (concurrent bcrypt, per-principal SSE streams), close the SSE
relay's real stalled-reader leak with a per-write deadline (and clear it so it can't leak
onto a pooled connection), validate the caller-supplied request id before it reaches the
audit trail, set listener timeouts to close Slowloris, and fix two functional bugs — the
ownerless-row 500 and the wake cooldown burned on a capacity refusal.
## Why
Each is a specific, demonstrated failure mode: a login flood pins every core in bcrypt; a
stalled SSE reader leaks a relay goroutine + its upstream kube-apiserver follow *for the
life of the process*; an unvalidated `X-Request-Id` is a CR/LF log-forgery vector. The
`WriteTimeout`-left-unset detail is load-bearing — a blanket write timeout would sever the
healthy long-lived console/build-log streams the platform depends on.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. Each fix shipped a
> targeted test at its commit — notably `d6e3189`/`8f41a00` use a deadline-aware
> `ResponseWriter` that fails closed if the guard is removed. The quota-claim TOCTOU
> (audit #4) is a documented KNOWN-LIMITATION (`2c56d17`), closeable only against a real
> Postgres. Not independently re-verified for this doc; current tree green at `9911b8c`.
-25
View File
@@ -1,25 +0,0 @@
# felis_* Prometheus metrics (§23) (ledger backfill)
- **Type:** feature — retroactive ledger entry
- **Date:** 2026-06-30
- **Area:** `internal/metrics` + the emit sites in build, platform/fleet, and the start lifecycle
- **Commits:**
- `75642d9` feat(metrics): named `felis_*` Prometheus collectors
- `2a93a9e` feat(metrics): record `felis_image_build_failures_total` on failed builds
- `79eae7f` feat(metrics): publish `felis_servers_total` from a fleet snapshot
- `8ac5e64` feat(metrics): observe `felis_start_duration_seconds` across the start lifecycle
- **Tasks:** #17 (§23 felis_* metrics decision)
## What it did
Added the named `felis_*` collector set and wired the three emit points that make it
non-empty: a counter incremented on image-build failure, a gauge published from a fleet
snapshot, and a histogram observed across the server start lifecycle.
## Why
§23 calls for first-class operational metrics under a stable `felis_` namespace rather than
ad-hoc logging, so an operator can alert on build failures, fleet size, and start latency.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. Not independently
> re-verified for this doc; current tree green at `9911b8c` (WSL oracle, go1.26.4).
@@ -1,37 +0,0 @@
# Auto-update subsystem: decision core + sources + gatherer + window API (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-07-01 – 2026-07-05
- **Area:** `internal/updates` (pure decision core), `internal/updater` (release sources, gatherer), `internal/api` (window admin API)
- **Commits:**
- `c01f133` feat(updates): pure I/O-free decision core — each tracked component is Pinned (Minecraft, left alone), Notify, or Scheduled (apply only inside a SysAdmin window); never force-applied, never a downgrade, never an auto-applied prerelease
- `3673af6` feat(api): admin API for the maintenance window (`GET`/`PUT /updates/window`), stored as JSON under `platform_settings` — API + persistence only, nothing consumes it yet
- `7464fa7` fix(updates): tag `Window` JSON so the persisted window round-trips (the obvious decode is correct by construction; a zero window fails closed to notify-only)
- `96b3cc9` feat(updater): wire `updates.Run` to a caller with PaperMC v3 release discovery
- `7d27640` feat(updater): GitHub Releases source, routing felis-api/k3s/cloudflared
- `7db57b9` feat(updater): `VersionGatherer` extraction core + CLI gather seam
- **Tasks:** #38 (auto-update: Felis/k3s/components/Velocity, pin Minecraft)
## What it did
Built the auto-update spine as a pure decision core plus the release-discovery sources
(PaperMC, GitHub Releases) and the version gatherer, with a SysAdmin-set maintenance
window read/written through an admin API. Version parsing tolerates the real feeds (leading
`v`, k3s `+k3s1` suffix, calendar versions, prerelease tails) and orders by SemVer
precedence.
## Why
The red lines are `不要强制自动更新` (never force auto-update) and `能不动的就别动`
(Minecraft stays pinned). The design encodes them structurally: a component may be applied
*only* inside a window the operator explicitly set, and Minecraft is Pinned so it is never
touched. `7464fa7`'s fail-closed zero-window (decodes to notify-only, never a rogue apply)
is the safety property for the not-yet-built runner.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The load-bearing
> invariants (pinned never changes, no downgrade, no auto-prerelease, apply-only-in-window)
> and the JSON round-trip contract were unit-tested at their commits. This subsystem is
> deliberately **report-only / integration-deferred**: the concrete Notifier/Applier,
> the `felis update` CLI + CronJob, and the current-version producing seams are declared
> but not wired (see `internal/updater/doc.go`, `openapi.yaml`). Not independently
> re-verified for this doc; current tree green at `9911b8c`.
@@ -1,37 +0,0 @@
# Passkey (WebAuthn) enrollment subsystem + hardening (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-07-01 – 2026-07-02
- **Area:** `internal/passkey` (go-webauthn adapter), `internal/api` (enrollment handlers/audit), `internal/store` (migrations 0007–0009)
- **Commits:**
- `f2c916d` feat(api): passkey enrollment persistence layer
- `742f15f` feat(api): passkey enrollment endpoints
- `0261204` feat(passkey): go-webauthn enrollment verifier adapter (Oracle-verified against a virtual authenticator)
- `fce0fce` feat(passkey): wire the enrollment verifier into felis-api
- `7278cd7` feat(passkey): require + record user verification at enrollment (`UserVerification=required`; capture `user_verified`/`backup_eligible`/`backup_state` — migration 0009) — *fix (d)*
- `cdbb5ab` fix(api): record credential id in the passkey-register audit event so bind/unbind are symmetric — *fix (a)*
- `9953275` fix(api): bound `webauthn_challenges` growth by superseding *all* prior rows per (user, purpose) — *fix (b)*
- `20e31fb` fix(store): cascade-delete passkeys + challenges on user removal (recreate both FKs `ON DELETE CASCADE`, scoped to the passkey tables only) — *fix (c)*
- `54bc6ef` fix(api): clear bound passkeys on password change to close a takeover foothold — *fix (e)*
- **Tasks:** #36 (passkey bind with email-OTP fallback), #48–#52 (fixes a–e)
## What it did
Built the WebAuthn *enrollment* half — persistence, the go-webauthn crypto adapter, and
the register-begin/finish endpoints — then hardened it through the five-fix batch (a–e):
symmetric audit, a bounded challenge table, cascade cleanup, enforced+recorded user
verification, and unbinding every passkey on a password reset so a passkey planted through
a transiently-hijacked session cannot survive as a standing login foothold.
## Why
Passkeys are the phishing-resistant factor with email-OTP as the fallback. The hardening
batch closes the seams that make enrollment safe to *rely on*: without UV enforcement a
passkey proves possession but not user; without the password-reset clear, a planted
passkey outlives the very remediation meant to evict an attacker.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The adapter crypto
> was verified against a virtual authenticator (virtualwebauthn), and each fix shipped
> with a targeted test (UV-negative rejection, challenge-growth bound, cascade, symmetric
> audit) at its commit. Not independently re-verified for this doc; current tree green at
> `9911b8c`. The assertion/login half is a separate doc ([passkey-login](2026-07-01-passkey-login.md)).
-36
View File
@@ -1,36 +0,0 @@
# Passkey (WebAuthn) login: assertion, discoverable, clone-detection (ledger backfill)
- **Type:** feature — retroactive ledger entry
- **Date:** 2026-07-01 – 2026-07-05
- **Area:** `internal/passkey` (assertion crypto), `internal/api` (login/assertion, unbind, UA-guard), `internal/store` (migrations 0013/0014)
- **Commits:**
- `e035142` feat(passkey): WebAuthn login/assertion crypto adapter (BeginLogin/FinishLogin over go-webauthn, Oracle-verified against a virtual authenticator; surfaces the signature counter as a ceremony fact)
- `ec468ba` feat(auth): discoverable (usernameless) passkey login — the from-zero door the username-first assertion couldn't key on
- `0dbd557` fix(store): renumber the discoverable-login migration 0013 → 0014
- `9e1df12` feat(passkey): advance `sign_count`, reject clone-warned assertions
- `4f59d51` feat(auth): owner-tier passkey-unbind remediation endpoint
- `a63f49d` feat(panel): steer WeChat/QQ in-app browsers to the system browser for passkey — a backend-only UA interstitial (the SPA is untouched); asset/API/health requests pass through, an `ua_ack` cookie lets a determined user continue
- **Tasks:** #40 (from-zero discoverable login), #67 (WeChat/QQ UA-guard in `internal/panel`)
## What it did
Built the assertion (login) half of the ceremony: the crypto adapter, then discoverable
credentials so a user with no typed identifier can still log in (the enrollment
identifier problem the earlier deferral doc named), clone detection via the advancing
signature counter, and the owner-tier unbind remediation. `a63f49d` guards the flow at the
transport edge — WebAuthn is unusable inside the WeChat/QQ WebViews, so those UAs get a
bilingual "open in your system browser" page instead of the passkey SPA.
## Why
Enrollment without a login path is half a feature. Discoverable credentials resolve the
blocker recorded in the earlier deferral (`users.email` is nullable/non-unique and a
player's username is their Minecraft UUID, so username-first assertion had nothing to key
on). The UA-guard stops the most common real-world dead end: a passkey prompt that can
never succeed inside an in-app browser.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The assertion crypto
> was verified against a virtual authenticator (enrollment→assertion chain, origin-mismatch
> and unbound-credential rejection); `9e1df12`'s clone policy and the UA-guard pass-through
> were unit-tested at their commits. Not independently re-verified for this doc; current
> tree green at `9911b8c`.
@@ -1,37 +0,0 @@
# System servers: login-limbo + lobby (always-on gate) (ledger backfill)
- **Type:** feature — retroactive ledger entry
- **Date:** 2026-07-02
- **Area:** `internal/config`, `internal/naming`, `internal/api` (CRD readiness), `internal/operator`, `internal/platform`, `cmd/felis`, `plugins/limbo`, `deploy/limbo` + `deploy/lobby`
- **Commits:**
- `9bed51b` feat(config): `[velocity] login_image/lobby_image` — setup provisions the always-on system services only when set (empty = fail-loud skip; no official LOOHP/Limbo image exists)
- `9ef817f` feat(naming): reserved system-server names + service-token identifiers (single source of truth for the internal-API credential Secret)
- `159107b` feat(api): HTTP readiness knob on `MinecraftServer` + user-server fallback defaults to the login gate
- `dc23cb5` feat(operator): system-server pod HTTP readiness probe + login-only `FELIS_SERVICE_TOKEN` env (keyed off the reserved name so it can never leak into a user pod; sourced via `secretKeyRef`, never inlined)
- `3fdb3d0` feat(platform): internal-API base-URL helper + single-sourced token Secret
- `f554d52` feat(cli): provision the reaper-exempt login/lobby servers + replicate the service-token Secret into the minecraft namespace
- `241fe21` feat(limbo): felis-limbo in-game login flow (join → blacklist check → mint bind code → open book to `console.<root_domain>` → poll link-status → BungeeCord transfer to lobby; fail-closed)
- `c7315e4` feat(deploy): login-limbo + lobby images with game-port pinning (server-port pinned to GamePort 25565 on every start)
- **Tasks:** #53–#68 (system-server plumbing L1–L4, limbo plugin, operator env injection)
## What it did
Stood up the always-on authentication gate: reserved, reaper-exempt login/lobby
`MinecraftServer`s provisioned by setup, an HTTP readiness path for the RCON-less LOOHP/Limbo
loader (which reports "started" only after the first tick), and the felis-limbo plugin that
runs the whole onboarding *inside* Limbo before transferring an admitted player to the
lobby. A fresh connection always lands on the login gate, never a user backend, so
authentication is always in front.
## Why
The spec requires that a player authenticate before reaching any real server. That needs a
purpose-built always-on front server (Limbo) that speaks to the internal API — hence the
login-only service-token injection (keyed to the reserved name so it can never reach a user
pod) and the HTTP readiness knob for a loader that has no RCON.
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. The Go layer
> (config/naming/readiness/operator env/platform) was unit-tested at each commit; the
> felis-limbo plugin is Java verified against a real Limbo jar via podman (#65), and the
> images carry a real build+boot check (#63, limbo `/healthz` 200 on 25565). Not
> independently re-verified for this doc; current tree green at `9911b8c`.
@@ -1,83 +0,0 @@
# Break-glass "halt a running server" op (#31 B4)
- **Type:** feature (addition)
- **Date:** 2026-07-05
- **Area:** `cmd/felis` — break-glass recovery console (Go, oracle-verifiable)
- **Commit:** `c2ee21a` — feat(breakglass): add halt-a-server op to the recovery console (§B4)
- **Task:** #31 Phase B4 (felis TUI break-glass ops)
## What it does
Adds a **"Halt a running server"** operation to the root-gated break-glass console.
The operator picks a server from the live fleet and the console flips that
`MinecraftServer` CRD's `spec.desiredState` to `Stopped`, letting the operator
reconcile it into a graceful shutdown. It is the emergency "stop this now" lever for
when the panel is unreachable but the box still has `root` + a kubeconfig.
## Why
The break-glass console already provisions the Owner and adds Operators, but there
was no local, panel-independent way to **stop** a misbehaving server (runaway,
compromised, resource-pinning). Halting is a reversible state nudge — the safest
possible break-glass power — so it belongs in the same root-gated recovery surface.
## Design decisions
- **CRD write, not pod kill.** The console flips `spec.desiredState=Stopped` with a
**spec-only merge patch** (`client.MergeFrom`), never a full-object `Update`. The
operator writes `status` on the same object continuously; a merge patch of
`spec.desiredState` touches a disjoint field and cannot race/clobber the operator's
status writes. A halt is therefore exactly the CRD write the operator already knows
how to honour.
- **Authority = root + kubeconfig.** The accountable actor is the OS user who
escalated to root (`osUser`), recorded for attribution — not proof. The root gate
plus kubeconfig possession *is* the authority, so (unlike the owner/operator paths)
no credential-minting auth sub-flow is needed for a reversible state change.
- **System servers allowed but named.** Halting the `login`/`lobby` system servers
takes the shared front door down (login has no fallback). Break-glass is deliberately
full power, so the console **warns** rather than forbids: a `⚠ system` tag in the
picker and an explicit `WARNING` line in the post-exit summary.
- **Audit is best-effort.** `performHalt` mirrors `performBreakGlass`: the halt
succeeds even if the audit sink is down (break-glass must work with logging broken);
any audit error rides back in the outcome and is surfaced as a summary `WARNING`.
- **Already-stopped is a no-op** reported distinctly ("was already stopped" vs "is now
stopping"), so the console never claims a stop it didn't perform.
- **Namespace from config.** The target namespace is `cfg.K8s.Namespace`, threaded
through the console constructors — never hardcoded.
## Files
| File | Change |
|---|---|
| `cmd/felis/halt.go` | **new** — pure core (no bubbletea): `listServersForHalt`, `haltServer` (merge patch), `isSystemServer`, `performHalt`, `auditHalt` |
| `cmd/felis/halt_test.go` | **new** — table tests against a controller-runtime **fake client** (applies patches for real): running→stopped persists, already-stopped no-op, missing→error, system flag, list projection + desired-state fallback, audit success, audit-failure-still-halts |
| `cmd/felis/tui_halt.go` | **new** — bubbletea/huh shell mirroring `ownerModel` (load → pick → work → done), empty-fleet guard, `⚠ system` picker labels, outcome card |
| `cmd/felis/tui_menu.go` | `bgHaltServer` enum + "Halt a running server" menu option |
| `cmd/felis/tui_root.go` | `namespace` field; `bgHaltServer` dispatch to `newHaltModel`; `haltResultMsg` terminal handling |
| `cmd/felis/breakglass.go` | halt fields on `breakGlassResult`; `namespace` threaded through `runBreakGlassTUI`/`runSetupTUI`/`runConsoleTUI`; post-exit halt summary (stopping / already-stopped, system + audit warnings, restart hint) |
| `cmd/felis/setup.go` | pass `cfg.K8s.Namespace` into `runSetupTUI` |
| `cmd/felis/tui_root_test.go` | pass `"minecraft"` namespace into `newRootModel` test call |
## Verification
WSL oracle (go1.26.4, FedoraLinux-44), authoritative for Go:
```
go build ./... → BUILD_OK
go vet ./cmd/felis/... → VET_OK
go test ./... → all 20 packages ok, ALL_GREEN
```
The core (`halt.go`) is fully unit-tested against a real `fake.Client`, which applies
the merge patch, so the test asserts the **persisted** `spec.desiredState`, not merely
that `Patch` was called. `tui_halt.go` is thin bubbletea glue (untested by house
convention, mirrors the existing `tui_owner.go`).
## Self-review outcome
- **ponytail (over-engineering):** lean — no one-impl interface, every field consumed,
audit seam justified. Nothing cut.
- **correctness:** caught and fixed a misleading restart hint — the summary originally
pointed at `felis apply`, but that command is **create-only** (errors "already
exists" on an existing server); corrected to "restart from the panel, or set
`spec.desiredState` back to Running."
@@ -1,80 +0,0 @@
# `/felis migrate` in-game command (§B3 inherit, Velocity side)
- **Type:** feature (addition)
- **Date:** 2026-07-05
- **Area:** `plugins/velocity` + `plugins/shared` — Velocity proxy plugin (Java, compile-verified)
- **Commit:** `c1aa38b` — feat(velocity): add /felis migrate to open an account migration (§B3 inherit)
- **Task:** completes the code-only gap named in `internal/api/handlers_account_migrate.go`
## What it does
Adds the in-game `/felis migrate` command that a player runs to **open an account
migration** — the first step of handing their owned servers to another account (spec
§B3 "inherit", scenario A). The command posts the player's Mojang-verified UUID to the
backend, which puts that account into migrate mode (`state=initiated`). The player then
finishes the migration on the web console (prove it's them, name the receiving account,
redeem a one-time code).
The Go backend (`handleMigrateStart` and the web-driven steps 2–4) already existed and
was tested; its header comment explicitly named **"the `/felis migrate` command that
calls handleMigrateStart"** as the code-only gap. This change closes that gap.
## Why
Without the in-game command, the migration flow had no entry point — the backend
handler was reachable only in theory. `/felis migrate` is the trustworthy initiator:
Velocity has already established the caller's online-mode UUID, so the sensitive proof
can be deferred to the web step-up while the in-game command just opens the migration.
## Design decisions
- **Mirrors the existing command suite verbatim.** `doMigrate` follows `doClaim`;
`migrateError` follows `claimError`; `migrateStart` follows `claim`/`opLoginApprove`.
No new imports, types, or idioms — every construct already appears in the same files.
- **Identity-bound + out-of-limbo, but server-independent.** Like `claim`, it requires
a real player past the login limbo (`requirePlayer` + `ensureOutOfLimbo`). Unlike
`claim`, it acts on the caller's *account*, not the server they stand on, so there is
**no** `registry`/current-server check.
- **Expects HTTP 201.** `migrateStart` posts to
`/api/v1/internal/account/migrate/start` and expects **201 Created** (`handleMigrateStart`
returns `StatusCreated`) — not 200 like the other calls. A 201 that does not affirm
`started:true` is treated as a contract breach, not a refusal.
- **Error mapping matches the handler's refusals:** 404 `not_linked` → "Link your
account on the web console before migrating"; 409 `account_retired` → "This account
can't start a migration (already migrated or retired)"; transport (0) and default →
generic retry text.
- **Points the player to the console on success.** The command only *opens* the
migration, so on success it prints the player web console URL
(`https://console.<root_domain>`, derived from config — never a hardcoded domain) and
a one-line description of the remaining steps. A proxy-side `logger.info` records the
initiating username against the UUID (the backend audit only has the UUID).
## Files
| File | Change |
|---|---|
| `plugins/shared/.../link/FelisApiClient.java` | **+`migrateStart(UUID)`** — POST mc_uuid, expect 201, affirm `started:true` |
| `plugins/velocity/.../FelisVelocityPlugin.java` | `migrate` literal in the Brigadier tree; **`doMigrate`** handler; **`migrateError`** mapper; `/felis migrate` help line |
## Verification
Java is not oracle-verifiable via the Go suite, but it **is** compile-verifiable via
the podman gradle toolchain established in #63/#65:
```
podman run --rm -v plugins:/work -w /work/velocity \
docker.io/library/gradle:jdk17 gradle --no-daemon compileJava
→ BUILD SUCCESSFUL in 19s (compiled against real velocity-api:3.3.0-SNAPSHOT)
```
The change compiles clean against the real Velocity API jar (including the shared
`FelisApiClient` compiled straight into the velocity module). The backend contract it
speaks to (`handleMigrateStart`) is covered by `handlers_account_migrate_test.go` on
the Go side.
## Self-review outcome
- **ponytail (over-engineering):** lean — pure mirror of three existing, compiling
methods; no speculative abstraction. Nothing cut.
- **correctness:** the one contract divergence (201 vs 200) was verified against the Go
handler source before writing.
@@ -1,29 +0,0 @@
# Operator: idle auto-stop, quotas, startup/readiness timeouts, /readyz (ledger backfill)
- **Type:** feature + fix — retroactive ledger entry
- **Date:** 2026-07-05
- **Area:** `internal/operator` (idle stop, timeouts), `internal/api` (quotas, /readyz)
- **Commits:**
- `91bfa27` feat(operator): idle auto-stop (§8)
- `e574749` feat(api): enforce CPU/memory/storage quotas (§9.3, §22)
- `7f7e459` fix(operator): enforce startup and readiness timeouts (§5, §8)
- `7becb38` fix(api): implement `/readyz` with real DB + K8s API + CRD checks (§7)
- **Tasks:** §5/§7/§8/§9.3/§22 operator + resource-governance spec items
## What it did
Rounded out the operator's lifecycle governance: stop idle servers automatically, enforce
per-resource CPU/memory/storage quotas at claim/create, bound how long a server may sit in
startup/readiness before the operator gives up, and make `/readyz` a real dependency check
(DB, Kubernetes API, and the CRD) rather than a static 200.
## Why
An orchestrator that never reclaims idle capacity or bounds startup will accumulate stuck
and wasteful workloads; a `/readyz` that always returns 200 tells the load balancer a
broken control plane is healthy. These are the spec's resource-governance and
readiness-correctness requirements (§5/§7/§8/§9.3/§22).
> **Backfill note.** Reconstructed 2026-07-07 from the commit history. Covered by Go unit
> tests at each commit. Not independently re-verified for this doc; current tree green at
> `9911b8c` (WSL oracle, go1.26.4).
@@ -1,117 +0,0 @@
# Break-glass "back up a world now" console peer (§B4 "Sync", phase 2b)
- **Type:** feature (addition)
- **Date:** 2026-07-07
- **Area:** `cmd/felis` (Go, oracle-verified); `internal/api` + `docs/openapi.yaml`
(the internal endpoint's `os_user` accountability extension)
- **Commit:** `fc748d3`
- **Task:** #31 Phase B4 break-glass ops — the "Sync" operation. Per the user's
**"两者都要"** decision the feature was built in two halves: the felis-api endpoint
that does the real backup-Job orchestration (phase 1 `7a7c0d5` external face, phase
2a `f2fc57c` internal face) and a break-glass menu peer that calls it while the API
is alive. **This change is that peer — phase 2b — the last build step of "Sync".**
(S3 remains open in B4; this does not close the phase.)
## What it does
Adds a **"Back up a world now (Sync)"** operation to the root-gated break-glass
console. The operator picks a server from the live fleet; the peer resolves the
`felis-api-internal` ClusterIP Service and the service token from the control
namespace, then POSTs the internal backup endpoint to snapshot that server's world
while felis-api is alive. It is the on-node counterpart to the halt op: an emergency
"snapshot this now" lever for when the panel is unreachable but the box still has
`root` + a kubeconfig and the API is running.
It also extends the internal backup endpoint (phase 2a) to accept an optional
`{"os_user":"..."}` body so the audit row names the operator at the keyboard rather
than the generic `break-glass`.
## Why
The console cannot render the backup Job itself — it lacks the deployment coordinates
(`FELIS_IMAGE`, `FELIS_BACKUP_PVC`) that only felis-api holds. So, unlike the halt peer
(which writes the `MinecraftServer` CRD directly), the backup peer must go **through**
the API. The internal face exists precisely so an on-node machine caller with the
service token — no browser session, no Cloudflare-Access Principal — can reach that
orchestration. This change is the client that knocks on that door.
## Design decisions
- **Goes through the API, does not orchestrate locally.** Mirrors the phase-2a
rationale: deployment coordinates live only in felis-api. The peer's job is to
resolve the endpoint, authenticate with the token, and translate the HTTP result
into a friendly outcome card — not to build a Job.
- **ClusterIP resolution, not DNS.** `resolveInternalAPI` `Get`s the
`platform.APIInternalServiceName` Service and dials its `Spec.ClusterIP:APIInternalPort`
directly, erroring on an empty or `None` (headless) ClusterIP. The on-node console's
host resolver is not CoreDNS, so the in-cluster Service DNS name would not resolve
from the host; the ClusterIP is routable from the node and is what the `2ba9948`
dedicated ClusterIP Service exists to provide.
- **`os_user` attribution, parity with halt.** The console sends the escalated OS user
in the request body; the endpoint makes it the audit actor. The body is decoded
whenever `ContentLength != 0` — **not** gated on `Content-Type` (`decodeJSON` checks
only for unknown/trailing fields, not the header) — so a console that forgets the
header still records the operator. Absent/blank falls back to `break-glass`. The
console does **not** double-audit: the API audits at the boundary, single-sourced.
- **Stopped-gate stays server-side.** The world PVC is RWO, so the server must be
stopped. The peer does not pre-check this; it lets the API's stopped-gate return
`409 not_stopped` and renders that as a "must be stopped — halt it first" card. The
safety check is single-sourced in the API, never duplicated (and possibly drifting)
in the console.
- **A running pick ends the session with exit 1 — deliberately accepted.** The picker
lists all servers (a running pick is easy to hit), and a `409` surfaces as an error
through `backupResultMsg{err}` → root sets `m.err` → `breakglass.go` prints the
friendly card to stderr and exits non-zero. A dedicated `not_stopped` non-error path
was considered and **rejected**: every break-glass op ends the session anyway (all
`tea.Quit`), so `409`-vs-success differs only in exit code — marginal for an
interactive TUI. No error is swallowed; the friendly card is shown either way. Adding
a soft-landing state machine for one status code is complexity the interactive
surface does not earn.
- **Core/shell split, mirrors halt.** All decision logic (`resolveInternalAPI`,
`requestBackup`, `backupErrorFromResponse`, `performBackupNow`) lives in `backupnow.go`
and is unit-tested against a controller-runtime fake client + `httptest`.
`tui_backupnow.go` is thin bubbletea/huh glue (untested by house convention, mirrors
`tui_halt.go`). The picker **reuses** `listServersForHalt`/`haltableServer` rather
than cloning a second server-listing path.
## Files
| File | Change |
|---|---|
| `cmd/felis/backupnow.go` | **new** — pure core: `resolveInternalAPI` (Service ClusterIP + token secret), `requestBackup` (POST + Bearer + `os_user` body, status→outcome), `backupErrorFromResponse` (409/503/404/error-body mapping), `performBackupNow` |
| `cmd/felis/backupnow_test.go` | **new** — table tests against a fake client + `httptest`: happy resolve, headless/empty-token errors; 202 asserts Bearer + `os_user` + path; 409/503/404 + transport-failure mapping |
| `cmd/felis/tui_backupnow.go` | **new** — bubbletea/huh shell mirroring `tui_halt.go`: load → pick → work → outcome card; empty-fleet guard; friendly error card |
| `cmd/felis/tui_menu.go` | `bgSyncBackup` enum + "Back up a world now (Sync)" menu option after the halt option |
| `cmd/felis/tui_root.go` | `bgSyncBackup` dispatch to `newBackupModel`; `backupResultMsg` terminal handling into `breakGlassResult` |
| `cmd/felis/breakglass.go` | `backedUp`/`backupServer`/`backupStatus` result fields; cancel guard; post-exit backup summary |
| `internal/api/handlers_backups.go` | `handleInternalBackup` decodes the optional `os_user` body (gated on `ContentLength`, not `Content-Type`) and passes it as the audit actor; defaults `break-glass` |
| `internal/api/handlers_backup_now_test.go` | **+subtest** in `TestInternalBackup`: `os_user` body attributes the audit to the operator |
| `docs/openapi.yaml` | document the `internalBackupNow` optional `os_user` request body |
## Verification
WSL oracle (go1.26.4, FedoraLinux-44, authoritative for Go):
```
go build ./... && go vet ./... && go test ./... → ALL_GREEN
```
`cmd/felis` and `internal/api` both re-ran (not cached), so the new `backupnow_test.go`
and the added `TestInternalBackup` subtest executed. `TestOpenAPIMatchesServedRoutes`
still passes: the `os_user` body is an addition to an already-documented operation, so
the served⇔documented route match is unchanged. The core is covered against a real
`fake.Client` (resolves the Service/Secret) + `httptest.Server` (asserts the wire
request and maps every status), so the tests exercise persisted/observable behaviour,
not merely that a call was made. `tui_backupnow.go` is thin glue, untested per the
`tui_halt.go` convention.
## Self-review outcome
- **ponytail (over-engineering):** lean — no new abstraction beyond the four core
functions two call sites (test + TUI) already justify; the picker reuses halt's
server-list core rather than cloning it; the deliberate rejection of a `not_stopped`
soft-landing path kept the state machine at four steps. Nothing cut.
- **correctness:** the `os_user` decode is gated on `ContentLength`, matching how
`decodeJSON` actually works (no `Content-Type` check), so a header-less console still
attributes correctly; the stopped-gate and audit stay single-sourced server-side, so
the peer cannot drift from the endpoint on the RWO safety check or the audit record.
@@ -1,82 +0,0 @@
# Separate ClusterIP Service for the felis-api internal face (8081)
- **Type:** bug fix (latent networking gap) + enabling change
- **Date:** 2026-07-07
- **Area:** `internal/platform` — Go (struct render oracle-verified; packet path **not**
verifiable in this environment — see Verification)
- **Commit:** `2ba9948`
- **Task:** #31 Phase B4 — surfaced while wiring the break-glass backup console peer
(phase 2b): the peer needs a routable path to the internal face, and that path was
broken for the login pod too.
## What it does
Renders a **new ClusterIP-only Service `felis-api-internal`** (control namespace)
that fronts the felis-api pod's internal port 8081, and repoints
`InternalAPIBaseURL` (the URL baked into the login pod's `FELIS_API_BASE_URL`) at
that Service name. Adds exported `APIInternalServiceName` / `APIInternalPort` so the
on-node break-glass console can resolve the Service's ClusterIP and dial it.
## Why (the latent bug)
The login limbo pod is configured with
`FELIS_API_BASE_URL = http://felis-api.<ns>.svc.cluster.local:8081` (setup.go) and
dials the internal face with the service token to mint bind codes and poll link
status. But the only Service named `felis-api` is the **external** face: a NodePort
Service that declares **only** port 443. A Service answers only on its declared
ports, so `felis-api:8081` had no backend — **every login-pod call to the internal
API silently failed to connect.** `deploy/limbo/README.md` even documented the
"login-pod → felis-api internal-port (8081) path" as reachable; it was not.
## Design decisions
- **A separate Service, not a second port on `felis-api`.** A `Type: NodePort`
Service allocates a node port for **every** declared port, with no per-port
opt-out. Folding 8081 into the NodePort `felis-api` Service would therefore publish
the internal face — which is service-token-only, explicitly **no Zero Trust** — on
every node's external IP. That violates the two-face security posture. A distinct
`ClusterIP` Service exposes 8081 **in-cluster only**: reachable by the login pod via
cross-namespace DNS, and by the on-node console via the ClusterIP (kube-proxy
programs ClusterIPs into the node's routing).
- **Repoint `InternalAPIBaseURL` to the new Service name.** The helper single-sources
the name the login pod is told to call; pointing it at `felis-api-internal` keeps
the login pod and the Service in agreement by construction.
- **Export the name + port for the console.** The break-glass backup peer (phase 2b)
resolves `APIInternalServiceName`'s ClusterIP at runtime and dials
`http://<clusterIP>:APIInternalPort` — it cannot use the cluster-DNS form because
the host's resolver is not CoreDNS.
## Files
| File | Change |
|---|---|
| `internal/platform/workloads.go` | **+`apiInternalService`** (ClusterIP, 8081→`internal`), wired into `Workloads()`; **+exported `APIInternalServiceName`/`APIInternalPort`**; `InternalAPIBaseURL` repointed at the internal Service, comment corrected |
| `internal/platform/workloads_test.go` | **+`TestAPIInternalService_ClusterIP`** — ClusterIP (never NodePort), 8081→`internal`, no nodePort, selects the api pods, name distinct from `felis-api` |
| `deploy/limbo/README.md` | document the `felis-api-internal` Service; correct the reachability note |
| `docs/troubleshooting.md` | §6 note: internal calls reached via `felis-api-internal`; a *connect* failure (not 401) points at that Service |
## Verification
WSL oracle (go1.26.4, authoritative for Go):
```
go build ./... && go vet ./... && go test ./... → ALL GREEN
```
`TestAPIInternalService_ClusterIP` freezes the Service's shape. **This is a
code-level fix only.** `go build/vet/test` verifies the Service *struct* renders
correctly; it verifies **nothing** about packets flowing — not the login pod's
in-cluster call, not the console's host→ClusterIP dial (which relies on kube-proxy's
OUTPUT-chain DNAT, present on k3s but unverified here), not that 8081 is programmed
on a live cluster. Per the project's "Java/K8s code-only" reality, the runtime path
is **pending real-cluster verification**; the manifest-level defect (a DNS name with
no backing port) is fixed and asserted.
## Self-review outcome
- **ponytail (over-engineering):** one Service + two exported identifiers, all
load-bearing (the login pod and the console both need the routable 8081). No new
abstraction; `apiInternalService` mirrors `apiService`/`registryService`.
- **correctness / security:** the ClusterIP-not-NodePort choice is the crux — it keeps
the no-Zero-Trust internal face off every node's external interface, which a second
port on the NodePort Service could not.
@@ -1,83 +0,0 @@
# Internal-face break-glass world backup endpoint (§B4 "Sync", phase 2a)
- **Type:** feature (addition)
- **Date:** 2026-07-07
- **Area:** `internal/api`, `docs/openapi.yaml` — Go, oracle-verified
- **Commit:** `f2fc57c`
- **Task:** #31 Phase B4 break-glass ops — the "Sync" operation. Per the user's
**"两者都要"** decision the feature is built in two halves: the felis-api endpoint
that does the real backup-Job orchestration (phase 1, `7a7c0d5`) and a break-glass
menu peer that calls it while the API is alive (phase 2b, follow-up). **This change
is phase 2a: the second, internal face of that endpoint** — the door the console
peer will knock on.
## What it does
Adds `POST /api/v1/internal/servers/{name}/backup`, an **internal-face** twin of the
external `POST /api/v1/servers/{name}/backup`. The on-node break-glass console (root
on the host, holding the service token) POSTs here to snapshot a stopped world while
felis-api is alive. Same 202 `backing_up` / 409 `not_stopped` / 503
`backup_unavailable` / 404 / 400 `bad_name` surface as the external face.
## Why
The console cannot render the backup Job itself: it lacks the deployment coordinates
(`FELIS_IMAGE`, `FELIS_BACKUP_PVC`) that only felis-api holds — the same reason the
endpoint exists at all (phase 1). But the external face requires a Cloudflare-Access
Principal the console does not have. The internal face authenticates with the service
token (a trusted machine caller, no Principal), so the console can reach the same
orchestration without a browser session.
## Design decisions
- **No owner gate on the internal face.** The external handler enforces owner-or-admin
from the Principal; the internal handler has none — the service token IS the
authorization (the operator already has root on the node), so a server owned by
someone else still backs up. This mirrors how the other internal-face handlers
(op-login approve, QR poll) trust the token rather than a Principal.
- **Shared `enqueueBackup` tail.** The RWO stopped-gate, the optional-Backuper 503,
the async hand-off, and the audit+202 were refactored out of `handleBackupNow` into
a single `enqueueBackup(w, r, name, rec, actor, source)` that both faces call. The
two faces differ **only** in how the caller is authorized and in the audit
actor/source — the security-critical stopped-gate is single-sourced so the faces
cannot drift apart.
- **Audit attributed to break-glass/internal.** The internal handler audits directly
via `Repo.Audit` with `Actor:"break-glass", Source:"internal"` (the `a.audit`
helper hardcodes `Source:"external"`), so a console-initiated backup is
distinguishable in the audit log from an owner's self-service one.
- **Console does not double-audit.** Unlike the halt peer — which writes the CRD
directly and audits locally — the backup peer goes through the API, and the API
audits at the boundary. Auditing is single-sourced there; the console will not
emit its own row.
## Files
| File | Change |
|---|---|
| `internal/api/handlers_backups.go` | **+`handleInternalBackup`**, **+`enqueueBackup`**; `handleBackupNow` tail now calls `enqueueBackup(..., p.Email, "external")` |
| `internal/api/api.go` | register `POST /api/v1/internal/servers/{name}/backup` on the internal-face route table |
| `docs/openapi.yaml` | document the `internalBackupNow` operation (`x-felis-face: [internal]`, `serviceToken` security) |
| `internal/api/handlers_backup_now_test.go` | **+`TestInternalBackup`** — no-owner-gate, break-glass/internal audit, stopped-gate/503/404/400 |
## Verification
WSL oracle (go1.26.4, authoritative for Go):
```
go build ./... && go vet ./... && go test ./... → ALL GREEN
```
`TestOpenAPIMatchesServedRoutes` gates the new route against `docs/openapi.yaml` in
both directions (served⇔documented) and passes. `TestInternalBackup` (5 subtests) and
the existing `TestBackupNow` (10) both pass — the external refactor is
behaviour-preserving (same audit actor `p.Email`/source `external`).
## Self-review outcome
- **ponytail (over-engineering):** the internal face is not a copy of the external
handler — the shared tail (`enqueueBackup`) collapses the duplication, and the two
handlers hold only their distinct auth + audit-attribution. No new abstraction
beyond the one shared function two callers already justify.
- **correctness:** the no-owner-gate difference is deliberate and matches the other
service-token handlers; the stopped-gate is unchanged and now single-sourced, so the
external and internal faces cannot diverge on the RWO safety check.
@@ -1,115 +0,0 @@
# On-demand world backup (§B4 break-glass "Sync"; felis-api endpoint + Job executor)
- **Type:** feature (addition)
- **Date:** 2026-07-07
- **Area:** `internal/backupjob` (new pkg), `internal/api`, `cmd/felis`, `docs/openapi.yaml` — Go, oracle-verified
- **Commit:** `7a7c0d5`
- **Task:** #31 Phase B4 break-glass ops — the "Sync" operation, resolved with the user as **immediate/on-demand world backup**. Per the user's "两者都要" decision this is built in two halves: **(this change) the felis-api endpoint that does the real backup-Job orchestration**, and (a follow-up) a break-glass menu peer that calls it while the API is alive.
## What it does
Adds `POST /api/v1/servers/{name}/backup`: an owner or admin snapshots a **stopped**
server's world into the archive store on demand, recorded as a first-class
`world_backups` row (reason `manual`) — restorable later by the existing restore path
and expired by the reaper's retention pass, so it never leaks as an orphan archive.
The backup runs asynchronously as a one-shot Kubernetes Job (the new
`internal/backupjob` package), mirroring how restore and image builds hand off to
Jobs. The handler answers **202 `backing_up`**.
## Why
felis-api cannot archive a world in-process: the world PVC is **RWO** and owned by the
operator's StatefulSet, so the API has nothing to mount at request time — the same
constraint that already makes `internal/restore` a Job. The break-glass console (which
runs direct-to-Postgres) likewise lacks the deployment coordinates (`FELIS_IMAGE`,
`FELIS_BACKUP_PVC`) needed to render the Job. Both point to the same home: the
orchestration belongs in felis-api, which holds those coordinates; other callers
invoke the endpoint.
## Design decisions
- **Backup Job self-records its `world_backups` row.** Unlike the restore Job — which
is deliberately DB-blind because it processes a potentially poisoned archive — the
backup Job **does** mount the felis config Secret and inserts its own backup row,
exactly like the reaper (the only other component holding both a world mount and the
database). This avoids the archive-then-async-record split that would otherwise leak
orphan archives on a crash. The security review for that one departure lives in
`internal/backupjob/jobspec.go` and is frozen by `jobspec_test.go`. Rationale: a
backup only **reads** a world the operator already owns and tars it (bytes, never
executed), so restore's poisoned-input threat does not apply; its blast radius (DB +
two PVCs) is a strict subset of the reaper's, and it never deletes a PVC nor calls
the K8s API (SA token stays un-mounted).
- **World mounted read-only, backup PVC read-write** — the mirror image of restore.
- **Stopped-gate (409 `not_stopped`).** The world PVC is RWO and held by a running
server, so a backup Job cannot double-mount it; the handler refuses unless the server
is fully stopped (`info.Ready || DesiredState != Stopped`). This also guarantees a
quiescent, non-torn archive. Mirrors `handleRestoreBackup`'s gate.
- **Authorization is restore's front half, minus the former-owner match.** Backup is
initiated by the **current** owner and records **their** ownership, so there is no
prior owner's data to leak — the leak guard that restore needs does not apply here.
An admin may back up an unowned (released) world; the recorded former owner is then
empty, exactly as the reaper records for an unowned reap.
- **Unique Job name per request.** Each backup Job is named `backup-<server>-<rand>`,
not a deterministic `backup-<server>`. A deterministic name would collide with a
just-finished Job still inside its `TTLSecondsAfterFinished` window (10m), and the
`AlreadyExists → 202` path would then silently produce **no** archive — the exact
window a user (or the console "立即备份" button) retries in. Unique names make every
request produce its own archive; `ErrAlreadyExists` remains only as a defensive
no-op on the ~impossible suffix collision. Ceiling (documented in `backup.go`): two
truly simultaneous taps may schedule two backup Pods — both mount the world PVC
read-only, so neither corrupts anything; single-flight-on-running is the upgrade
path if a double-tap storm ever appears.
- **One retention clock.** The entrypoint reuses the reaper's `reaperConfig` derivation
so a manual backup expires on the same schedule as an inactivity backup — one policy,
not two. The `"bk-"+hex` id scheme also matches, so manual and inactivity backups are
indistinguishable downstream.
- **Fail-safe on record failure.** If the row insert fails, the entrypoint deletes the
just-written archive so a failed backup leaves no unrecorded bytes.
- **Optional executor, honest 503.** Wired only when `FELIS_IMAGE` + `FELIS_BACKUP_PVC`
are supplied (same gate as restore); otherwise `API.Backuper` is nil and the endpoint
returns 503 `backup_unavailable`, so the authorization boundary is exercised before
the Job executor is deployable.
## Files
| File | Change |
|---|---|
| `internal/backupjob/jobspec.go` | **new** — `BackupJob` renderer + `BackupJobName`; weak SA, token off, hardened container, world RO / backup RW, config-Secret mount |
| `internal/backupjob/backup.go` | **new** — `Backuper` (idempotent enqueue) + `Config`/`withDefaults` |
| `internal/backupjob/k8sjobs.go` | **new** — controller-runtime `CreateBackupJob` (AlreadyExists → idempotent) |
| `internal/backupjob/jobspec_test.go` | **new** — freezes the Job's security shape incl. the deliberate config-Secret mount |
| `internal/backupjob/backup_test.go` | **new** — asserts each `Backup` call mints a unique Job name (repeat-tap must not silently no-op) |
| `cmd/felis/backup.go` | **new** — `felis backup` in-Pod entrypoint: archive + self-record + orphan-cleanup |
| `cmd/felis/run.go` | dispatch `case "backup"` + usage line |
| `internal/api/backuper.go` | **new** — the narrow `Backuper` port |
| `internal/api/handlers_backups.go` | **+`handleBackupNow`** |
| `internal/api/api.go` | `Backuper` field + `POST /servers/{name}/backup` route |
| `internal/api/handlers_backup_now_test.go` | **new** — `fakeBackuper` + handler subtests |
| `internal/api/backuper_wire_test.go` | **new** — compile-time `Backuper = (*backupjob.Backuper)(nil)` |
| `cmd/felis/api.go` | wire `backuper` under the `FELIS_IMAGE`+`FELIS_BACKUP_PVC` gate; `backupConfig` helper |
| `docs/openapi.yaml` | document the `backupNow` operation |
## Verification
WSL oracle (go1.26.4, authoritative for Go):
```
go build ./... && go vet ./... && go test ./... → ALL GREEN
```
The `internal/api` OpenAPI served-route contract test (`TestOpenAPIMatchesServedRoutes`)
initially failed — the new route was served but undocumented — and passes after adding
the `backupNow` operation to `docs/openapi.yaml`. `internal/backupjob` and the new
handler subtests pass. The controller-runtime `K8sJobs` binding is integration-only
(needs a live cluster) and is exercised only by the interface conformance test.
## Self-review outcome
- **ponytail (over-engineering):** the backup Job is a near-mirror of the restore Job,
not a shared parameterization — deliberate, because its security shape differs (it
holds DB creds) and must be asserted independently, not hidden behind a shared knob.
No speculative config; `Config.withDefaults` fills only real deployment values.
- **correctness:** the RWO stopped-gate and the self-recording atomicity were traced to
the reaper and restore before writing; the former-owner asymmetry vs restore is
justified above.
@@ -1,155 +0,0 @@
# Test-quality integrity audit — do the verifications verify FUNCTION, or just go green?
- **Type:** audit / verification evidence (no code changed)
- **Date:** 2026-07-07
- **Method:** mutation testing on the WSL oracle (go1.26.4) + per-function coverage backbone
- **Scope:** the load-bearing safety invariants the backfilled change ledger *claims* were tested
- **Tree state:** every mutation reverted; authoritative Windows-git working tree clean at `5a7cd5a`
- **Point verified:** each gate is broken at `HEAD` (`5a7cd5a`), not per-commit — this is the
right reading of "does the verification verify the FUNCTION": the current test pins the
current implementation. A per-commit sweep would audit history hygiene, a different question.
## Why this audit exists
The ledger backfill asserts, per subsystem, that a set of load-bearing safety
properties are "unit-tested". A passing suite proves the tests are GREEN; it does
not prove they would go RED if the behaviour broke. Those are different claims —
"passing ≠ verifying". This audit closes that gap the only way that earns the word
*verified*: **break the implementation, confirm the specific test turns red.** A
subagent (or a human) *reading* a test and judging it "looks thorough" reproduces
the exact error being audited (looks-right ≠ verifies), so reading was used only to
locate the gate line; the verdict is always the mutation result.
## Result: 18 / 18 crown-jewel invariants mutation-verified
Each row is a one-line break of the implementation, run against its own package on
the oracle. **CAUGHT = the suite went red** = the test genuinely pins the behaviour.
| # | Invariant (claimed tested) | Impl gate mutated | Verdict |
|---|---|---|---|
| 1 | Pinned component is NEVER changed | `plan.go` pin branch → fall through | CAUGHT |
| 2 | A downgrade is NEVER proposed | `plan.go` `lv.After(current)` → `true` | CAUGHT |
| 3 | A prerelease is NEVER auto-applied | `plan.go` `!lv.IsPrerelease()` → `true` | CAUGHT |
| 4 | Apply ONLY inside the SysAdmin window | `plan.go` `Window.Contains(now)` → `true` | CAUGHT |
| 5 | `After` is strict (no equal-version churn) | `version.go` `> 0` → `>= 0` | CAUGHT |
| 6 | Clone-warned assertion refused fail-closed | `handlers_passkey.go` `if va.CloneWarning` → `if false` | CAUGHT |
| 7 | Approval CAS builds exactly once | `submit.go` `if !won` → `if false` | CAUGHT |
| 8 | An `everyone` base is not fail-open | `cfsetup.go` `if !includeHasEveryone` → `if true` | CAUGHT |
| 9 | Scoped-identity recognition actually admits | `cfsetup.go` `scoped = true` → `scoped = false` | CAUGHT |
| 10 | SSE per-principal stream cap holds | `api.go` cap-disable threshold | CAUGHT |
| 11 | OTP atomic reserve → one winner per burst | `api.go` `Sub(last) < window` → `< 0` | CAUGHT |
| 12 | Idle server is auto-stopped | `reconciler.go` `AutoStopEnabled &&` → `false &&` | CAUGHT |
| 13 | Startup/readiness timeout fires | `reconciler.go` `>= timeout` → `>= timeout + 1h` | CAUGHT |
| 14 | `/readyz` 503s when DB/K8s is down | `handlers_internal.go` dep-check `err != nil` → `false` | CAUGHT |
| 15 | NodePort fence only fires once connector serves | `tui_edge_apply.go` `connectorConnCount` parse-fail `return 0` → `1` | CAUGHT |
| 16 | CRITICAL-CVE build is NEVER admitted | `build.go` scan-gate `JobFailed`→`StatusFailed` → `StatusSucceeded` | CAUGHT |
| 17 | A user can NEVER claim a reserved system name | `naming.go` `reserved[name]` → `reserved["__nomatch__"]` | CAUGHT |
| 18 | Service token reaches ONLY the login pod | `builders.go` `Name == SystemLoginServer` → `true` | CAUGHT |
Rows 15–18 close the gap a review of this audit surfaced: the first pass verified a
*subset* and worded the verdict as the whole set. They are the four remaining
load-bearing safety properties the ledger docs name as "unit-tested" (§ *Documented-tested
claim reconciliation* below). Each mutation produced a real `--- FAIL` on the specifically
named test — e.g. #15 reddened `TestConnectorConnCount/garbage_is_not_a_healthy_tunnel`,
#16 `TestSyncFailedDoesNotAdmitImage`, #18 `TestBuildEnvWithholdsServiceTokenFromUserServers`
— i.e. an assertion failure, not a compile break.
Not one crown-jewel test was vacuous. The `cfsetup` fail-closed test additionally
feeds five distinct *violating* policies (bare-everyone, everyone-OR-identity,
unrecognized `ip` type, empty rule, wrong decision) and asserts each is rejected —
strong negative-path coverage, confirmed by mutations #8–#9.
## Coverage backbone — what no oracle test executes (failure-mode B)
Coverage triages code that no test even runs (so it cannot be verified). It does NOT
itself earn "verified" — high coverage with weak asserts is the same green-number
trap. Per-function scan of the security packages:
**Integration-only by design (0% on the oracle — honest, NOT a gap).** The real
adapters run only against live infra; unit tests exercise the ports through fakes:
- `pgrepo.go` — all SQL, **including `QuotaAvailable`/`QuotaCheck` (the quota TOCTOU
atomic claim)**. This matches task #45's own "ENV-blocked" note: the atomic claim
is a Postgres `INSERT … WHERE`, verifiable only against a real DB.
- `k8scluster.go`, the K8s console/log-stream adapters — real Kubernetes/RCON I/O.
- `tui_edge_apply.go` `verifyConnectorServing` + the nftables fence apply — shell out to
live `cloudflared`/`nft`. **Correction from the first pass:** the doc splits this from the
*pure* `connectorConnCount` decision gate, which IS unit-tested and is now mutation-proven
(#15). The first pass wrongly folded the whole fence into "integration-only"; only the live
calls are. The gate that decides *whether* to fence is verified.
**Genuine coverage gap (untested at the HTTP layer — "not verified").** These are
*missing* tests, not fake-passing ones:
- `handlers_users.go` — the P5 SysAdmin account-management suite: `handleCreateUser`,
`handleGetQuotas`, `handleSetQuotas`, `handleListUsers`, `handleGetUser`,
`handlePatchUser`, `handleDeleteUser`, `handleDisableUser`, `handleLinkAccount`,
`handleUnlinkAccount`, `handleListUserSessions`, `handleRevokeUserSessions`, and
`validateUsername`. All 0%; no `handlers_users*_test.go` exists. (The adjacent
`DELETE …/passkeys` remediation handler *is* tested by `TestUnbindUserPasskeys`.)
- `handleReady` — the internal-face "server is up" push (distinct from the tested
`handleReadyz`); 0%.
These handlers are owner/operator-role-gated, so the blast radius is bounded, but
`validateUsername` is load-bearing input validation and is the highest-value target
for a follow-up test. **Recommendation:** add an `handlers_users_test.go` covering
create/quota/link + `validateUsername` negative paths. Filed as a proposed change,
not made here (this is a read-and-verify audit — no test/impl was modified).
## Cheap tells (static pre-pass)
- 3 `t.Skip` sites, all benign: RNG-collision reruns (a 1-in-10^6 OTP code clash),
not coverage-gating skips.
- No test file falls below 2 assertions per test function.
## Documented-tested claim reconciliation
To avoid the subset-verified/whole-worded trap a second time, every "unit-tested"
string in the ledger docs was enumerated (`grep -niE "unit-tested" docs/changes/*.md`)
and mapped to a verdict — verified fail-open gates get a mutation; behavioural/contract
claims are scoped, not silently dropped:
| Doc claim | Verdict |
|---|---|
| modpack: approval CAS builds once | mutation #7 |
| modpack: **scan in front of any push** | mutation #16 |
| cloudflare: `validateFailClosed` refuses public policy | mutations #8–#9 |
| cloudflare: **conn-count fence gate** | mutation #15 |
| system-servers: **naming reservation** | mutation #17 |
| system-servers: **service-token → login pod only** | mutation #18 |
| operator: idle stop / startup+readiness timeout / `/readyz` | mutations #12 / #13 / #14 |
| auto-update: pin / no-downgrade / no-prerelease / window / strict-`After` | mutations #1–#5 |
| passkey: clone-warned assertion refused | mutation #6 |
**Scoped, NOT individually mutation-proven** (behavioural/contract-level, not fail-open
safety gates — they rest on the green suite + the coverage backbone, and are called out here
rather than folded into the verdict):
- console-auth: content-type guard, anti-enumeration, forced-change lockdown. Anti-enumeration
is the one with security weight; the current public login door is email-OTP/passkey, and its
anti-enumeration behaviour is a candidate for a future mutation pass.
- break-glass setup: owner-auth match/non-match over the fake store.
- username-reclaim + auto-update JSON round-trip: in-memory repo contract / serialization.
## Toolchain honesty
Only Go runs on the oracle. The felis-limbo plugin (Java) is podman-verified against
a real Limbo jar (#65); the limbo/lobby images carry a build+boot check (#63). Those
completions were never a green-Go-tests claim and are not audited as if they were.
## Verdict
The commit history's verification claims are **accurate**: all 18 load-bearing *fail-open
safety gates* the ledger names as tested — spanning every subsystem, reconciled one-for-one
against the docs' "unit-tested" claims above — are mutation-proven to pin behaviour, not
merely to pass. No crown-jewel test was vacuous. The shortfalls are (a) integration seams
unrunnable on the oracle by design (honestly classified — including the live
`cloudflared`/`nft` fence-apply, whose *decision* gate is nonetheless verified); (b) one
untested cluster of admin user-management handlers — a missing test, not a false green; and
(c) a residue of behavioural/contract-level "unit-tested" claims (console anti-enumeration,
break-glass owner-auth, repo/JSON contracts) that rest on the green suite plus coverage and
are scoped above rather than individually mutation-proven — the honest boundary of this pass.
**Method note.** The first pass mutation-verified 14 gates but worded its verdict as "every"
invariant; a review caught that 4 documented safety gates (fence, scan, naming, service-token)
were named-as-tested yet unverified, and one (the fence) was mis-classified as integration-only.
Those four are now mutation-proven (#15–#18) and the classification corrected. The lesson is
the audit's own thesis turned on itself: *reading a scope and judging it complete* reproduces
the *looks-right ≠ verifies* error — only the enumerate-and-mutate reconciliation earns the word.
@@ -1,129 +0,0 @@
# Adversarial input-validation audit — every dangerous sink fails closed
- **Type:** negative-path / input-validation audit (no production code change)
- **Date:** 2026-07-08
- **Area:** `internal/api`, `internal/naming`, `internal/submit`, `internal/rcon`
- **Task:** a different question from the #82 / round-2 mutation audits. Those asked
*do the TESTS catch a gate regression?* This asks *does the CODE reject hostile
INPUT, or does bad data PASS?* — feed the real endpoints malformed, boundary, and
hostile bodies (故意加错误数据) and confirm they fail closed (4xx) rather than letting
the garbage reach a sink.
## Method — sink-first, not fuzz-everything
The low-hanging garbage (oversized body, unknown field, wrong content-type) is already
caught by the universal body guards, so a blanket "fuzz all ~80 handlers" would burn
effort where the answer is known. The real "can bad data PASS?" risk lives at the
**sinks** — the few places a request string is concatenated into an RCON command, used
as a K8s object name, joined into a filesystem/archive path, put in a SQL query, or
accepted as an enum/quantity **without a validator in front**. So the audit traces each
dangerous sink class from its handler entry to the sink, and for the crown-jewel class
(text → RCON) **mutation-verifies** the guard is non-vacuously pinned: loosen the guard
in source, run the package tests, confirm the specifically-named negative test reddens
(`--- FAIL: <subtest>`), then revert. Oracle: WSL Fedora-44, go1.26.4.
## The universal belt (caught before any sink)
`decodeJSON` (`internal/api/util.go`) wraps every body in
`http.MaxBytesReader(w, r.Body, 1<<20)` (1 MiB cap), sets `DisallowUnknownFields()`, and
rejects trailing data after the first JSON value — all → `400 bad_request`.
`requireJSONContentType` returns `415 unsupported_media_type` on credential writes
(a CSRF belt). So oversized, unknown-field, multi-document, and wrong-type bodies never
reach a handler body at all.
## The dangerous sinks — each traced fail-closed
### 1. Text → RCON (the lead). Two vectors, both fenced.
**(a) Structured commands** — `handlers_access.go` (LuckPerms permission/group,
whitelist/ban/kick). Every operand is validated against an anchored allow-list charset
*before* it is concatenated: `mcNameRe = ^[A-Za-z0-9_]{1,16}$` (player),
`lpNodeRe = ^[A-Za-z0-9_.*-]{1,64}$` (node), `lpCtxRe = ^[A-Za-z0-9_-]{1,48}$`
(world/group). No space, separator, or control character can appear in a validated
operand, and **there is no free-text field anywhere** — a ban/kick deliberately carries
no reason string (that would be the one splice vector). Go's `$` is `\z` (absolute end,
not `\Z`), so even a single trailing `\n` is rejected.
*Mutation-verified:* loosening `mcNameRe` to admit a space
(`^[A-Za-z0-9_ ]{1,16}$`) reddens
`TestAccessInjectionRejected/{whitelist,ban,kick,permission}_player_space` — the guard
is real, not vacuous.
**(b) Free-text passthrough** — `handlers_console.go` `handleCommand`, POST
`/servers/{name}/command`. This is the *one deliberate* free-text → RCON vector, and it
is **owner/admin-gated** (403 for a stranger, 404 for an unknown server). Its input
fence: trim + strip a single leading `/`, reject empty, cap at 1024 bytes, and
`strings.IndexFunc(command, func(c rune) bool { return c < 0x20 }) >= 0 → 400` — every
C0 control (incl. `\n`) is rejected so one request cannot splice a second command.
*Mutation-verified:* disabling the scan (`c < 0x20` → `c < 0x00`) reddens
`TestConsoleCommand/control_character_(newline)_->_400,_no_RCON_call`, whose input is
literally `{"command":"say hi\nop attacker"}` and whose assertion is 400 **and**
`console.calls == 0`. (Severity note: because this vector is owner-gated by design, the
scan is an audit-integrity measure — one request = one command — not a privilege
boundary; a splice on your *own* server escalates nothing, since the owner may already
run any RCON command. The fence exists regardless.)
### 2. Break-glass / internal-face (the newest code — scrutinised specifically)
This surface runs under a "service-token-authed / local-root, inputs trusted" posture,
the classic place a field-level guard gets skipped. Traced end to end:
- **Server name** — every internal handler (`handleReady`, `handleJoinEvent`,
`handleInternalWake`, `handleInternalClaim`, `handleInternalMenuStatus`) validates the
path name with `naming.ValidateServerName` before use.
- **`mc_uuid`** (join/wake/claim bodies) — checked non-empty, then flows *only* to
DB-parameterized calls (`RecordJoin`, `UserByMCUUID`, `UUIDInAllowlist`). The
"allowlist" is a **DB table**, not a live RCON `whitelist add` — there is no
`mc_uuid` → RCON path.
- **Break-glass "OP-create"** (`performAddOperator` → `provisionOperator` →
`InsertOperator(ctx, id, username, email)`) is a **parameterized DB INSERT** creating a
*panel staff account*, **not** a Minecraft `op` RCON command. The hypothesised
name → RCON `op` sink was checked and **does not exist** in this shape; the username is
`TrimSpace`d and reaches only `$N`-parameterized SQL, from a local-root caller.
- **`os_user`** (internal backup attribution) — `TrimSpace`d, sets only the audit actor
(a DB row); shown non-vacuous in the round-2 backup/restore audit (`b7b4a3b`).
### 3. SQL injection — dismissed.
`pgrepo.go` uses uniform `$1/$2/$3` parameterization throughout
(`QueryRowContext`/`ExecContext(ctx, q, args…)`); no request string is `Sprintf`'d into a
query.
### 4. Path traversal (submit) — dismissed.
`internal/submit` validates the submission id (rejects `..`, path separators, uppercase,
space, empty), and the on-disk blob name is a **fixed** constant (`contextBlobName`) — no
attacker-supplied filename is ever joined. The hostile-id matrix
`{"../evil","sub/../../etc","SUB-UPPER","has space","","a/b"}` is test-pinned in both the
local and S3 backends. The one free-form field a submission carries (`DisplayName`) is
charset-constrained by `displayNameRE` and rejects control chars
(`submit_test.go` "control chars" case).
### 5. K8s object names — validated at every cluster write.
`ValidateServerName` / `ValidateSystemServerName` (`^[a-z0-9-]{3,32}$`, no
leading/trailing dash, reserved-name set) and `ValidateHostname`
(`dnsLabelRE`, single label under the configured root domain) gate every create/patch.
`cluster.go` documents the invariant: "there is no free-form YAML path — every field is a
typed, validated value," so no raw CRD field can be smuggled through a create/patch body.
### 6. Numeric / enum — fail-closed.
`resolveResources` routes **every** quantity (memory, `resources.cpu/memory`, requests)
through `parsePositiveQuantity`, which rejects `q.Sign() <= 0` (negative *and* zero) with
a field-named 400, plus a request>limit guard; `parseStorageSize` carries the same guard.
Enums are closed sets: `parseAutostartPolicy` (ownerOnly/public/allowlist), the
access-action switch, and the image `ImageAdmitted` allow-list (no free image string).
## Verdict
**Bad data does not pass.** Every dangerous sink is fail-closed — including the
break-glass / internal-face surface, where the hypothesised `mc_uuid`/OP-create → RCON
paths were traced and found not to exist (parameterized DB, not RCON). Both text → RCON
vectors — structured (`handlers_access`) and free-text (`handleCommand`) — are
mutation-pinned by their named negative tests. No gap was found and no production code
changed; the honest result of "故意加错误数据" is that the input surface rejects it.
Scope is deliberately bounded to the dangerous sinks and the newest (break-glass) code,
not an exhaustive fuzz of all ~80 handlers — the claim proven is "every place user input
reaches a dangerous sink validates before the sink," by trace plus two mutations, not
"every handler was fuzzed."
@@ -1,57 +0,0 @@
# §B4 phase close — S3 archive backend deferred by design (decision record)
- **Type:** decision / scope record (no code changed)
- **Date:** 2026-07-08
- **Area:** `internal/config`, `internal/backup` — the archive-store backend selection
- **Task:** #31 Phase B4 break-glass ops — **closes the phase**, superseding the phase-2b
note in [break-glass-backup-peer](2026-07-07-break-glass-backup-peer.md) ("S3 remains
open in B4; this does not close the phase").
## Decision
The three operational B4 break-glass ops are built, wired into the recovery console menu,
and oracle-verified:
| Op | Menu enum | Commit |
|---|---|---|
| Provision/reset Owner | `bgProvisionOwner` | (Phase B1 lineage) |
| Add Operator ("OP create") | `bgAddOperator` | recovery-console menu |
| Halt a running server | `bgHaltServer` | `c2ee21a` |
| Back up a world now ("Sync") | `bgSyncBackup` | `7a7c0d5` / `f2fc57c` / `fc748d3` |
The fourth B4 line item — **S3 archive backend (`tarS3`)** — is **deferred by design**, not
left as a silent gap. It is closed as a documented deferral and **#31 is done**.
## Why deferring is safe (not a loose end)
- **Fail-closed at config load, frozen by a test.** `config.Load` rejects
`store = "tarS3"` (and `volumeSnapshot`, `longhorn`) with an error that points the
operator at the `tarLocal` remediation. `TestLoadRejectsUnimplementedArchiveStore`
freezes exactly this: a config naming an unimplemented backend fails at load, so
felis-api can never boot green while the reaper CronJob fails every run and restore
silently 503s. tarS3 cannot be selected into a broken state.
- **Peer to two other deferred backends.** `tarS3` sits beside `volumeSnapshot` and
`longhorn` as recognized-but-unimplemented store names. The spec's own phasing is
tarLocal-first ("起步 `tarLocal` … 要异地/跨集群 → `tarS3`"): the baseline single-node
path is `tarLocal` (tar → backup PVC), which is implemented, tested, and the backend
every built backup/restore path uses today.
- **Offsite/cross-cluster DR is the only capability gap**, and it is opt-in future work,
not a correctness hole in the shipped baseline.
## The build path, when offsite DR is wanted
`minio-go/v7` is already vendored (the modpack upload lane's `internal/submit/s3store.go`),
so tarS3 adds no dependency. A future build is bounded:
1. `internal/backup/tars3.go` — a `WorldArchiver` reusing the existing package-level
`writeTarGz`/`readTarGz`/`pruneToManifest`, streaming the tar to an object via
`PutObject` (size −1, multipart) and reading it back via `GetObject`, mirroring
`s3store.go`'s fakeable-client testability.
2. Store-selection factory in the backup/reaper entrypoint (`store = "tarS3"` → construct
the minio-backed archiver) + remove tarS3 from the config fail-closed list (update
`TestLoadRejectsUnimplementedArchiveStore` to keep only volumeSnapshot/longhorn).
3. Inject the S3 Secret into the backup/reaper Job Pods (integration wiring, like the
Kaniko S3-context credential path).
The live-S3 upload + Secret-into-Pod would be integration-only verified, exactly as the
modpack S3 backend and the pgrepo SQL are.
@@ -1,93 +0,0 @@
# Backup/restore data-safety mutation audit (round 2) — 7 gates pinned, 1 gap closed
- **Type:** test-quality audit + one test added (code change: `internal/api/handlers_backups_test.go`)
- **Date:** 2026-07-08
- **Area:** `internal/api` (`handlers_backups.go`), `internal/backupjob`
- **Task:** continues the #82 test-quality integrity audit onto the on-demand
backup/restore surface, which postdates the 18-gate round-1 audit
([mutation audit `4626ab5`](2026-07-07-test-quality-mutation-audit.md)). The
backup/restore endpoints (`7a7c0d5` / `f2fc57c` / `fc748d3`) were not in that pass.
## Method
Same as round 1: apply a one-line mutation to a fail-open gate in the source, run the
package tests, confirm the **specifically-named** test reddens with an *assertion*
failure (`--- FAIL: <subtest>`), then revert. A build break (`declared and not used`,
`undefined`) is not a valid verdict, so mutations are operator-flips that keep every
operand referenced (`!=`→`==`, drop a `!`, a literal→`true`, or `if false && <orig>` to
disable a gate without orphaning its variables). Airtightness: each mutation is re-run
with `-run` scoped to the intended subtest and `grep -- "--- FAIL: <subtest>"`, so a
reddening sibling can't be mistaken for the gate under test. Oracle: WSL Fedora-44,
go1.26.4.
## Fail-open gates mutation-verified (all CAUGHT at the named subtest)
| # | Gate (file:line) | What it guards | Mutation | Subtest that reddened |
|---|---|---|---|---|
| A | `handlers_backups.go:282` enqueueBackup stopped-gate | RWO double-mount / torn archive while the world is up | `!=`→`==` | `TestBackupNow/starting_server_->_409_not_stopped` |
| B | `:151` restore stopped-gate | restore Job can't mount a live world's RWO PVC | `!=`→`==` | `TestRestoreBackup/starting_server_->_409_not_stopped` |
| C | `:136` restore former-owner match | a fresh claimant resurrecting the previous owner's world | `!=`→`==` | `TestRestoreBackup/current_owner_who_is_not_former_owner_->_403` |
| D | `:116` restore cross-server guard | restoring server A's backup onto server B | `!=`→`==` | `TestRestoreBackup/restore_by_backup_id_cross-server_->_403` |
| E | `:214` backup owner-or-admin authz | a stranger backing up someone else's world | drop `!` | `TestBackupNow/non-owner_->_403,_no_backup` |
| F | `:26` list cross-user scope | a user seeing other tenants' backups | `p.IsAdmin()`→`true` | `TestListBackups/user_sees_only_own_former-owned_present_backups` |
## The gap this audit found — and closed
**Restore's owner-or-admin gate (`handlers_backups.go:85`) was not pinned by any test.**
It is the twin of gate E, but the two are *not* symmetric. Disabling it
(`if false && !a.isOwnerOrAdmin(p, rec)`, which keeps `rec` referenced so the package
still builds) reddened **nothing** — `go test ./internal/api/` stayed `ok`. The same
disable applied to backup's L214 (gate E) reddened `non-owner` immediately, proving the
technique valid and the asymmetry real.
Root cause: the former-owner gate at L136 backstops every non-owner case the suite
exercised (a stranger and a wrong-backup current owner both fail L136 *and* L85, so
L136's 403 masks a broken L85). The one case only L85 catches went untested: a
**superseded former owner** — a user who owned a server, took this backup
(`FormerOwner=them`), then released it to a *new* owner. They still pass L136 (they *are*
the former owner) but must be stopped by L85, or they could roll the new owner's live
server back onto their old world (cross-tenant clobber). The handler comment names this
the "must re-claim first" rule (`handlers_backups.go:77-79`).
**Fix (code):** added `TestRestoreBackup/former owner after release -> 403, no restore`,
the mirror of the existing L136 test. Verified both directions: green on the clean tree,
and it is the sole subtest that reddens when L85 is disabled — so it now pins the owner
gate specifically, not L136. No production code changed; `handlers_backups.go` is a pure
test addition away from where it was.
## Enumeration — covered vs. scoped (so "the gates" means all of them)
- **Fail-open data-safety gates — all pinned:** A–F above, plus L85 (now closed). 7/7.
- **Accountability, not fail-open (verified non-vacuous):** the `os_user` attribution at
`handlers_backups.go:254` — a supplied operator name overrides the default `break-glass`
audit actor. Mutating `u != ""`→`u == ""` reddens
`TestInternalBackup/os_user_body_attributes_the_audit_to_the_operator`, so the
attribution test isn't vacuous. A failure here degrades the audit actor; it grants no
bypass, so it is out of the fail-open bucket.
- **Contract/behavioral (tested, out of mutation scope):** nil `Backuper`/`Restorer` → 503;
a failed backup/restore → 500 **not** audited; `backup_ref` never serialized to the wire;
the internal-face actor defaults to `break-glass`. Each has a direct test; none is a
fail-open safety gate.
- **`internal/backupjob` (glanced, not mutated):** orchestration only — each backup gets a
unique Job name (`BackupJobName` + random suffix) so a repeat "立即备份" tap can't collide
with a just-finished Job still inside its TTL; `ErrAlreadyExists` is a defensive no-op;
`Backup` returns once the Job is created (the async 202 is honest). Unit-tested against a
fake `Jobs`; the controller-runtime `k8sjobs.go` and the `jobspec.go` Pod shape (weak SA
with its token un-mounted, config Secret mounted for the self-recorded row, read-only
world mount) are integration-verified per the package doc — not fail-open API gates.
## Coverage nuance (documented, not a gate failure)
The stopped-gate is `if info.Ready || info.DesiredState != DesiredStopped`. The `!=`→`==`
mutation pins the `DesiredState` operand (both A and B reddened), but `info.Ready` is not
*independently* pinned: no test sets `Ready=true` together with `DesiredState=Stopped` —
the stopping-but-still-up race. Low risk because in practice `Ready` drops as
`DesiredState` leaves `Stopped`, but the belt-and-suspenders `Ready` operand rides on
coverage of the operand beside it rather than its own case.
## Verdict
The backup/restore data-safety surface is a coherent unit, and this closes it: **7/7
fail-open gates pinned** (6 pre-existing, 1 added this round), one accountability gate
shown non-vacuous, one coverage edge documented. Not extended to every handler — that
would be an unbounded "continue the audit."
@@ -1,110 +0,0 @@
# Felis-nano — the federating `hasJoined` multiplexer (step 1: the Go resolver)
- **Type:** feature (new endpoint) — the verifiable "brain" of Felis-nano
- **Date:** 2026-07-12
- **Area:** `internal/api` (`handlers_hasjoined.go` + test), `internal/api/api.go`
(route + `AuthSources` field), `docs/openapi.yaml`, `go.mod`
- **Task:** Felis-nano provides a MultiLogin-like capability — one Velocity proxy that
accepts logins verified by **several** Yggdrasil auth servers at once (Mojang + N
third-party roots), 正版优先 (Mojang-first). This change builds **step 1**: the Go
`hasJoined` multiplexer that does the federating verification. It is the only part of
the plan that produces immediate verifiable hard evidence (a unit-tested HTTP endpoint);
the two delivery shells that point Velocity at it (a JVM `-Dmojang.sessionserver` flag,
and a thin reflection-hook plugin for third-party servers) are later steps.
## What Velocity asks for, and what this answers
On a Minecraft login Velocity's authlib computes the `serverId` hash and issues
`GET /session/minecraft/hasJoined?username=<name>&serverId=<hash>[&ip=<ip>]` against
whatever URL its `mojang.sessionserver` system property names. A 200 with a game profile
means "verified"; a 204 means "not verified" and authlib rejects the login. Vanilla points
this at Mojang alone. Felis-nano points it **here**, and this endpoint fans the same query
out to the configured Yggdrasil roots **in priority order**, returning the first source
that validates. Each upstream Yggdrasil runs its own `serverId`-hash check — the
multiplexer only relays, it computes no hashes.
## The one non-negotiable transform — per-source UUID namespacing
A third-party Yggdrasil's UUIDs are **self-asserted**: nothing stops a malicious source
from answering with a *genuine Mojang player's* UUID. If that UUID were emitted as-is, the
third-party could impersonate any Mojang player with full UUID fidelity — and the reclaim/
blacklist layer could never catch it, because its whole invariant is "the genuine Mojang
player has a **different** UUID from any squatter." That invariant would simply be false.
So the resolver rewrites every non-identity source's profile into a per-source namespace
**before it leaves the resolver** — the single entry point every login crosses:
```
canonical = UUIDv3(felisAuthNS, tag + ":" + nativeID) // third-party
canonical = the source's UUID verbatim // Mojang (Identity: true)
```
MD5 (UUIDv3) preimage resistance means no third-party can mint a value inside Mojang's
UUID space; the per-`tag` prefix means two sources can't collide onto one identity. Every
downstream key — `account_links`, `username_blacklist`, owner checks — then sees exactly
one canonical UUID per real identity, so the reclaim invariant is true **by construction**,
not by assumption.
## Fail-closed details that bite if wrong
- **Bar gate at the chokepoint.** The canonical UUID is checked against
`Repo.IsUsernameBlacklisted` *before* the profile is returned, so a reclaimed squatter
stays out even on a consumer that has no limbo plugin. Keyed on the **dashed** canonical
(`.String()`) — the exact form `Repo.ReclaimUsername` stores. A DB error there fails
closed (non-200 → authlib rejects), matching the existing `handleCheckBlacklist` pattern.
- **Emit undashed.** authlib's `GameProfile` expects the 32-hex undashed `id`
(`hex.EncodeToString(u[:])`); the DB/reclaim/blacklist keys are dashed. The resolver
**checks** on the dashed string and **emits** the undashed one. Mixing the two forms is a
silent gate miss — pinned by the tests below.
- **`properties` relayed verbatim** (`[]json.RawMessage`) so a source's signed textures
survive the multiplexer untouched.
- **Inert by default.** `AuthSources` is nil until `cmd/felis` wires configured sources,
so the endpoint 204s every login until deliberately configured — it ships off.
- **Public internal-face route.** authlib sends no service token, so the route is mounted
`Public: true` on the internal face (like `/healthz`); no third face is introduced. The
OpenAPI parity test enforces `x-felis-face: [internal]` + `x-felis-tier: public`.
## Files
| File | Change |
|---|---|
| `internal/api/handlers_hasjoined.go` | **new** — `handleHasJoined` + `resolveHasJoined` + `AuthSource`/`sessionProfile` types + `felisAuthNS` |
| `internal/api/handlers_hasjoined_test.go` | **new** — `TestHasJoined`, 7 subtests over `httptest` fake Yggdrasil roots |
| `internal/api/api.go` | `AuthSources []AuthSource` field (nil = inert) + `GET /session/minecraft/hasJoined` `Public` internal route |
| `docs/openapi.yaml` | `/session/minecraft/hasJoined` path — `x-felis-face: [internal]`, `x-felis-tier: public`, `security: []` |
| `go.mod` | promote `github.com/google/uuid` indirect→direct (first direct importer) |
## Verification evidence
Oracle: WSL Fedora-44, go1.26.4. `go build ./...` → `BUILD-OK`. Full `internal/api`
package green (`ok felis.lolicon.best/internal/api`), `go vet ./internal/api/` clean. The
full package (not a `-run` filter) was run because this change edits two shared surfaces —
the `API` struct and the `internalAPIRoutes()` table — where a route that isn't under
`/api/v1/` is exactly what a route-table-driven invariant test would trip; nothing
reddened.
`TestHasJoined` — 7 subtests, all PASS:
1. `mojang identity passthrough` — Mojang UUID unchanged, `properties` relayed.
2. **`thirdparty UUID rewritten, never emitted as-is`** — the security invariant: an evil
source returns real Notch's Mojang UUID; the resolver emits neither that UUID nor any
Mojang-space value, but the deterministic `UUIDv3(felisAuthNS, "littleskin:"+id)`.
3. `mojang priority wins over thirdparty` — Mojang-first ordering.
4. `fallthrough to thirdparty when mojang 204s` — priority scan continues past a 204.
5. `no source validates -> 204`.
6. `barred canonical UUID -> 204` — the reused reclaim bar gate holds at the resolver.
7. `missing username -> 204` — no source touched on a malformed query.
`TestOpenAPIMatchesServedRoutes` PASS — the new route's served facets match its
`docs/openapi.yaml` entry in both directions.
## Deferred (not in this change)
- **Source configuration** (step 2): a `tag`/`type`/`url`/`priority` schema and
`cmd/felis` wiring that populates `AuthSources`. Until then the endpoint is inert.
- **Delivery shells** (step 3): Shell 1 = the `-Dmojang.sessionserver` JVM flag on
Felis-managed proxies; Shell 2 = the thin reflection-hook Velocity plugin for
third-party servers, plus a Velocity verification runbook. The user has a real
server to test Shell 2 against.
- **Name-match / textures-signature enforcement** — deliberately out of scope: identity
is `tag:nativeID`, not the name, and textures are the cosmetic bucket relayed verbatim.
-255
View File
@@ -1,255 +0,0 @@
# Felis change ledger
The index of every functional change to Felis — what it did and which commit records
it. This is the durable, in-repo map that `git log` alone doesn't give: it links
substantial changes to their detail docs and flags work that is built and verified but
not yet committed.
## Convention
- **Every functional change** (a feature addition, a behaviour change, a bug fix) gets:
1. a dated detail doc in this directory — `docs/changes/YYYY-MM-DD-<slug>.md`, covering
_what it did, why, the files touched, and the verification evidence_; and
2. a row in the ledger below, carrying its **commit record** (the short SHA).
- A change that is **built and verified but not yet committed** (e.g. while PGP signing
is locked) sits in **Pending** with `commit: pending`, and moves into the ledger with
its real SHA once committed.
- Pure-cosmetic or non-functional commits (docs, style) still appear in the ledger table
for completeness, but do not require a dedicated detail doc.
- The ledger table is generated losslessly from git history and can be regenerated:
```
git log --reverse --pretty=format:'| %h | %ad | %s |' --date=short
```
## Pending (built + verified, not yet committed)
_None._
## Detail docs
Depth docs for substantial changes, keyed to the commit(s) they cover. The committed
ledger table below stays a lossless mirror of `git log` (so it can be regenerated); this
section is where a row's detail doc, when it has one, is found. Most rows — panel/UI,
docs, chore, style — have no detail doc by convention and are recorded by their table row
alone. Entries marked *(backfill)* were reconstructed retroactively on 2026-07-07 from git
history to close the ledger's detail-doc axis for the pre-convention functional commits;
each carries a backfill note stating it was not independently re-verified. Frontend/`panel`
commits are the collaborator's UI work and are not given detail docs here.
| Detail doc | Commit(s) | Scope |
|---|---|---|
| [foundational-subsystems](2026-06-26-foundational-subsystems.md) *(backfill)* | `7fbebfe` `708cdfc` `43ab921` `78b8cf6` `d39605e` `b508fcc` `47fcd90` `93f143f` | initial import: CRD, core libs, backup, operator, submit, api, platform, plugins |
| [modpack-submission-lane](2026-06-26-modpack-submission-lane.md) *(backfill)* | `d39605e` `598f3d3` | §8 modpack build/approval pipeline + local/S3 backends |
| [deploy-bootstrap-installer](2026-06-27-deploy-bootstrap-installer.md) *(backfill)* | `58fa4b0` `94a3b7b` `deaa2f8` `318a724` `e5f1682` `28c3eee` `c14ed17` `d9e866f` `b84debf` | one-line bootstrap installer + demo bring-up |
| [console-auth-passwordless](2026-06-27-console-auth-passwordless.md) *(backfill)* | `af14f02` `0c1cc59` `3b43f05` `c20b12c` | local-password login → passwordless migration + residue sweep |
| [felis-cli-break-glass-setup](2026-06-27-felis-cli-break-glass-setup.md) *(backfill)* | `e108a37` `2d0bbb0` `a94b001` `eb5875a` `f5d00f3` `9c46632` `7d91373` | break-glass recovery console + first-run setup + apply/migrate |
| [cloudflare-tunnel-access-edge](2026-06-27-cloudflare-tunnel-access-edge.md) *(backfill)* | `53a7664` `ba13839` `a531f5e` `2810fe8` `7d3be64` `346ec68` `e058a64` | §14 Tunnel + fail-closed Access edge + NodePort fence |
| [player-onboarding-b2](2026-06-27-player-onboarding-b2.md) *(backfill)* | `dbe34a1` `1f8b9bb` `116595f` `fe2ece0` `55592ed` `6c3999a` `879b177` | §B2 email-OTP, account-link, QR, Bind-Code + OTP throttle |
| [username-reclaim-b3](2026-06-27-username-reclaim-b3.md) *(backfill)* | `a29571d` `fdb6efb` | §B3 Mojang-priority reclaim + account migration |
| [felis-metrics](2026-06-30-felis-metrics.md) *(backfill)* | `75642d9` `2a93a9e` `79eae7f` `8ac5e64` | §23 felis_* Prometheus collectors |
| [felis-api-hardening](2026-06-30-felis-api-hardening.md) *(backfill)* | `7a51c1d` `164ac44` `c6c0772` `3c1d647` `d6e3189` `8f41a00` `6368ab1` `2a4a81b` `9873904` | audit #1–#3 + robustness fixes |
| [passkey-enrollment](2026-07-01-passkey-enrollment.md) *(backfill)* | `f2c916d` `742f15f` `0261204` `fce0fce` `7278cd7` `cdbb5ab` `9953275` `20e31fb` `54bc6ef` | WebAuthn enrollment + hardening a–e |
| [passkey-login](2026-07-01-passkey-login.md) *(backfill)* | `e035142` `ec468ba` `0dbd557` `9e1df12` `4f59d51` `a63f49d` | WebAuthn assertion/discoverable login + UA-guard |
| [auto-update-subsystem](2026-07-01-auto-update-subsystem.md) *(backfill)* | `c01f133` `3673af6` `7464fa7` `96b3cc9` `7d27640` `7db57b9` | update decision core + sources + gatherer + window API (report-only) |
| [system-servers-login-limbo-lobby](2026-07-02-system-servers-login-limbo-lobby.md) *(backfill)* | `9bed51b` `9ef817f` `159107b` `dc23cb5` `3fdb3d0` `f554d52` `241fe21` `c7315e4` | always-on login-limbo + lobby auth gate |
| [operator-idle-quota-readiness](2026-07-05-operator-idle-quota-readiness.md) *(backfill)* | `91bfa27` `e574749` `7f7e459` `7becb38` | idle auto-stop, quotas, timeouts, /readyz |
| [break-glass-halt](2026-07-05-break-glass-halt.md) | `c2ee21a` | §B4 break-glass halt-a-server op |
| [felis-migrate-command](2026-07-05-felis-migrate-command.md) | `c1aa38b` | §B3 `/felis migrate` account migration |
| [on-demand-world-backup](2026-07-07-on-demand-world-backup.md) | `7a7c0d5` | §B4 Sync phase 1 — external backup endpoint + Job |
| [internal-backup-endpoint](2026-07-07-internal-backup-endpoint.md) | `f2fc57c` | §B4 Sync phase 2a — internal-face backup endpoint |
| [internal-api-clusterip-service](2026-07-07-internal-api-clusterip-service.md) | `2ba9948` | felis-api internal-face ClusterIP Service |
| [break-glass-backup-peer](2026-07-07-break-glass-backup-peer.md) | `fc748d3` | §B4 Sync phase 2b — console backup peer |
| [adversarial-input-audit](2026-07-08-adversarial-input-audit.md) | `667c6d3` | adversarial input-validation audit — sink-first negative-path, two text→RCON guards mutation-pinned, no code change |
| [felis-nano-hasjoined-resolver](2026-07-12-felis-nano-hasjoined-resolver.md) | `ff550c4` | Felis-nano §B3 — federating hasJoined multiplexer + per-source UUID namespacing |
## Committed change ledger
Oldest first (project build order). Commit = short SHA on `main`. Frontend/`panel`
commits are the collaborator's UI work; backend (Go/Java/K8s) is tracked here as the
primary record.
| Commit | Date | Change |
|---|---|---|
| 5b7b38d | 2026-06-26 | chore: add Go module manifest and ignore rules |
| 5a30aa5 | 2026-06-26 | docs: add OpenAPI 3.1 served-route contract |
| 7fbebfe | 2026-06-26 | feat(apis): add MinecraftServer CRD types (v1alpha1) |
| 708cdfc | 2026-06-26 | feat(core): add naming, RCON, store, config, and image-build libraries |
| 43ab921 | 2026-06-26 | feat(backup): add backup, restore, and reaper subsystems |
| 78b8cf6 | 2026-06-26 | feat(operator): add MinecraftServer controller and reconcilers |
| d39605e | 2026-06-26 | feat(submit): add user modpack build and approval pipeline |
| b508fcc | 2026-06-26 | feat(api): add felis-api service with permissions, modpack lane, and fleet read |
| 47fcd90 | 2026-06-26 | feat(platform): add node orchestration and the felis entrypoint |
| eee00c2 | 2026-06-26 | feat(panel): add three-sided web console (User, Admin, SysAdmin) |
| 93f143f | 2026-06-26 | feat(plugins): add Velocity proxy and Fabric/Forge/NeoForge/Paper integration mods |
| ce0ba76 | 2026-06-27 | chore(api): add kubebuilder object-generation markers to v1alpha1 |
| 7d91373 | 2026-06-27 | fix(migrate): honor -config flag placed after the up verb |
| 99de43f | 2026-06-27 | chore: ignore plugin build artifacts and editor config |
| 58fa4b0 | 2026-06-27 | feat(deploy): add one-line bootstrap installer and container image |
| af14f02 | 2026-06-27 | feat(api): local-password authentication backend |
| e108a37 | 2026-06-27 | feat(cli): break-glass emergency console TUI |
| 885c4a9 | 2026-06-27 | feat(panel): local-password login and forced password change |
| 2d0bbb0 | 2026-06-27 | feat(cli): attribute break-glass recovery to the SysAdmin who runs it |
| dbe34a1 | 2026-06-27 | feat(api): add player email OTP verification (spec §B2 onboarding) |
| 1f8b9bb | 2026-06-27 | feat(api): record account-link auth source (mojang/thirdparty) |
| a29571d | 2026-06-27 | feat(api): reclaim squatted usernames for Mojang-priority players (spec §B3) |
| 53a7664 | 2026-06-27 | feat(cfsetup): recommended Cloudflare Tunnel + Access edge setup |
| ba13839 | 2026-06-27 | feat(breakglass): optional Cloudflare Tunnel + Access setup in the TUI |
| a5a6482 | 2026-06-27 | feat: dev mock |
| dd2fc6f | 2026-06-27 | feat(panel): i18n |
| 7b458da | 2026-06-27 | feat(panel): light/dark theme |
| 51c9eab | 2026-06-27 | docs: CONTRIBUTOR.md |
| 74e7e6e | 2026-06-27 | docs: CONTRIBUTING.md |
| b81b334 | 2026-06-27 | Merge branch 'main' of https://github.com/MliroLirrorsIngenuity/Felis |
| 9d13787 | 2026-06-27 | refactor(panel): dashboard |
| f3521e3 | 2026-06-27 | refactor(panel): uniform margins |
| 18b4be0 | 2026-06-27 | refactor(panel): uniform title icon styles |
| 665841b | 2026-06-27 | fix(panel): remove internal spec references from user-facing text |
| cf88bcc | 2026-06-27 | fix(panel): extract hardcoded security note into i18n keys |
| 061482d | 2026-06-27 | fix(panel): extract hardcoded Chinese text to i18n keys |
| cfe126f | 2026-06-27 | feat(panel): add RCON command input to server console |
| ae91133 | 2026-06-27 | style(panel): refine button styles with shadow, active scale, toned-down colors |
| e189265 | 2026-06-27 | refactor(panel): compact server card layout, denser grid |
| f4df3e2 | 2026-06-27 | feat(panel): pagination for server lists |
| b512a18 | 2026-06-27 | refactor(panel): adjust margins |
| a49443c | 2026-06-27 | refactor(panel): simplify sidebar |
| 86f2ae4 | 2026-06-28 | feat(panel): sidebar foot shows current account + sign-out; reorder Account cards |
| f5d00f3 | 2026-06-28 | feat(cli): implement felis apply command for direct CRD creation |
| 832b200 | 2026-06-28 | fix(panel): reactive system theme detection |
| c9cd9dc | 2026-06-28 | style(panel): unify dialog animation to fade and scale from center |
| 94a3b7b | 2026-06-28 | fix(deploy): harden bootstrap for RHEL-family Linux |
| 9c46632 | 2026-06-28 | feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency |
| 5450c26 | 2026-06-28 | chore: normalize line endings and apply formatting |
| deaa2f8 | 2026-06-28 | feat(deploy): add zypper support for openSUSE/SLES |
| 318a724 | 2026-06-28 | feat(deploy): add pacman support for Arch Linux |
| e5f1682 | 2026-06-29 | refactor(deploy)!: TUI |
| 28c3eee | 2026-06-30 | refactor(deploy): improved TUI walkthrough |
| 116595f | 2026-06-30 | feat(api): add QR scan-login completion poll on the internal face |
| a94b001 | 2026-06-30 | feat(deploy): add break-glass Operator account provisioning |
| 346ec68 | 2026-06-30 | refactor(deploy): improved cloudflare walkthrough |
| 563041a | 2026-06-30 | feat(panel): add fail-closed role-switcher view-mode logic |
| 50b8487 | 2026-06-30 | feat(panel): wire role-switcher into the app shell |
| 75642d9 | 2026-06-30 | feat(metrics): add named felis_* Prometheus collectors |
| 2a93a9e | 2026-06-30 | feat(metrics): record felis_image_build_failures_total on failed builds |
| 79eae7f | 2026-06-30 | feat(metrics): publish felis_servers_total from a fleet snapshot |
| 8ac5e64 | 2026-06-30 | feat(metrics): observe felis_start_duration_seconds across the start lifecycle |
| 676407d | 2026-06-30 | docs(diagrams): align §28 sequence diagrams with implemented routes |
| ac02c69 | 2026-06-30 | docs(troubleshooting): add operator failure-mode checklist |
| eb5875a | 2026-06-30 | feat(felis): add Operator break-glass op behind an operation menu |
| c14ed17 | 2026-06-30 | fix(docker): keep embedded panel/ and deploy/ in the image build context |
| 2a4a81b | 2026-06-30 | fix(api): don't burn wake cooldown when refused at capacity |
| 6c3999a | 2026-06-30 | fix(api): rate-limit email-OTP sends to close the email-bomb vector |
| 9873904 | 2026-06-30 | fix(operator): populate Status.Players from an RCON list probe |
| 879b177 | 2026-06-30 | fix(api): make OTP-start throttle atomic to close concurrent-burst bypass |
| 29f5341 | 2026-07-01 | docs(api): correct cooldownLimiter doc for its OTP reuse |
| 7507cfa | 2026-07-01 | Revert "feat(panel): wire role-switcher into the app shell" |
| f2c916d | 2026-07-01 | feat(api): add passkey enrollment persistence layer |
| 742f15f | 2026-07-01 | feat(api): add passkey enrollment endpoints |
| d2de11a | 2026-07-01 | feat(panel): fleet |
| 0261204 | 2026-07-01 | feat(passkey): add go-webauthn enrollment verifier adapter |
| fce0fce | 2026-07-01 | feat(passkey): wire enrollment verifier into felis-api |
| 2810fe8 | 2026-07-01 | fix(cfsetup): repoint stale DNS record when routing a tunnel hostname |
| 7d3be64 | 2026-07-01 | feat(cfsetup): start the tunnel connector as a setup step |
| a531f5e | 2026-07-01 | fix(cfsetup): keep connector install in the host apply layer only |
| e058a64 | 2026-07-01 | feat(edge): close the panel NodePort to the public after the tunnel is up |
| c01f133 | 2026-07-01 | feat(updates): add pure decision core for component self-update |
| fe2ece0 | 2026-07-01 | feat(api): add public Bind-Code onboarding for the player console |
| 3673af6 | 2026-07-01 | feat(api): add admin API for the SysAdmin-set auto-update maintenance window |
| 7464fa7 | 2026-07-01 | fix(updates): tag Window JSON so the persisted maintenance window round-trips |
| e035142 | 2026-07-01 | feat(passkey): add WebAuthn login/assertion crypto adapter |
| f34711c | 2026-07-01 | docs(api): record passkey login-handler deferral rationale |
| 7a51c1d | 2026-07-01 | fix(api): bound concurrent login bcrypt to shed CPU-pin floods |
| 164ac44 | 2026-07-01 | fix(api): validate inbound X-Request-Id before echo and audit persist |
| c6c0772 | 2026-07-01 | fix(api): set read/idle timeouts on the felis-api listeners |
| 3c1d647 | 2026-07-01 | fix(api): cap concurrent SSE streams per principal |
| d6e3189 | 2026-07-01 | fix(api): bound SSE relay writes with a deadline to sever stalled readers |
| 2c56d17 | 2026-07-01 | docs(api): record the quota-claim TOCTOU as a KNOWN-LIMITATION (audit #4) |
| 8f41a00 | 2026-07-01 | fix(api): clear the SSE write deadline on return so it can't leak to a reused connection |
| 15c58d9 | 2026-07-01 | feat(panel): player management |
| 8ae65ae | 2026-07-02 | feat(panel): backup management |
| a15ff55 | 2026-07-02 | refactor(panel): optimize player list layout and horizontal operations |
| 149f01a | 2026-07-02 | fix(panel): change console button to outline variant on my servers page |
| 4ecaf3c | 2026-07-02 | refactor(panel): set defaultOpen parameter of whitelist card to false |
| a9dbc8b | 2026-07-02 | feat(panel): add search and status filtering to my servers page |
| fa7bab5 | 2026-07-02 | style(panel): refine search and filter layout to align with header |
| 70d17a0 | 2026-07-02 | feat(panel): align my servers page search layout with fleet table |
| 5a8eff1 | 2026-07-02 | feat(panel): remove developer comment footer cards from my servers and server admin pages |
| 92770ea | 2026-07-02 | style(panel): adjust pagination padding to pt-3 for balanced spacing |
| 6e43a46 | 2026-07-02 | fix(panel): pin sidebar navigation and enable independent content scroll |
| 3b4298d | 2026-07-02 | refactor(panel): unify servers cockpit layout, resolve duplicate pages and adjust spacing |
| c0d333b | 2026-07-02 | feat(panel): support full server config edit dialog with status prefilling |
| 6368ab1 | 2026-07-02 | fix(api): coalesce MyServers owned flag so ownerless rows do not 500 |
| cdbb5ab | 2026-07-02 | fix(api): record credential id in passkey-register audit event |
| 9953275 | 2026-07-02 | fix(api): bound webauthn_challenges growth by superseding all prior rows |
| 20e31fb | 2026-07-02 | fix(store): cascade-delete passkeys and challenges on user removal |
| 7278cd7 | 2026-07-02 | feat(passkey): require and record user verification at enrollment |
| 54bc6ef | 2026-07-02 | fix(api): clear bound passkeys on password change to close a takeover foothold |
| 19f500b | 2026-07-02 | style(panel): update destructive red color and rename wake to start |
| e0bc288 | 2026-07-02 | feat(panel): implement image build pipeline and admin whitelist with mock dev api |
| 8594622 | 2026-07-02 | feat(panel): implement email OTP verification and passkey registration management |
| 9bed51b | 2026-07-02 | feat(config): add [velocity] login_image/lobby_image for system servers |
| 9ef817f | 2026-07-02 | feat(naming): system-server names, validation, and service-token identifiers |
| 159107b | 2026-07-02 | feat(api): HTTP readiness knob on MinecraftServer and login-gate fallback default |
| dc23cb5 | 2026-07-02 | feat(operator): system-server pod readiness probe and login service-token env |
| 3fdb3d0 | 2026-07-02 | feat(platform): internal API base-URL helper and single-sourced token secret |
| f554d52 | 2026-07-02 | feat(cli): provision login/lobby system servers with login env and token replica |
| a63f49d | 2026-07-02 | feat(panel): steer WeChat/QQ in-app browsers to the system browser for passkey |
| 241fe21 | 2026-07-02 | feat(limbo): felis-limbo in-game login flow over the shared account-link client |
| c7315e4 | 2026-07-02 | feat(deploy): login-limbo and lobby images with game-port pinning |
| 191640c | 2026-07-02 | feat(panel): implement admin submission approval and reject queue |
| 598f3d3 | 2026-07-02 | feat(submit): local + S3 backends for modpack upload contexts, installer-selectable |
| adf0d99 | 2026-07-02 | feat(panel): implement user-side modpack submissions with drag & drop context upload |
| d9e866f | 2026-07-03 | fix(deploy): make the lobby image actually build |
| b84debf | 2026-07-03 | feat(deploy): one-shot demo bring-up wrapper |
| 73d6ec1 | 2026-07-03 | feat(mock): add mock submissions for owner account |
| 5427bc7 | 2026-07-03 | feat(servers): support claiming servers directly from ServersPage list |
| 55592ed | 2026-07-03 | feat(auth): support public auth bind endpoint |
| 804459c | 2026-07-03 | feat(panel): implement admin maintenance window settings page |
| dd7dff6 | 2026-07-03 | feat(panel): support importing parameters from submission with owner-restricted unapproved entries |
| a8701c1 | 2026-07-03 | fix(panel): prevent automatic wake during server claim in mock api |
| f749c2c | 2026-07-03 | style(panel): resolve double borders and uneven padding in server console |
| 5f402b1 | 2026-07-03 | fix(panel): force dark mode and pure black bg on server console card |
| b7d8000 | 2026-07-03 | feat(panel): implement dedicated LuckPerms permissions and groups management sub-page |
| e60784b | 2026-07-04 | fix(panel): eliminate page collapse and scroll shifts during LuckPerms query reload |
| 439f19e | 2026-07-04 | fix(panel): prevent page collapse and scroll shifts in players and bans management sections during reload |
| 83e57b4 | 2026-07-04 | fix(panel): implement two-step confirmation for claiming a server to prevent accidental operations |
| 3347cc0 | 2026-07-04 | feat(panel): implement user management administration panel with sessions and minecraft link support |
| 67b4e19 | 2026-07-04 | fix(panel): override generic already_exists error message during user creation and profile editing |
| 2c95da8 | 2026-07-04 | fix(panel/i18n): add missing users_col_user key to translation files |
| 627883e | 2026-07-04 | fix(panel): refine reset password messages and fix empty email placeholder in mock api response |
| 0c1cc59 | 2026-07-04 | feat(auth): migrate console login to passwordless |
| 3b43f05 | 2026-07-04 | refactor(api): drop dead login concurrency limiter and reconcile passwordless comments |
| 4f59d51 | 2026-07-04 | feat(auth): add owner-tier passkey-unbind remediation endpoint |
| c20b12c | 2026-07-04 | refactor(api): drop dead password-era ResetMailer, reconcile passkey-unbind docs |
| 0a2accd | 2026-07-04 | chore: stop tracking Autohand-generated AGENTS.md |
| 96b3cc9 | 2026-07-04 | feat(updater): wire updates.Run to a caller with PaperMC v3 release discovery |
| 9896fe1 | 2026-07-05 | docs(updater): correct PaperMC UA/fixture overclaims, re-tier the boundary |
| 7d27640 | 2026-07-05 | feat(updater): add GitHub Releases source and route felis-api/k3s/cloudflared |
| bd49313 | 2026-07-05 | refactor(panel): 抽取 10 个公共组件,消除 ~150 处重复代码 |
| 91bfa27 | 2026-07-05 | feat(operator): implement idle auto-stop (spec §8) |
| e574749 | 2026-07-05 | feat(api): enforce CPU/memory/storage quotas (spec §9.3, §22) |
| 7db57b9 | 2026-07-05 | feat(updater): add VersionGatherer extraction core and CLI gather seam |
| ec468ba | 2026-07-05 | feat(auth): add discoverable (usernameless) passkey login |
| 154002e | 2026-07-05 | docs(auth): cite MultiLogin reference for UUID-keyed reclaim split |
| 0dbd557 | 2026-07-05 | fix(store): renumber discoverable-login migration 0013 -> 0014 |
| 9e1df12 | 2026-07-05 | feat(passkey): advance sign_count, reject clone-warned assertions |
| 7f7e459 | 2026-07-05 | fix(operator): enforce startup and readiness timeouts (§5, §8) |
| 7becb38 | 2026-07-05 | fix(api): implement /readyz with real DB + K8s API + CRD checks (§7) |
| 9079a2c | 2026-07-05 | feat(panel): implement email otp and passkey login interface |
| cfe68ae | 2026-07-05 | fix(panel): align status distribution order to put Stopped at the end |
| bbcfaeb | 2026-07-05 | refactor(panel): remove redundant voxel network topology description subtitle |
| fdb6efb | 2026-07-05 | feat(account): migrate a live account's owned servers to a new account (§B3 inherit) |
| abad137 | 2026-07-06 | style(panel): unify vertical spacing below PageHeader across pages |
| c2ee21a | 2026-07-06 | feat(breakglass): add halt-a-server op to the recovery console (§B4) |
| c1aa38b | 2026-07-06 | feat(velocity): add /felis migrate to open an account migration (§B3 inherit) |
| 7a7c0d5 | 2026-07-07 | feat(api): add on-demand world backup endpoint and Job executor (§B4 Sync) |
| f2fc57c | 2026-07-07 | feat(api): add internal-face break-glass world backup endpoint (§B4 Sync) |
| 2ba9948 | 2026-07-07 | fix(platform): front the felis-api internal face on its own ClusterIP Service |
| fc748d3 | 2026-07-07 | feat(breakglass): add "back up a world now" console peer (§B4 Sync) |
| 9911b8c | 2026-07-07 | docs(changes): record the break-glass backup console peer (§B4 Sync phase 2b) |
| 096d597 | 2026-07-07 | docs(changes): backfill detail docs for pre-ledger functional commits |
| 5a7cd5a | 2026-07-07 | docs(changes): fold 346ec68 cloudflare-edge walkthrough into its detail doc |
| 4626ab5 | 2026-07-07 | docs(changes): mutation-audit the ledger's "unit-tested" safety claims |
| 729bd7b | 2026-07-07 | docs(changes): index the mutation audit and two lagging ledger rows |
| c67a4d3 | 2026-07-08 | docs(changes): close §B4 with the S3 archive backend deferred by design |
| 85b8a92 | 2026-07-08 | test(api): pin restore's owner gate against a superseded former owner |
| b7b4a3b | 2026-07-08 | docs(changes): record the round-2 backup/restore mutation audit and index the owner-gate test |