drop/server/prisma/models/community-servers.prisma
wdunn001 a74ed9eded
Some checks failed
Server CI / Lint (push) Failing after 2m0s
Server CI / Typecheck (push) Successful in 3m28s
feat(community): SSO to native game account bridge, phases 2 and 3
Adds the player-facing half of game account management: a panel on
/community/servers/[id] that shows your linked account for that server,
creates one, and changes its password.

None of these emulators speak OIDC and none will - each ships its own
login against its own auth database. So this is a bridge, not SSO: one
Drop identity tied to one native account per server, enforced by a unique
index on (userId, serverSlug). That constraint IS the feature.

Drop owns identity, authorization, the link table and the UI. The
per-game DB access and password crypto stay in mmo-portal's provider API
(SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and
fails silently when subtly wrong.

The password never touches Drop's database; it goes straight through to
the provider, which turns it into whatever that emulator wants.

Notable details:
- GameServer.slug names the game, provider.key names the emulator. They
  agree for eqemu and daoc but wow != trinitycore, so the mapping is an
  explicit three-entry constant, not an assumption.
- Panel renders NOTHING when a server has no account backend (Impostor is
  a lobby with no identity), rather than a dead button.
- 'backend unreachable' is kept distinct from 'you have no account' - the
  latter would invite a duplicate during an outage.
- Provision creates in the game first, then links. The reverse would
  leave a link pointing at a nonexistent account, which reads as
  corruption to every later call.
- Authorization is just an authenticated Drop session with the ACL: Drop
  access and game access already imply each other, so a third gate could
  only disagree with them.

Typecheck clean.
2026-08-12 17:04:33 -04:00

281 lines
8.6 KiB
Text

// --- M4 --- game-server registry and issue reports (drop-community M4).
//
// Ownership: GameServer, GameServerStatusHistory, Issue, and IssueComment are
// this milestone's models. Cross-references to User (reporter/resolver/
// owner) are deliberately PLAIN string id fields with no Prisma @relation,
// matching the existing `profilePictureObjectId String // Object` precedent
// in user.prisma: it avoids editing the shared User model (and the back-
// relation array Prisma would otherwise require there) while three other
// milestones are concurrently adding their own models in this same repo.
// Joins against User happen in the service layer instead.
//
// Title spine note: this fork's title spine is split, not unified (see
// community.prisma's header) -- Drop content is a `Game` row directly, RomM
// content is a `CommunityTitle` row, and some servers (EQEmu, the WoW realm,
// the Goldberg relay) are not tied to any specific title at all. So instead
// of an FK to one table, both GameServer and Issue carry a
// (titlePlatform, titleId) pair -- the same (platform, external_id) idea
// DESIGN.md's unified `title` table used, applied across two real tables
// (plus "none") instead of one. No formal relation, for the same reason as
// the User references above.
enum CommunityContentPlatform {
drop
romm
none
}
enum GameServerKind {
eqemu
wow
goldberg_relay
minecraft
source
generic
}
enum GameServerProtocol {
tcp
udp
http
}
enum GameServerStatus {
up
down
degraded
unknown
}
// Strategy-per-probe_type, mirroring script-library's
// game-server-registry-probe.py exactly (the reference implementation this
// milestone's ServerProbe sweep is built from). spire_api and source_query
// are modeled now so a server can declare them without a schema change, but
// have no working implementation yet -- they report "unknown", never a
// guessed status.
enum GameServerProbeType {
tcp_connect
http_get
spire_api
presence_inferred
source_query
none
}
enum GameServerRegistration {
manual
auto
}
// DESIGN.md's game_server.visibility also has a `friends` value, deferred
// here: friends-only visibility needs the Friendship graph, which is M3's
// model, not merged into this branch. Adding `friends` later is a single
// additive enum value (ALTER TYPE ... ADD VALUE), so this is not a dead end.
enum GameServerVisibility {
public
private
}
model GameServer {
id String @id @default(uuid())
name String
// Stable, hand-picked slug -- NOT a Prisma relation, same reasoning as
// ChatRoom.serverKey/gameServerId (see community.prisma): M3's chat
// shipped three server-scoped rooms (KNOWN_SERVERS in chatService.ts,
// keyed "eqemu" | "wow" | "daoc") before this milestone landed, and
// reserved `ChatRoom.gameServerId` for a future backfill pass matched by
// slug equality. These three rows use exactly those keys so that match is
// trivial: WHERE "GameServer"."slug" = "ChatRoom"."serverKey". Do not
// rename an existing slug without also updating chatService.ts's
// KNOWN_SERVERS and any backfill that has already run.
slug String @unique
// Persistent-world servers where "is anyone on right now" is the whole
// question (WoW, EQEmu, OpenDAoC) get pinned to the top of the compact
// status panel on the community landing page, ahead of one-off/lobby
// servers (Impostor) that share the same `kind: generic` bucket and
// otherwise couldn't be told apart from it. Admin-settable per server,
// not hardcoded by name in the UI.
featured Boolean @default(false)
titlePlatform CommunityContentPlatform @default(none)
titleId String?
kind GameServerKind
host String
port Int
protocol GameServerProtocol
status GameServerStatus @default(unknown)
playersOnline Int?
lastCheckedAt DateTime?
lastUpAt DateTime?
consecutiveFailures Int @default(0)
// probeConfig mirrors the registry JSON's `probe_config` shape per
// probe_type, plus an optional nested `playerCountProbe` object
// ({method, ...}) for the additive, non-gating player-count extensions
// (ssh_mysql_query, ssh_docker_logs_regex) documented in
// script-library/scripts/game-server-registry-probe.py. Never used to
// compute `status` -- only probeType is.
probeType GameServerProbeType @default(none)
probeConfig Json?
probeIntervalSec Int @default(60)
joinInstructions String?
deepLink String?
registration GameServerRegistration @default(manual)
ownerUserId String?
visibility GameServerVisibility @default(public)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
statusHistory GameServerStatusHistory[]
@@index([kind])
@@index([status])
@@index([featured])
@@index([titlePlatform, titleId])
}
model GameServerStatusHistory {
id String @id @default(uuid())
serverId String
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
checkedAt DateTime @default(now())
status GameServerStatus
latencyMs Int?
playersOnline Int?
detail Json?
@@index([serverId, checkedAt(sort: Desc)])
}
enum IssueKind {
bug
crash
launch_failure
missing_files
multiplayer
performance
request
}
enum IssueSeverity {
blocker
major
minor
cosmetic
}
enum IssueStatus {
open
triaged
in_progress
resolved
wontfix
duplicate
}
// Generic over platform for free, because titlePlatform/titleId already
// abstracts drop | romm: a RomM DOS title and a Drop Steam title file into
// the same queue with the same code (DESIGN.md sect. 7.7).
model Issue {
id String @id @default(uuid())
titlePlatform CommunityContentPlatform
titleId String
reporterId String
kind IssueKind
severity IssueSeverity @default(minor)
subject String
body String
status IssueStatus @default(open)
resolvedById String?
resolvedAt DateTime?
resolutionNote String?
duplicateOfId String?
duplicateOf Issue? @relation("IssueDuplicates", fields: [duplicateOfId], references: [id])
duplicates Issue[] @relation("IssueDuplicates")
// Auto-attached by the launch wrapper once M2's wrapper surface exists
// (OS build, GPU, wrapper version, resolved launch command, exit code,
// log tail). Optional and unused until then; a manually filed issue just
// leaves this null.
environment Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments IssueComment[]
@@index([titlePlatform, titleId])
@@index([status])
@@index([reporterId])
}
model IssueComment {
id String @id @default(uuid())
issueId String
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
authorId String
body String
createdAt DateTime @default(now())
@@index([issueId, createdAt])
}
// --- M6: SSO identity <-> native game account ------------------------------
//
// THE WHOLE POINT: none of these emulators speak OIDC and none of them will.
// EQEmu, TrinityCore and OpenDAoC each ship their own login against their own
// auth database. So this is not SSO for the game servers -- it is a BRIDGE:
// one Drop identity tied to one native game account per server, so a player
// has a single place to create and reset credentials they will then type into
// a game client that has never heard of Authentik.
// See docs/design/0002-game-account-management.md.
//
// ONE ACCOUNT PER USER PER SERVER is the feature, not a limitation -- "tie my
// identity to my account on that server" only means one thing. Enforced by
// @@unique([userId, serverSlug]).
//
// serverSlug is GameServer.slug, NOT a relation -- same reasoning as
// ChatRoom.serverKey above. It also deliberately survives a GameServer row
// being deleted and re-added: the link is to the SERVER as a concept, and
// losing a player's account mapping because a registry row was recreated
// would be data loss with no upside.
//
// accountId is provider-defined and OPAQUE -- TEXT even where the game's
// native id is numeric (TrinityCore's account.id). That is what lets one
// column work for every backend; mmo-portal's own link table made the same
// call for the same reason. Providers convert at their own boundary.
//
// NEVER INFERRED FROM A NAME MATCH. Every row is written explicitly at
// provisioning time. A game account whose username happens to equal a Drop
// username is not that user's account.
model GameAccountLink {
id String @id @default(uuid())
userId String
serverSlug String
accountId String
accountUsername String
createdAt DateTime @default(now())
@@unique([userId, serverSlug])
@@index([serverSlug])
@@index([userId])
}