Files
Felis/internal/api/repo.go
T
flyemoji b508fccc6f feat(api): add felis-api service with permissions, modpack lane, and fleet read
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.
2026-06-26 23:32:38 +09:00

143 lines
7.4 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
}
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
// may auto-start, or may claim.
type MyServerView struct {
Name string `json:"name"`
Subdomain string `json:"subdomain"`
Owned bool `json:"owned"`
Claimable bool `json:"claimable"`
Phase string `json:"phase,omitempty"`
}
// AuditEntry is one row written to audit_logs (spec §6). The actor is the Access
// email for human callers and the component name for internal callers.
type AuditEntry struct {
Actor string
Source string
Action string
ServerName string
RequestID string
}
// 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"`
}
// 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
}
// 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 {
// 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); 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).
CreateLinkCode(ctx context.Context, code, mcUUID 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. A missing or expired code →
// ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict;
// re-verifying the same (user, uuid) pair is idempotent. now is the API clock so
// expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID string, err error)
// QuotaAvailable reports whether the user is under their max_servers quota
// (spec §9.3 step ②, evaluated before provisioning).
QuotaAvailable(ctx context.Context, userID string) (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).
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)
// 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.
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)
// AllBackups lists every present world backup, newest first (spec §7 GET
// /backups, admin scope). Expired/deleted rows are never returned.
AllBackups(ctx context.Context) ([]BackupView, error)
// BackupsForUser lists the present world backups of worlds the user formerly
// owned, newest first (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) ([]BackupView, 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)
// 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, both idempotent. It returns ErrConflict if the subdomain is
// already bound to a different server, so the create handler can fail before
// touching the CRD. ClaimServer requires this row to exist, so a CRD-only
// server would be unclaimable — the create path must seed here first.
SeedServer(ctx context.Context, name, subdomain string) error
// Audit appends one audit row.
Audit(ctx context.Context, e AuditEntry) error
}