Files
Felis/internal/api/repo.go
T
Lemon-miaow 61b283ae2f feat(auth): add Owner authentication source settings
Manage Yggdrasil providers from the panel using durable platform settings, protected identity namespaces and atomic revisions. Apply changes to subsequent logins and profile lookups without restarting. Return operator-host logouts to the login method selection page.
2026-10-04 22:18:37 +08:00

966 lines
57 KiB
Go

package api
import (
"context"
"time"
)
// ServerRecord is the business-layer projection of a server (spec §6 servers /
// server_aliases). It carries only what the CRD cannot express — ownership and
// the cached subdomain alias — never authoritative lifecycle fields.
type ServerRecord struct {
Name string
Subdomain string
// OwnerID is the claiming user, or "" when the server is unclaimed.
OwnerID string
// CachedPhase is the non-authoritative phase projection used for fast lists.
CachedPhase string
// Retire is the pending request to give the server up or delete it, nil when
// there is none (PUT /servers/{name}/retirement).
Retire *RetireState
}
// RetireState is a server's pending retirement: its owner gave it up, or an admin
// asked for it to be deleted (Delete). The reaper carries it out on its next run
// (archive the world, delete the volume, release the server, and with Delete also
// remove the server itself); until then the server stays stopped and cannot be
// woken or claimed.
type RetireState struct {
RequestedAt time.Time `json:"requested_at"`
Delete bool `json:"delete"`
}
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
// may auto-start, or may claim. Everything after Claimable is NOT stored in
// Postgres — handleMyServers joins it best-effort from the CRD status
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0 and the
// cached phase, never a 500. DesiredState, AutostartPolicy, PlayerCountUnknown,
// AutoRestarts and StartGaveUp are owner detail and stay empty on rows the caller
// does not own, the same split publicServerInfo makes on the status route.
type MyServerView struct {
Name string `json:"name"`
Subdomain string `json:"subdomain"`
Owned bool `json:"owned"`
Claimable bool `json:"claimable"`
Phase string `json:"phase,omitempty"`
PlayersOnline int32 `json:"playersOnline"`
PlayersMax int32 `json:"playersMax"`
DisplayName string `json:"displayName,omitempty"`
DesiredState string `json:"desiredState,omitempty"`
AutostartPolicy string `json:"autostartPolicy,omitempty"`
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
AutoRestarts int32 `json:"autoRestarts,omitempty"`
StartGaveUp bool `json:"startGaveUp,omitempty"`
// Retiring is the pending retirement of a server the caller owns.
Retiring *RetireState `json:"retiring,omitempty"`
}
// ServerOwnership is one live server's claim state as the fleet read joins it.
type ServerOwnership struct {
// OwnerID is the claiming account, "" while the server is unclaimed. The fleet
// compares it with the caller's id: an account without an email shows its
// username as Owner, so the display text cannot say whose server it is.
OwnerID string
// Owner is the claiming account's display identity (email, or username when
// the address is absent), "" while unclaimed.
Owner string
// Retire is the server's pending retirement, nil when there is none. A server
// being deleted is not claimable even while it has no owner.
Retire *RetireState
}
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
// a verified email or the username for people (auditActor), the component name
// for internal callers. ActorUserID is the account that acted, the column to
// attribute by: an unverified email proves nothing, so only the id is binding.
type AuditEntry struct {
Actor string
ActorUserID string
Source string
Action string
ServerName string
RequestID string
// ClientIP and UserAgent say where an external call came from; empty for
// internal callers. ClientIP is the address the sign-in limit keys on.
ClientIP string
UserAgent string
// Payload is an optional structured detail blob stored in the audit_logs.payload
// jsonb column. It MUST be valid JSON or nil; nil (the zero value) is stored as
// SQL NULL, so existing callers that leave it unset are unaffected. The
// break-glass console uses it to record the accountability detail (mode, target
// owner, OS user, admin account) that does not fit the flat columns.
Payload []byte
}
// BackupView is one row of GET /api/v1/backups (spec §7, world_backups in §22).
// It deliberately omits backup_ref — the opaque internal storage handle (a tar
// path / VolumeSnapshot name / Longhorn URL, spec §19) — on the same principle
// that keeps the RCON password server-side (spec §286): the client lists backups
// and triggers a restore by server, never by handle.
type BackupView struct {
ID string `json:"id"`
ServerName string `json:"server_name"`
FormerOwner string `json:"former_owner,omitempty"`
SizeBytes int64 `json:"size_bytes"`
Reason string `json:"reason"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
// Corrupt reports that the archive failed a read-back: it cannot be
// restored. VerifiedAt is its last read-back that matched, and
// SkippedEntries the world entries it could not hold (symbolic links,
// devices, sockets).
Corrupt bool `json:"corrupt,omitempty"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
SkippedEntries int `json:"skipped_entries,omitempty"`
}
// BackupListOpts selects a page of the backups list. Server narrows it to one
// server's backups (the server's Backups page); empty lists every server in the
// caller's scope. The scope itself is never an option: it is which Repo method
// runs.
type BackupListOpts struct {
Server string
Limit int
Offset int
}
// DefaultBackupListLimit and MaxBackupListLimit bound one page of backups.
const (
DefaultBackupListLimit = 20
MaxBackupListLimit = 100
)
// BackupRecord is the server-side view of a backup used to drive a restore (spec
// §466). It carries the opaque backup_ref the Restorer needs and the former_owner
// the restore authorization compares against — neither is ever serialized to the
// client (contrast BackupView).
type BackupRecord struct {
ID string
ServerName string
FormerOwner string
BackupRef string
SizeBytes int64
// Corrupt reports that the archive failed a read-back (see BackupView).
Corrupt bool
// SHA256 is the digest recorded when the archive was written, which an
// export checks the bytes against as they stream. Empty for an archive from
// before digests were kept.
SHA256 string
}
// StaffUser is the login-side projection of a users row (spec §B passwordless
// auth). The Owner is the role=owner row, minted by `felis setup` (MC link) and
// recovered by `felis breakGlass` (email OTP); Operators are role=admin;
// players are role=user. There is no password column — staff authenticate via
// email-OTP / passkey + in-game approve, never a password.
type StaffUser struct {
ID string
Username string
Email string
Role string
// EmailVerified mirrors users.email_verified (spec §B2): the address was proven
// via an email OTP, not merely asserted. Players carry it through onboarding;
// staff rows seeded by setup leave it false until a code is redeemed.
EmailVerified bool
}
// PasskeyCredential is one bound passkey (Phase 6 WebAuthn enrollment). It carries
// only public, non-secret attestation material: a WebAuthn public key is meant to
// be public (unlike a session token), so it is safe at rest. CredentialID is the
// authenticator's globally-unique handle (base64url) and PublicKey the COSE key
// (base64); SignCount is the uint32 signature counter captured at registration.
// LastUsedAt is nil until a passkey sign-in or step-up stamps it
// (AdvanceCredentialSignCount, with the advanced counter).
type PasskeyCredential struct {
ID string
UserID string
CredentialID string
PublicKey string
SignCount uint32
AAGUID string
Name string
CreatedAt time.Time
LastUsedAt *time.Time
// Ceremony flags captured at enrollment (migration 0009). UserVerified records that a
// PIN/biometric was performed at bind; BackupEligible/BackupState record whether the
// credential is syncable/backed up. Persisted so a future login path can enforce UV
// per credential and reason about single-device vs. synced authenticators.
UserVerified bool
BackupEligible bool
BackupState bool
}
// SessionedUser is the projection resolved from a live session cookie: the
// identity SessionAuth needs to build a Principal. EmailVerified mirrors
// users.email_verified so the lockdown middleware can gate setup-incomplete
// accounts without a second DB read.
type SessionedUser struct {
ID string
Username string
Email string
Role string
EmailVerified bool
// LastSeenAt is when the session last authenticated a request, as last
// recorded by TouchSession (so up to sessionTouchEvery stale).
LastSeenAt time.Time
// ReauthAt is when the holder last proved a factor of the account (see
// requireReauth); zero when the session never did.
ReauthAt time.Time
}
// NewSession is one session to record at sign-in: the sha-256 of the opaque
// cookie value, its owner, its absolute expiry, and the device it was minted for,
// so the account's session list can tell the holder which sign-in is which.
type NewSession struct {
TokenHash string
UserID string
// CreatedAt is the sign-in instant on the API clock: the session is created
// and last seen then, so the staff idle cutoff (also API clock) measures it.
CreatedAt time.Time
ExpiresAt time.Time
UserAgent string
ClientIP string
// ReauthAt is set when the sign-in itself proved a factor (passkey, email
// code, op-login, setup token); zero for a bind-code sign-in.
ReauthAt time.Time
}
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
// durable second factor (in-game approval) that pairs with an email_otps code under
// purpose 'op_login'. Username is joined from users so the in-game admin can name who
// is waiting; Consumed reflects consumed_at, so the finish path can refuse an
// already-spent request without a second query.
type OpLoginRequest struct {
ID string
UserID string
Username string
Email string
Status string // 'pending' | 'approved' | 'denied'
Consumed bool // consumed_at IS NOT NULL (single-use guard)
ExpiresAt time.Time
CreatedAt time.Time
// ClientIP and UserAgent say where start was called from; empty on rows
// from before migration 0030.
ClientIP string
UserAgent string
}
// NewOpLoginRequest is the row op-login start writes. Email is a snapshot for the
// audit trail; ClientIP and UserAgent describe the browser that asked, for the
// in-game admin to check before vouching.
type NewOpLoginRequest struct {
ID, UserID, Email string
ClientIP, UserAgent string
ExpiresAt time.Time
}
// MigrationView is the live account-migration for a source user (spec §B3 inherit,
// scenario A): its state-machine position and the fields the web step-up, issue-code,
// and status paths read. TargetUserID is empty until a code is issued; ConfirmFactor,
// ConfirmSession and ConfirmedAt are empty/nil until the source completes step-up;
// CodeExpiresAt is nil until code_issued. ConfirmSession is the token hash of the
// session that gave the step-up.
type MigrationView struct {
ID string
SourceUserID string
TargetUserID string
State string
ConfirmFactor string
ConfirmSession string
ConfirmedAt *time.Time
CodeExpiresAt *time.Time
CreatedAt time.Time
}
// Repo is the business-layer data access the API depends on. It is an interface
// so handlers are tested against an in-memory fake; the Postgres implementation
// (pgRepo) is integration-tested only — it requires a live database.
type Repo interface {
// Ping probes the database — used by the /readyz endpoint (spec §7) to verify
// the DB connection is alive.
Ping(ctx context.Context) error
// ServerBySubdomain resolves a subdomain alias to its server, or ErrNotFound.
ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error)
// ServerByName loads a server's business projection, or ErrNotFound.
ServerByName(ctx context.Context, name string) (*ServerRecord, error)
// IsLinked reports whether the user has a verified MC account link (spec §10),
// a precondition for every ownership operation.
IsLinked(ctx context.Context, userID string) (bool, error)
// CreateLinkCode mints a one-time account-link code for an in-game player
// (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the
// verified mc_uuid (online-mode=true established it) and the authSource that
// established it (mojang|thirdparty, spec §10 dual-Yggdrasil); a web user binds
// it to their user_id later via VerifyLinkCode. This is internal-face only — the
// web has no verified UUID to mint against (the account_link_codes schema has no
// user_id column, which forces the in-game origin), nor does it see the
// authentication, which is why authSource also originates here.
CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error
// VerifyLinkCode consumes a non-expired code for the logged-in user and writes
// the account_links binding, atomically (spec §10: 网页 verify 填码 → 写
// account_links). It returns the bound mc_uuid and the authSource captured at
// mint (copied from the code onto the durable link). A missing or expired code →
// ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict;
// re-verifying the same (user, uuid) pair is idempotent and refreshes the stored
// authSource. now is the API clock so expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error)
// RedeemPlayerBindCode is the account-less player-console bootstrap (console-tier
// access model): it redeems a one-time Bind Code into a PLAYER account + link in
// one atomic step, so a first-time player with no Felis account can create one
// from the console.<root_domain> door. Unlike VerifyLinkCode it takes NO prior
// user — it creates or fetches one, keyed on the verified mc_uuid the code carries:
//
// - code missing/expired → ErrLinkCodeInvalid (does not consume it);
// - the uuid is not yet linked → create a role='user' player row with id
// newUserID (username derived from the uuid so it is unique and
// deterministic), write the account_links binding, consume the code, and
// return newUserID;
// - the uuid is already linked to a role='user' player → return THAT user
// (idempotent "log in via the game"), consuming the code;
// - the uuid is linked to a STAFF account (role != 'user', i.e. admin or owner)
// → ErrPlayerBindForbidden WITHOUT consuming the code (staff use op.console
// behind Zero Trust; the public bootstrap never mints a session for a staff
// identity).
//
// Safe as an unauthenticated entrypoint because a Bind Code is minted internal-face
// only (CreateLinkCode), against an online-mode-verified UUID, short-TTL and
// single-use — possession already proves control of a Minecraft identity. now is
// the API clock so expiry is testable. It returns the effective userID plus the
// bound mc_uuid and authSource (for the response + audit).
RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error)
// QuotaCheck reports whether claiming a server with the given resource spec
// would push the user over any of their four quota caps: max_servers,
// max_cpu_milli, max_memory_mb, and max_storage_gb (spec §9.3 / §22). A nil
// or missing quota row/unset column means unlimited for that dimension.
// excludeName is the server being claimed/edited ("" when checking a fresh
// claim without an existing cached row) so its own current resources are
// not double-counted.
QuotaCheck(ctx context.Context, userID string, excludeName string, incoming ResourceSpec) (bool, error)
// ClaimServer atomically sets owner_id where it is currently NULL and returns
// whether a row changed. false means the server was already claimed (spec §9.3:
// 0 rows → 409). The claim runs in one transaction that re-checks the four quota
// dimensions under pg_advisory_xact_lock(hashtext(user_id)), so it is the
// authoritative gate: a claim that would exceed a cap → ErrQuotaExceeded (403),
// and two concurrent claims by one user cannot both pass (audit #4).
// QuotaCheck remains the advisory pre-check for the handler's fast-path 403.
// A successful claim resets last_active_at to now and clears warned_*, so the
// reaper counts idleness from the claim.
// A server with a pending retirement is not claimable either (false).
ClaimServer(ctx context.Context, name, userID string) (bool, error)
// UserInAllowlist reports whether the user's linked UUID is on the server
// allowlist (spec §9.4).
UserInAllowlist(ctx context.Context, name, userID string) (bool, error)
// UUIDInAllowlist reports whether an in-game UUID is on the server allowlist
// (spec §9.4). It is the internal-face symmetric of UserInAllowlist: velocity
// drives a domain-autostart wake knowing the joining player only by their
// online-mode UUID, never a web user_id, so the allowlist gate for that path
// must be keyed by UUID directly (the allowlist table is UUID-keyed; the
// account_links join in UserInAllowlist only exists to bridge the web side).
UUIDInAllowlist(ctx context.Context, name, mcUUID string) (bool, error)
// ServerAllowlist lists the server's wake allowlist, newest first, revoked
// entries included, each with the live account its UUID is linked to.
ServerAllowlist(ctx context.Context, name string) ([]AllowlistEntry, error)
// SetAllowlistWake gives an allowlisted UUID its wake right back (true) or
// takes it away (false); ErrNotFound when the UUID is not on the list. A
// revoked entry stays on the list so the player's next join cannot restore it.
// Both allowlist checks above skip revoked entries, and a change of owner
// (claim, reaper release, account deletion) empties the list.
SetAllowlistWake(ctx context.Context, name, mcUUID string, canWake bool) error
// RequestRetire records that the server is to be given up, or deleted when
// deleteServer, and returns the pending request. Asking again keeps the first
// request time, and a deletion once asked for stays one. ErrNotFound when the
// server does not exist.
RequestRetire(ctx context.Context, name string, deleteServer bool) (RetireState, error)
// CancelRetire drops the server's pending retirement; with none pending it
// changes nothing. A pending deletion is dropped only when mayCancelDelete,
// and otherwise ErrConflict. ErrNotFound when the server does not exist.
CancelRetire(ctx context.Context, name string, mayCancelDelete bool) error
// UserByMCUUID resolves a verified in-game UUID to the user_id it is linked to
// (spec §10 account_links), or ErrNotFound when the UUID is not linked. The
// internal-face wake uses it to apply the owner bypass for a player known only
// by UUID; an unlinked UUID simply falls through to the autostartPolicy gate.
// Only a LIVE account resolves (audit #33): a link whose account is disabled or
// soft-deleted carries no standing on the in-game doors — claim, menu, wake
// authorization, op-login vouch and the QR link-status poll all read a dead
// account exactly like an unlinked UUID, never as a retired identity.
UserByMCUUID(ctx context.Context, mcUUID string) (userID string, err error)
// RecordJoin updates last_active_at, clears reaper warnings, and auto-appends
// the UUID to the allowlist (spec §7 join-event, §9.4).
RecordJoin(ctx context.Context, name, mcUUID string) error
// MyServers lists the servers a user owns or may claim.
MyServers(ctx context.Context, userID string) ([]MyServerView, error)
// ServerOwners maps every live server to its claim state, for the SysAdmin
// cockpit's fleet read. It is a READ-ONLY join: owner stays authored in Postgres
// (§6 business authority) and is never written back to the CRD, so this does not
// breach §1's store-of-record split. An unclaimed server is present with an empty
// OwnerID; a soft-deleted one, or a CRD with no business row, is absent, and
// cannot be claimed. The cockpit treats it as best-effort: a lookup error leaves
// ownership unknown rather than failing the fleet read.
ServerOwners(ctx context.Context) (map[string]ServerOwnership, error)
// AllBackups lists one page of every present world backup, newest first, and
// how many match in all (spec §7 GET /backups, admin scope). Expired/deleted
// rows are never returned.
AllBackups(ctx context.Context, opts BackupListOpts) ([]BackupView, int, error)
// BackupsForUser lists one page of the present world backups of worlds the
// user formerly owned, newest first, and how many match in all (spec §7 GET
// /backups, former_owner scope). The former_owner column is stamped when the
// world is archived at release time, so a user sees their own released worlds
// even after the server is re-seeded.
BackupsForUser(ctx context.Context, userID string, opts BackupListOpts) ([]BackupView, int, error)
// LatestBackup returns the most recent present backup for a server, or
// ErrNotFound when none exists (spec §466 restore). The returned BackupRecord
// carries the server-side backup_ref + former_owner the restore path needs; the
// client never sees them.
LatestBackup(ctx context.Context, serverName string) (*BackupRecord, error)
// BackupByID returns a single present backup by its id, or ErrNotFound when
// none matches. Like LatestBackup the returned BackupRecord carries the
// server-side backup_ref the restore path needs; the client never sees it.
BackupByID(ctx context.Context, id string) (*BackupRecord, error)
// ExpireBackup takes one present backup out of every list, restore and budget
// at once (status expired, expires_at no later than at), leaving its archive
// for the reaper's next retention pass and its off-site copy for the next
// sync. ErrNotFound when no present backup has that id.
ExpireBackup(ctx context.Context, id string, at time.Time) error
// LastBackupRequest returns when an on-demand backup of the server was last
// accepted (its newest backup.create audit row) at or after since, or the zero
// time when there was none. The since bound keeps the lookup inside the
// cooldown window the caller enforces.
LastBackupRequest(ctx context.Context, serverName string, since time.Time) (time.Time, error)
// BackupStoreBytes sums the sizes of every present world backup, the figure
// [archive] max_local_bytes caps.
BackupStoreBytes(ctx context.Context) (int64, error)
// SeedServer inserts the business-layer rows for a newly created server (spec
// §15): a servers row (owner_id NULL — claimed later, spec §9.3) and its
// subdomain alias. A row left by an earlier server of the same name starts over:
// no owner, claim, activity clock, reaper warnings, other aliases or allowlist
// carry over, and a retried create lands on the same fresh state. The resource
// cache (cpuMilli, memoryMB, storageMB) is seeded alongside so QuotaCheck can
// aggregate per-owner usage without cross-system CRD reads. It returns
// ErrConflict, and changes nothing, if the subdomain is already bound to a
// different server.
SeedServer(ctx context.Context, name, subdomain string, cpuMilli, memoryMB, storageMB int) error
// UpdateServerResources overwrites a server's resource cache with the size the
// cluster holds (a claim writes it through; a resize the cluster refused is put
// back with it), so the per-owner aggregate stays in sync.
UpdateServerResources(ctx context.Context, name string, cpuMilli, memoryMB, storageMB int) error
// ResizeServer writes a server's new CPU and memory to its resource cache,
// keeping its storage, in the owner's claim lane (the lock ClaimServer and
// RedeemMigration take): resizes and claims of one owner's servers queue, and
// each counts the sizes the others wrote. A resize that grows CPU or memory
// must first fit every cap with the server's whole size counted; one that does
// not returns ErrQuotaExceeded and writes nothing. prev is what the cache held.
// A server with no owner has no caps to fit, and one with no live row writes
// nothing and returns zeroes.
ResizeServer(ctx context.Context, name string, cpuMilli, memoryMB int) (prev ResourceSpec, err error)
// Audit appends one audit row.
Audit(ctx context.Context, e AuditEntry) error
// ---- player email verification (spec §B2 onboarding) ----
// CreateEmailOTP persists a freshly minted one-time code for (userID, purpose):
// only its sha-256 (codeHash), never the digits. It supersedes any prior live
// (unconsumed) code for the same (userID, purpose) so a user has at most one
// outstanding code per purpose — a re-request invalidates the earlier mail.
// expiresAt is the API clock + TTL so expiry is driven by one authoritative clock.
CreateEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error
// AddLoginEmailOTP persists a code for a PRE-SESSION login door (email login,
// op-login) beside the codes already mailed for (userID, purpose): it keeps the
// newest otpLiveLoginCodes live, dropping live codes that expired at now and the
// oldest beyond the allowance. Unlike CreateEmailOTP it never cancels a code
// because another start came in — anyone who knows an address can start a login
// for it, and ConsumeLoginEmailOTP accepts any live code.
AddLoginEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error
// VerifyEmailOTP redeems the newest live code for (userID, purpose) against
// codeHash, atomically (spec §B2). No live code, an expired one, or a consumed
// one → ErrOTPInvalid; an exhausted attempt budget → ErrOTPLocked; a spent
// (user, purpose) wrong-code budget → *OTPAccountLockedError, even for the right
// code; a hash mismatch increments attempts, charges that budget, and returns
// ErrOTPInvalid WITHOUT consuming the code (so a typo does not burn it). On a match the code is consumed and the
// user row is flipped to email=<the proven address>, email_verified=true; the
// proven email is returned — unless a DIFFERENT account has already proven the
// same address, which is ErrEmailTaken with the code left unconsumed (the
// address, not the code, is the problem). now is the API clock so expiry is
// testable.
//
// This is the ONBOARDING primitive: verifying the code is the moment the address
// becomes proven, so the write is load-bearing. The pre-session LOGIN door must
// NOT use it — see ConsumeLoginEmailOTP.
VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error)
// SetUserEmail records email on the user row WITHOUT proving control of it, and
// clears email_verified in the same write (proving nothing, it must never leave a
// stale verified flag — see the PGRepo impl). This backs the setup wizard's Step 1:
// the bootstrap has no SMTP, so the Owner cannot receive an emailed code, and the
// address is stored unverified for a later Settings/SMTP flow to verify. An unknown
// userID returns ErrNotFound.
SetUserEmail(ctx context.Context, userID, email string) error
// ConsumeLoginEmailOTP redeems a code for (userID, purpose) against codeHash for
// the PRE-SESSION login doors and the step-up doors. Every live (unconsumed,
// unexpired at now) code is a candidate, since a login door keeps several. The
// lifecycle matches VerifyEmailOTP (FOR UPDATE, lockout before hash compare; all
// live codes out of attempts → ErrOTPLocked; a mismatch charges one attempt to each
// code still open plus one account failure, consuming nothing), and a match spends
// every live code of (userID, purpose). It has NO identity side-effects: it neither
// writes users.email nor runs the verified-email uniqueness guard. Login resolved
// userID via UserByEmail, which already requires email_verified, so the address is
// settled — re-proving control of a code this session must not re-touch the row.
// Returning only an error is deliberate: unlike onboarding, login has nothing to
// prove about the address, so there is no email to hand back. Errors are exactly
// ErrOTPInvalid / ErrOTPLocked / *OTPAccountLockedError (ErrEmailTaken is
// structurally impossible here).
ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) error
// OTPLockedUntil reports when the (userID, purpose) wrong-code lock ends, or the
// zero time when that door is open. Both redeem paths enforce the lock
// themselves (returning *OTPAccountLockedError, and charging every mismatch to
// the budget); the start doors read it so a locked door mails no code.
OTPLockedUntil(ctx context.Context, userID, purpose string, now time.Time) (time.Time, error)
// ---- op.console staff login: in-game approval state machine (spec §B op-login) ----
// CreateOpLoginRequest records a fresh pending op.console login attempt for a staff
// account (spec §B op-login). ID is the opaque handle the browser polls. It writes
// the SECOND factor only — the email-OTP itself is minted separately under purpose
// 'op_login' (AddLoginEmailOTP) — so a row here means "this staff account is waiting
// for an in-game admin to vouch". ExpiresAt is the API clock + TTL so expiry is
// driven by one authoritative clock.
CreateOpLoginRequest(ctx context.Context, req NewOpLoginRequest) error
// OpLoginRequestByID loads a request by its handle, joined to its account's
// username, or ErrNotFound. The status poll, the finish path and the in-game
// approval all use it: finish additionally checks Status=='approved', !Consumed,
// and ExpiresAt>now before it will mint a session, so a pending, spent, or expired
// request can never be exchanged.
OpLoginRequestByID(ctx context.Context, id string) (*OpLoginRequest, error)
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired at now)
// requests oldest-first, each joined to its staff username, for the in-game admin's
// approval prompt (Velocity polls this on the internal face). A resolved or expired
// request drops out of the list, so an admin only ever sees actionable attempts.
ListPendingOpLogins(ctx context.Context, now time.Time) ([]OpLoginRequest, error)
// ApproveOpLogin marks a pending request approved by approverUserID (the in-game
// admin, resolved from their online-mode UUID via UserByMCUUID), atomically: it
// stamps status='approved', approved_by, approved_at only WHERE the row is still
// pending, unconsumed, and unexpired at now. A request that is gone, already
// resolved, or expired affects zero rows and returns ErrNotFound, so a double
// approval or an approval of a dead request is a no-op the caller can surface.
ApproveOpLogin(ctx context.Context, id, approverUserID string, now time.Time) error
// ConsumeOpLoginRequest stamps consumed_at on an APPROVED, unconsumed, unexpired
// request, atomically, so it can be exchanged for a session exactly once. Zero rows
// affected (pending, already consumed, or expired) → ErrNotFound. This is the finish
// path's single-use guard; the email-OTP is consumed separately, so a lost race here
// never silently mints a second session.
ConsumeOpLoginRequest(ctx context.Context, id string, now time.Time) error
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind) ----
// CreatePasskeyChallenge persists the server-side state of a credential-creation
// ceremony for (userID, purpose): the opaque go-webauthn SessionData blob and its
// expiry. Only the server holds it, so the client cannot forge the challenge it
// must answer at finish. It supersedes any prior live (unconsumed) challenge for
// the same (userID, purpose) so a re-begin invalidates the earlier ceremony —
// at most one outstanding challenge per (user, purpose). expiresAt is the API
// clock + TTL so expiry is driven by one authoritative clock.
CreatePasskeyChallenge(ctx context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error
// ConsumePasskeyChallengeByUser redeems the newest live (unconsumed, unexpired at
// now) challenge for (userID, purpose), atomically: it stamps consumed_at and
// returns the stashed SessionData so finish can validate the attestation against
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
// for the same ceremony finds nothing live and fails. now is the API clock so
// expiry is testable. Bound to user_id — enrollment and step-up know the principal
// at begin and only its holder can begin one; the public login doors use the
// challenge-matched and non-user-keyed pairs below.
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
// AddPasskeyLoginChallenge persists an email-first passkey LOGIN ceremony for
// (userID, purpose) beside the ones already live, recording challenge (the
// canonical base64url challenge handed to the browser) and source (the caller's
// network, see challengeSource). Anyone who knows an address can begin a login
// for it, so a begin never cancels another; it reaps the account's expired or
// consumed login rows and refuses with ErrTooManyPasskeyChallenges once source
// holds maxLiveChallengesPerSource live login challenges.
AddPasskeyLoginChallenge(ctx context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error
// ConsumePasskeyLoginChallenge redeems the live login challenge of (userID,
// purpose) whose challenge is the one the browser signed, atomically and
// single-use, returning its SessionData. No such live row →
// ErrPasskeyChallengeInvalid.
ConsumePasskeyLoginChallenge(ctx context.Context, userID, purpose, challenge string, now time.Time) (sessionData []byte, err error)
// CreateDiscoverableChallenge persists a DISCOVERABLE ("usernameless") login ceremony
// (task #40, migration 0013), keyed by an opaque server-minted handle id — NOT a user,
// since a from-zero begin has no principal — and tagged with source (the caller's
// network, see challengeSource). In one transaction it reaps expired/consumed rows and
// then refuses with ErrTooManyPasskeyChallenges, rather than inserting, when source
// already holds maxLiveChallengesPerSource live rows or the table holds
// maxLiveDiscoverableChallenges. now and expiresAt are both the API clock (now drives
// the reap; expiresAt = now + TTL drives liveness).
CreateDiscoverableChallenge(ctx context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error
// ConsumeDiscoverableChallenge redeems the discoverable challenge under handle id,
// atomically and single-use (mirrors ConsumePasskeyChallengeByUser without the user key):
// it takes the row FOR UPDATE, checks expiry against now, stamps consumed_at, and returns
// the stashed SessionData. An unknown, expired, or already-consumed handle →
// ErrPasskeyChallengeInvalid, so the finish door never doubles as a state oracle.
ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) (sessionData []byte, err error)
// CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6
// enrollment). It writes only public attestation material (credential_id,
// public_key, sign_count, aaguid) plus the caller's nickname. A credential_id
// already bound to ANY account → ErrConflict (the UNIQUE guard); the handler maps
// that to 409 rather than silently rebinding an authenticator.
CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error
// PasskeyCredentialsForUser lists the passkeys a user has bound, newest first, for
// the credential-management view. It returns only display fields (never a secret —
// a passkey carries none); LastUsedAt is nil where no assertion has been verified.
PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error)
// DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller
// can only unbind their OWN credential. No matching (user, id) row → ErrNotFound,
// so a stale or cross-user id cannot silently no-op as success. When the row is
// the user's last passkey and their email is unverified it returns ErrLastPasskey
// and deletes nothing; the check and the delete hold the user row locked, so two
// concurrent deletes of a user's last two passkeys cannot both pass.
DeletePasskeyCredential(ctx context.Context, userID, id string) error
// DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds — the
// remediation that stops a passkey planted via a transiently-hijacked session from
// surviving as a standing login foothold. Its production caller is the owner-tier
// DELETE /users/{id}/passkeys (handleUnbindUserPasskeys); a complete remediation
// pairs it with a session revoke, since unbinding the credential without revoking
// live sessions leaves the hijacked session itself, and revoking sessions without
// unbinding leaves a re-enrollable credential. Removing zero rows is success, not an
// error — an account with no passkeys is the intended post-condition either way.
DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error
// AdvanceCredentialSignCount records a successful assertion on the passkey identified by
// credentialID (base64url): it sets the stored signature counter to newSignCount and stamps
// last_used_at. credential_id is UNIQUE, so exactly one row is updated; a missing row (the
// credential was unbound mid-ceremony) is a successful no-op, never an error. The login doors
// call it only after clone policy allows the assertion, so for a counter-keeping authenticator
// the stored counter only ever moves forward — the baseline a later regression is judged against.
AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error
// ---- player game-login: username-collision reclaim (spec §B3) ----
// ReclaimUsername records a Mojang-priority username reclaim, atomically (spec
// §B3 正版优先): in one transaction it bars the non-genuine squatter UUID
// (username_blacklist) and stashes that account's data as a hold the velocity
// reclaim callback drives. The two writes are all-or-nothing — a half-applied
// reclaim (a barred UUID whose data was never held, or a hold for a UUID still
// able to connect) would either lose the player's data or let the squatter back
// in. id is the opaque hold row id; dataRef is the opaque archiver handle (""
// stored as NULL when archival is deferred); expiresAt is the proposed held_at +
// the 30-day window on the API clock. Keyed by mc_uuid on both tables, so a
// repeat reclaim of an already-barred UUID is idempotent and never errors on a
// duplicate — the block stays on the squatting UUID, never the contested name.
// It returns the EFFECTIVE persisted expiry: on a fresh reclaim that is the
// passed expiresAt, but on an idempotent retry it is the FIRST reclaim's expiry,
// so the caller never reports a window the stored hold does not actually have.
ReclaimUsername(ctx context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error)
// IsUsernameBlacklisted reports whether an in-game UUID was barred by a prior
// reclaim (spec §B3). The velocity login gate calls it on the internal face to
// reject a squatter while letting the genuine Mojang UUID — same username,
// different UUID — through: the check is keyed by UUID, never by the name.
IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool, error)
// IsProtectedAdminLink reports whether an in-game UUID belongs to a Linked
// Operator/SysAdmin who authenticates through the configured third-party
// Yggdrasil — the admin-on-Yggdrasil reclaim exception (spec §B3). Such a holder
// is staff logging in via the Login Server, not a Mojang squatter, so a
// Mojang-priority reclaim must never bar them. The predicate is exactly three
// conjuncts: the UUID is linked (account_links), that link authenticated via
// 'thirdparty' (auth_source), and the linked user is staff — admin or owner
// (migration 0011), the Owner being the identity most in need of the exception.
// It deliberately does NOT ask HOW the staff account signs in: an Operator may
// authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local
// passwordless door, and must be protected all the same — the sign-in method is
// orthogonal to both "is staff" and "logs in via the Login Server". An unlinked
// UUID, a Mojang-sourced link, or a player link all yield false, so the
// exception never broadens to ordinary thirdparty players (Mojang priority still
// displaces them) nor to Mojang-authenticated identities (who have no Login-Server
// name to protect). Keyed by UUID — the only identity velocity knows.
IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error)
// ---- staff account lookups (spec §B, passwordless) ----
// UserByUsername loads the login projection of a staff account by its unique
// username, or ErrNotFound. Its caller is the `felis breakGlass` recovery TUI,
// which resolves an Owner/Operator username before sending an email-OTP — there is
// no password compare (the account is passwordless). A non-staff (role='user') row
// resolves too; callers that require staff enforce the role themselves.
UserByUsername(ctx context.Context, username string) (*StaffUser, error)
// UserByID loads the same staff projection by user id, or ErrNotFound. Callers hold
// a session (which yields a user id, not a username) and need the account behind it
// — e.g. setup redeem/status resolving the lockdown session's owner. It is the
// id-keyed counterpart of UserByUsername.
UserByID(ctx context.Context, id string) (*StaffUser, error)
// UserByEmail resolves a VERIFIED email address to its login projection,
// case-insensitively, or ErrNotFound (spec §B email-first login). It is the
// entry point every email-first web login shares: the address must be proven
// (email_verified true), so a merely-asserted or unverified address never
// resolves to a session-mintable identity — an attacker cannot claim someone
// else's login by typing their email. Matching is on lower(email) to align with
// the users_verified_email_unique partial index (migration 0020), which
// guarantees at most one verified row per normalized address, so the result is
// unambiguous. A player (role='user') row resolves too — email-first login is
// passwordless and role-agnostic here; the door that consumes this result decides
// what each role may do.
UserByEmail(ctx context.Context, email string) (*StaffUser, error)
// UpsertOwner creates or resets the single Owner account direct-to-Postgres
// (the `felis setup` / `felis breakGlass` recovery path). role is forced to
// 'owner' (migration 0011 — the tier every user-admin route gates on); on a
// username conflict the existing row's email is overwritten and the role
// re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is
// promoted. The account is passwordless by design.
UpsertOwner(ctx context.Context, id, username, email string) error
// AdminExists reports whether any staff account (admin or owner) exists. It is false
// only on an install `felis setup` has not bound an Owner on yet, where local sign-in
// is still off: the sign-in page reads it to say so (handleOwnerStatus).
AdminExists(ctx context.Context) (bool, error)
// CreateSession records a minted session (spec §B sessions). Only the hash of
// the cookie is stored, mirroring tokens, so a database read never yields a
// usable cookie. The session counts as seen at creation.
CreateSession(ctx context.Context, s NewSession) error
// SessionUser resolves a live session hash to its user, or ErrNotFound. Live
// means unrevoked, unexpired at now, its account neither disabled nor deleted,
// and — for a staff account — seen within staffSessionIdle of now. It is the
// cookie half of SessionAuth.
SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error)
// TouchSession records that the session authenticated a request at now. It
// never moves last_seen_at backwards, and touching an absent session is not
// an error.
TouchSession(ctx context.Context, tokenHash string, now time.Time) error
// MarkSessionReauth records that the holder of a live session proved a factor
// at the given time. Marking an absent or revoked session is not an error.
MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
// absent or already-revoked session is not an error.
RevokeSession(ctx context.Context, tokenHash string) error
// RevokeUserSession revokes one live session of userID. A hash that is not a
// live session of that user — another user's, already revoked, expired or
// unknown — is ErrNotFound and changes nothing.
RevokeUserSession(ctx context.Context, userID, tokenHash string) error
// RevokeOtherUserSessions revokes every live session of userID except
// keepTokenHash (every one when keepTokenHash is empty) and reports how many
// it ended.
RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error)
// RedeemSetupToken spends a one-time setup token and stores s as a session of
// the token's user (s.UserID is ignored) in one transaction, so a failure
// leaves the token unspent for another try. It returns the user id, or
// ErrNotFound when the token is absent, already spent, or expired. The /setup?token=... web flow redeems it for a lockdown
// session that can only complete passwordless login setup (verify email /
// enroll passkey).
RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (userID string, err error)
// ---- runtime platform settings (spec §B platform_settings) ----
// GetSetting reads a runtime setting's raw jsonb value, or ErrNotFound when the
// key is absent. The live API reads these per-request so the break-glass TUI can
// flip toggles (e.g. local_auth_enabled) direct-to-DB without rolling the pod.
GetSetting(ctx context.Context, key string) ([]byte, error)
// SetSetting upserts a runtime setting's raw jsonb value by key.
SetSetting(ctx context.Context, key string, value []byte) error
// CompareAndSetSetting refuses a concurrent edit with ErrConflict. A nil
// expected value creates only when the setting has never been written.
CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error
// ---- user admin (spec §7, admin-only) ----
// ListUsers returns a page of non-deleted users matching the optional filters,
// newest first. total counts every user the same filters match, so the admin
// page can size its pagination without a second round-trip.
ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error)
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
// A deleted user is returned (the row lives for audit) but flagged.
UserDetail(ctx context.Context, userID string) (*UserDetail, error)
// CreateUser mints a new user row (role forced to either 'admin' or 'user').
// createdBy is the actor email for audit. A username conflict → ErrConflict.
CreateUser(ctx context.Context, input CreateUserInput, createdBy string) (*UserView, error)
// UpdateUser applies the non-nil fields of patch to the user identified by
// userID and returns the updated view. A username conflict → ErrConflict;
// a non-existent user → ErrNotFound. updatedBy is the actor email.
UpdateUser(ctx context.Context, userID string, patch UpdateUserInput, updatedBy string) (*UserView, error)
// DeleteUser soft-deletes the user: sets deleted_at, revokes every live
// session, and releases every owned server (owner_id → NULL). deletedBy is
// the actor email. A non-existent or already-deleted user → ErrNotFound.
// The row is preserved so audit_logs.actor references survive.
DeleteUser(ctx context.Context, userID, deletedBy string) error
// SetUserDisabled flips the disabled flag on a live (non-deleted) user.
// Setting disabled→true additionally revokes every live session so a
// disabled account is immediately locked out. A non-existent user →
// ErrNotFound; a deleted user → ErrNotFound.
SetUserDisabled(ctx context.Context, userID string, disabled bool) error
// ---- quota admin (spec §6 quotas, admin-only) ----
// GetQuotas returns the quotas row for a user, or a zero-value view when no
// row exists (which means unlimited per spec §9.3).
GetQuotas(ctx context.Context, userID string) (*QuotaView, error)
// SetQuotas replaces the quotas row for userID with q: a nil field is stored as
// NULL (unlimited), any other value is the cap, 0 included.
SetQuotas(ctx context.Context, userID string, q QuotaInput, setBy string) (*QuotaView, error)
// ---- session admin (admin-only) ----
// ListUserSessions returns every live session for a user (live as SessionUser
// defines it), most recently seen first. An empty list is not an error.
ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error)
// RevokeAllUserSessions marks every live session of userID revoked.
// Revoking zero sessions is not an error.
RevokeAllUserSessions(ctx context.Context, userID string) error
// ---- account-link admin (admin-only) ----
// UnlinkAccount removes a single (user_id, mc_uuid) binding. It does not
// consume the UUID's link code — a re-link by the player later is still
// possible — but the admin can unlink without going through the player.
// A non-existent binding → ErrNotFound.
UnlinkAccount(ctx context.Context, userID, mcUUID string) error
// LinkAccount force-binds a verified MC UUID to a user, bypassing the
// normal code-verification flow. The UUID must not already be linked to a
// different user (→ ErrConflict). A duplicate bind of the same pair is
// idempotent. authSource records which Yggdrasil established the UUID
// (mojang | thirdparty, spec §10 dual-Yggdrasil).
LinkAccount(ctx context.Context, userID, mcUUID, authSource string) error
// ---- account migration (spec §B3 inherit, scenario A) ----
// StartMigration puts a LIVE source account into migrate mode: it supersedes any
// earlier unfinished migration for the source (so re-running /felis migrate
// restarts cleanly, invalidating a prior outstanding code) and inserts a fresh row
// in state 'initiated'. id is the opaque handle. The source must be a live
// (non-deleted) account — ErrNotFound otherwise, so a retired account can never
// re-initiate. now stamps the row.
StartMigration(ctx context.Context, id, sourceUserID string, now time.Time) error
// MigrationForSource loads the live (non-redeemed) migration for a source, or
// ErrNotFound when none is in flight. The web status/confirm/issue paths use it to
// gate each step on the correct prior state.
MigrationForSource(ctx context.Context, sourceUserID string) (*MigrationView, error)
// ConfirmMigration records that the source proved control via a FRESH step-up
// (factor 'passkey' | 'email_otp') on the session whose token hash is session,
// advancing to 'confirmed'. It advances only from where migrationStage puts the
// caller back at the step-up: 'initiated', a 'confirmed' that lapsed
// (migrateConfirmWindow) or belongs to another session, or a 'code_issued' whose
// code expired at now, which it clears. A live confirmation of this session, a live
// code, a redeemed migration or none → ErrConflict. now stamps confirmed_at.
ConfirmMigration(ctx context.Context, sourceUserID, factor, session string, now time.Time) error
// IssueMigrationCode binds the named target and stores the one-time code hash,
// advancing 'confirmed' → 'code_issued'. The confirmation must be session's and
// younger than migrateConfirmWindow at now. targetUserID must be a live account
// other than the source (validated by the caller before this call); the target FK
// also guarantees the row exists. codeHash is the sha-256 of the code; expiresAt is
// its TTL. Anything else → ErrConflict.
IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, session, codeHash string, now, expiresAt time.Time) error
// RedeemMigration is the ATOMIC transfer: keyed by (codeHash, targetUserID) it
// finds the 'code_issued', unexpired migration whose named target is exactly the
// redeeming user, re-points every server owned by the source to the target, retires
// the source account (disabled + soft-deleted, its live sessions revoked), and marks
// the migration 'redeemed' — all in one transaction. It returns the source user id
// and the moved server names for the audit trail. No matching or expired code, or a
// code whose named target is a different user → ErrLinkCodeInvalid (an intercepted
// code is useless to anyone but the named target). When the source owns servers, the
// target's quota must hold with all of them added in, checked under ClaimServer's
// lock for both accounts; over any cap → ErrQuotaExceeded, with nothing moved and the
// code left unspent. Server ownership is the only thing moved — the mc_uuid link and
// web credentials stay with their accounts. now drives expiry and the terminal
// timestamps.
RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (sourceUserID string, movedServers []string, err error)
}
// ---- user admin types ----
// ListUsersOpts carries the optional filters and pagination for ListUsers.
// Zero values mean "no filter / default page."
type ListUsersOpts struct {
Query string // substring match on username or email
Role string // exact role match ("admin" / "user"), or "" for all
Hidden string // "true" = disabled only, "false" = enabled only, "" = all
Limit int // page size; 0 → default 20
Offset int // page offset; 0 → first page
}
// UserView is one row of the admin user list.
type UserView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
EmailVerified bool `json:"email_verified"`
ServerCount int `json:"server_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserDetail is the full admin view of one user, including linked MC accounts.
type UserDetail struct {
UserView
DeletedAt *time.Time `json:"deleted_at,omitempty"`
LinkedAccounts []LinkedAccount `json:"linked_accounts,omitempty"`
}
// AllowlistEntry is one player on a server's wake allowlist (server_allowlist):
// someone who joined it, and so may wake it under autostartPolicy=allowlist
// unless the owner took that away (CanWake false). Username is the live account
// the UUID is linked to, empty when there is none.
type AllowlistEntry struct {
MCUUID string `json:"mc_uuid"`
Username string `json:"username,omitempty"`
AddedAt time.Time `json:"added_at"`
CanWake bool `json:"can_wake"`
}
// LinkedAccount is one verified MC-UUID binding (account_links, spec §10).
type LinkedAccount struct {
MCUUID string `json:"mc_uuid"`
AuthSource string `json:"auth_source"`
VerifiedAt time.Time `json:"verified_at"`
}
// CreateUserInput is the admin create-user form.
type CreateUserInput struct {
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
}
// UpdateUserInput is the admin patch-user form. Every field is a pointer so
// an absent field ("leave unchanged") is distinguishable from a zero value.
type UpdateUserInput struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Role *string `json:"role,omitempty"`
}
// QuotaView is the admin-visible quotas row (spec §6).
type QuotaView struct {
UserID string `json:"user_id"`
MaxServers *int `json:"max_servers,omitempty"`
MaxCPUMilli *int `json:"max_cpu_milli,omitempty"`
MaxMemoryMB *int `json:"max_memory_mb,omitempty"`
MaxStorageGB *int `json:"max_storage_gb,omitempty"`
}
// QuotaInput is the admin set-quotas form. It replaces all four caps at once: an
// absent or null field is unlimited, and 0 grants none of that resource, so every
// claim that needs it is refused. The handler rejects a value outside 0..MaxInt32.
type QuotaInput struct {
MaxServers *int `json:"max_servers,omitempty"`
MaxCPUMilli *int `json:"max_cpu_milli,omitempty"`
MaxMemoryMB *int `json:"max_memory_mb,omitempty"`
MaxStorageGB *int `json:"max_storage_gb,omitempty"`
}
// ResourceSpec is the resource footprint of one server, in the units that the
// quotas table uses. The resource cache on the servers row mirrors these values
// so QuotaCheck can aggregate per-owner usage with pure SQL.
type ResourceSpec struct {
CPUMilli int // CPU in millicores (e.g. 4000 = 4 cores)
MemoryMB int // memory in megabytes (e.g. 4096 = 4 GiB)
StorageMB int // storage in megabytes (e.g. 10240 = 10 GiB)
}
// SessionView is one live session row, as the account holder and an admin see
// it. Current is set only on the holder's own list, on the session making the
// request.
type SessionView struct {
TokenHash string `json:"token_hash"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastSeenAt time.Time `json:"last_seen_at"`
UserAgent string `json:"user_agent"`
ClientIP string `json:"client_ip"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
Current bool `json:"current,omitempty"`
}