Add the insert-only Operator-creation path to the break-glass console
(felis breakGlass). An Operator is an additional staff admin: role=admin
with must_change_password=true, identical in shape to the Owner, since
Felis has no separate operator DB role (migration 0003).
Unlike the Owner upsert, provisioning is insert-only -- a username already
taken returns ErrConflict (ON CONFLICT DO NOTHING + zero RowsAffected)
rather than silently resetting a live account, so adding an Operator can
never clobber the Owner's or another Operator's credential. A typed
password is used as-is; an empty one is replaced with a generated
one-time credential returned for display. Operator-add does not touch
local_auth_enabled -- that global gate belongs to the Owner thread alone.
Accountability is recorded best-effort under a break_glass.operator_create
audit action, written only after a successful provision.
The TUI menu router that reaches this path is deferred; this lands the
fully unit-testable logic layer (provisionOperator, performAddOperator,
auditAddOperator) with the PGRepo insert kept integration-only.
QR scan-to-login is a device-code grant where the QR encodes the existing
short-lived account-link code (spec §B3 player game-login). velocity mints a
code in-game, renders it as a QR, the player scans it on a phone already signed
in to the panel, and that web session's verify writes the durable account_links
row bound to that user. The only new verifiable surface that flow needs is the
completion poll velocity calls to learn the link landed and admit the player.
Add GET /api/v1/internal/account/link/status/{mc_uuid}: a read-only, internal
handleLinkStatus keyed by the verified mc_uuid velocity already holds. It reuses
the existing UserByMCUUID, so it adds no migration and no mutation to the
load-bearing VerifyLinkCode; ErrNotFound maps to {linked:false} (pending /
not-yet-scanned), a hit to {linked:true, user_id}. Keying on the public UUID and
not the scanned code means the read carries no guessing surface and needs no
attempt cap — the internal face already gates it to service callers, and the poll
consumes nothing so a velocity restart re-polls safely.
QR render, limbo collision routing, in-game admit, and the reclaim
inherit-disambiguation stay CODE-ONLY (Java/Velocity) and are labeled as such;
this endpoint reports link completion only.
Document the route in openapi.yaml (x-felis-face internal, x-felis-tier service)
so the parity gate holds, and cover it with a hermetic vertical that proves the
poll reflects the durable link only after the external verify and binds the
verifier's id, plus unknown-uuid, idempotency, and internal-only face separation.
- Convert CRLF to LF across Go, panel, and plugin files
- Add Cloudflare API token template URL to breakGlass TUI edge intro
- Verify API token in cfsetup before creating tunnel, DNS, or Access app
Add internal/cfsetup, the verifiable core of an optional one-click
Cloudflare Tunnel + Access provisioning flow for the SysAdmin edge
(spec §14). It is domain-agnostic (every FQDN is composed from the
configured root_domain) and IdP-agnostic (any valid Access JWT aud is
accepted, whichever IdP fronts it), so a SysAdmin who brings their own
domain or Zero-Trust scheme stays fully supported.
The load-bearing safety property is a fail-closed guard on the
recommended Access policy. validateFailClosed is an allowlist that
refuses any policy that could be public: a bypass/non-allow decision, an
empty include, an "everyone" include not narrowed by a constraining
require (include rules are OR, so "everyone" beside an identity is still
public), or any include rule it cannot positively recognize as a scoped
identity. Setup runs the guard before any side effect, so a public
policy aborts the run with nothing created.
The tunnel ingress routes only the web hostnames to the local panel
origin and terminates in the mandatory fail-shut 404 catch-all; the raw
game host is never proxied. Gating preconditions (cloudflared present,
tunnel login completed, API token) are hard checks with no side effects
on failure.
The actual cloudflared exec, DNS routing, and Access API calls live in
runner.go and are integration-only: they require the operator's own live
Cloudflare account and interactive browser consent, which cannot be
unit-tested. The policy guard, ingress generation, request bodies, and
gating are unit-tested.
When the configured third-party Yggdrasil and the official Mojang service
issue the same username under different UUIDs, the non-genuine squatter is
displaced in favour of the real Mojang owner (正版优先). This adds the
Go-verifiable data layer of that flow on the internal (velocity) face.
- migration 0006: username_blacklist (barred squatter UUIDs) and
player_data_holds (the displaced account's 30-day data stash), both keyed
by mc_uuid so the genuine Mojang player — identical username, different
UUID — is never caught by the bar.
- POST /api/v1/internal/player/reclaim bars the squatter UUID and stashes
its data in one transaction (all-or-nothing). It is idempotent on a
retried callback and returns the hold's effective expiry — the first
reclaim's window, never a fresh now()+30d — so the rejected player is told
the truth about how long their data is kept.
- GET /api/v1/internal/player/blacklist/{mc_uuid} is the login-gate check
velocity calls to reject a barred squatter before admitting them.
Scope: velocity collision-routing, the limbo prompt, the authlib
dual-backend and the data-inherit flow are code-only (Java plus a QR-bound
device session a row cannot express) and are not part of this slice. Unit
tests cover the handlers and the in-memory repo contract; the Postgres SQL
path is exercised by integration only.
Capture which Yggdrasil authenticated an in-game UUID when a link code is
minted (spec §10 dual-Yggdrasil) and copy it onto the durable account_links
row at verify. The value originates in-game — the web verify side never sees
the authentication — so it threads through account_link_codes, mirroring how
mc_uuid (not user_id) lives on a code.
- migration 0005: add link_auth_source enum + auth_source column on both
account_link_codes and account_links; DEFAULT 'mojang' backfills existing
rows and sets the Mojang-priority default for a mint that omits the field
- mint validates an explicit auth_source (unknown value -> 400); verify
surfaces it in the 200 body and refreshes it on idempotent re-verify
Forced web onboarding proves a player controls an email before it is
bound to their account. POST /api/v1/account/email/start mints a random
6-digit code, mails it (or logs it server-side when no Mailer is wired —
the demo has no SMTP), and POST /api/v1/account/email/verify redeems it,
flipping users.email_verified in the same transaction that consumes the
code.
Brute force is bounded two ways: a 10-minute TTL and a 5-attempt cap,
both enforced in the repo so the fake and Postgres agree. Only the
sha-256 of the code is stored; the digits live only in the email. Both
routes are app-tier external — verifying your own email is scoped to the
principal, never names another user.
Root is machine authority, not a human identity, so `felis breakGlass`
now also records WHICH SysAdmin broke the glass. Even under
`sudo felis breakGlass` an account and password are entered in the TUI;
the root gate is necessary but no longer sufficient for accountability.
The console resolves one of three modes up front and audits the
difference:
- bootstrap (no staff account exists yet): the typed credential mints
the first Owner; the act is attributed to the OS user ($SUDO_USER,
else root) and recorded verified:false.
- recovery (an admin already exists): the operator authenticates as an
existing admin via bcrypt; the verified identity is the accountable
actor and the row is recorded verified:true.
- root override (the typed credential did not verify): a deliberate
OVERRIDE token proceeds under local-root authority, attributed to the
OS user and recorded verified:false. Break-glass never refuses -
recovering when no admin password can be produced is its whole job.
Attribution is best-effort, not proof (whoever runs this is root and can
edit Postgres directly); the audit row is honest about which it is.
- internal/api: AuditEntry gains an optional jsonb Payload (nil maps to
SQL NULL, so existing callers are unaffected); PGRepo.Audit writes it
and a new PGRepo.AdminExists drives the bootstrap-vs-recovery switch.
- the accountability row is written the instant the credential changes,
before local auth is enabled, so a failed toggle write can never leave
a reset credential with no "who did it" record.
- local_auth_enabled is now one exported api.LocalAuthEnabledKey shared
by the break-glass writer and the per-request reader, replacing two
drifting copies of the literal.
- break-glass password entry reuses the panel's 8-72-byte rule so a
credential set here is never later rejected by web change-password.
Covered by Go unit tests over a fake owner store: auth match/non-match,
the three audit modes and their payloads, that a dead audit sink does
not fail the recovery, that the audit precedes the toggle write, and a
headless drive of the TUI state machine asserting no credential reaches
provisioning without a verified admin or an explicit OVERRIDE.
Add username+password login for Owner/Operator staff accounts on
op.console, the primary web login when Zero Trust is not in front of the
API. Three handlers form the whole surface: login mints a server-side
session cookie, logout revokes it idempotently, and change-password
re-verifies the current password before rotating the hash and clearing
must_change_password.
- Session cookies are HttpOnly+Secure+SameSite=Lax, host-only, stored
server-side as a SHA-256 hash with a 12h TTL.
- Login is anti-enumeration: every failure runs a uniform bcrypt compare
against a dummy hash and returns the same vague error.
- Credential-bearing writes require Content-Type: application/json,
returning 415 otherwise, to close the cross-site form-POST forgery
vector as a belt to the SameSite cookie.
- Local auth fails closed: login is rejected unless local_auth_enabled
is set, so a Zero-Trust-only deployment never accepts a local password.
- Extend the users table with a nullable password_hash and
must_change_password; staff are role=admin rows with a hash, players
are role=user rows with hash NULL.
- /me now reports must_change_password so the panel can force a
first-login change.
Covered by Go unit tests (handlers, content-type guard, anti-enumeration,
forced-change lockdown) and the OpenAPI route-parity gate.
The platform package that places servers across nodes and wires the operator, build, restore, and reaper subsystems, plus cmd/felis, the single binary that runs them.
The dual-faced felis-api: internal (service) and external (public/app/admin) routes behind a Zero-Trust guard. Includes the access domain (whitelist, ban, and LuckPerms permission/group control over the owner-gated RCON path), the modpack submission endpoints, and the admin-tier SysAdmin fleet read. Structured access fields are charset-validated before assembly so no field can splice a second RCON command.
A user-directed extension over the build subsystem: an uploaded modpack stays in pending_review and is never built until an admin approves. Approval is a single-winner compare-and-swap that hands off to the image-build Job, keeping the mandatory vulnerability scan in front of any push.
Foundational libraries: deterministic resource naming, the RCON client, the Postgres store with embedded SQL migrations, configuration loading, and container image-build helpers.