Old clients pair fine (the handshake is unauthenticated) then fail every authenticated call with a message-less 403, which the desktop app renders as an opaque 'Unrecoverable error'. Gate /client/auth/initiate on the User-Agent the 0.4+ remote crate always sends, and give the non-JWT auth fallthrough an explicit statusMessage/message pointing at cdn.quasarke.net/drop-client.
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.
Two of three match (eqemu, daoc) but wow does not - Drop names the game,
the provider names the emulator (trinitycore). Needs one explicit alias
constant, not an assumption that slug == key. Asserted in mmo-portal's
tests so a rename breaks a test rather than the mapping.
Reframes the feature as an identity bridge: these emulators do not and
will not speak OIDC, each ships its own login against its own auth DB, so
the value is tying one SSO identity to one native account per server.
That makes two former open questions answers rather than choices - one
account per user per server IS the feature, and no authorization gate
beyond a Drop session, since Drop access and game access already imply
each other and a third check could only disagree with them.
Retail TrinityCore on hold.
Ports mmo-portal's cross-game provider layer, provisioning and admin
dashboard into Drop for all live servers. GameServer.slug is the join key
to provider.key - no new identifier.
Recommends keeping the Python provider layer as an internal service
rather than reimplementing SRP6 and EQEmu password hashing in TypeScript;
that code writes into live auth DBs and fails silently when wrong.
Player guide covers the two supported hardware paths (Lighthouse and Quest),
the SteamVR runtime install that needs no Steam account or client, and which
titles work, including the ones that do not: Half-Life Alyx and The Lab need
a real SteamVR backend, No Man's Sky VR is broken through the translation
layer, and VRChat/Rec Room/Zenith depend on their companies' servers.
Operator reference covers the architecture, the routing decision tree, why
per-process XR_RUNTIME_JSON is preferred over the machine-wide registry
switch, why OpenVR routing uses a per-game DLL swap, and the staging/import
ordering constraint.
Both state plainly that only runtime startup has been validated: tracking and
per-title rendering are unverified on real hardware.
The Quasarke Edition docs still described the desktop client's Community
tab as either an upstream "Under construction" stub or something that
opens in the system browser. Neither is true anymore -- e8d3df03 retired
that entirely and replaced it with native News/Friends/Alerts/Chat pages
(0.4.4). News is confirmed working against the live server; the new
profile page and achievements card are built but not yet verified running,
so they're marked unverified rather than shipped.
Also:
- New client-auth.md documents the two credential types the desktop client
uses (client JWT for /api/v1/client/*, opaque webtoken for
aclManager-gated routes) and the CLIENT_WEBTOKEN_ACLS scopes -- this cost
three rebuild cycles because the wrong one produces a clean 403 that
looks like a permissions bug.
- Corrects the "EQEmu player count is genuinely unavailable" claim: WoW,
OpenDAoC, and EQEmu all read real live player counts now (characters
.online, serverstats with a 150s staleness cutoff, and Spire's
authenticated API via a drop-service account, respectively).
- Notes the published client (0.4.1) is behind current source (0.4.4).
- Corrects Linux packaging: real .deb/.rpm (amd64) now exist; no AppImage
(never a bundle target) and no arm64 -- and flags a leftover copy bug on
the download page itself still advertising AppImage.
- Updates row counts (CommunityTitle, Mod, AchievementDefinition, game
server registry) against a direct database check.
News called /api/v1/client/news through apiGet(), which sends the
webtoken Bearer obtained from POST /api/v1/client/user/webtoken. But
that route is a defineClientEventHandler -- it verifies the clients own
short-lived signed JWT and never consults the webtokens ACLs at all, so
it rejected the request outright. Store and Library have always worked
because they already use the JWT path.
Adds api_get_jwt on the Rust side and apiGetClient() in the composable,
and points News at it. Verified working against the live server.
Two auth mechanisms coexist here and picking the wrong one yields a
clean 403 that looks like a permissions problem: /api/v1/client/* wants
the client JWT, while routes gated by aclManager.getUserIdACL want the
webtoken plus the right ACL.
0.4.2 was built two minutes before the webtoken fix landed, so that
artifact carries the broken JSON parse and cannot authenticate any
community API call. Bumping so the fixed build is unambiguous.
The server route returns a bare string (return token.token), which h3
sends as a raw text/plain body rather than a JSON-quoted string. Parsing
it with .json::<String>() failed against the live server -- serde_json
read the UUID leading hex as a number literal and errored with "invalid
number at line 1 column 7".
Without this the client cannot obtain a web token at all, so every
community API call (news, friends, notifications) fails to authenticate
regardless of the page implementation.
The News page was a hardcoded "under construction" stub, and the Friends/
Alerts header icons did nothing -- both replaced with real Vue pages/
widgets backed by the server's existing news/notifications/community APIs,
reached through a generic authenticated REST bridge (community_api.rs:
api_get/api_post/api_delete) and a websocket bridge for live chat/presence
(community_ws.rs, mirrors the reqwest_websocket pattern remote.rs already
uses for the auth-code exchange).
- News: GET /api/v1/client/news, rendered with micromark, with real
loading/empty/error states.
- Alerts: GET/POST /api/v1/notifications/*, polled (not the notifications
websocket -- see notifications.ts for why), unread badge + mark
one/all read.
- Friends: full friends composable (list/requests/search/presence) plus a
header dropdown and a /community/friends management page.
- Chat: /community/chat, rooms (server/global/title/DM) over the live
community websocket with a REST history/read-state load on room switch.
- Achievements: per-game card on the library detail page and a per-user
summary on the new read-only profile page (/community/profile/:username).
- The Community tab's "opens in your system browser" page is gone;
/community is now a real in-app hub (activity feed + server list) linking
into friends/chat.
Also fixes a real auth gap found by actually running the built client
against the live server: notifications/community routes are gated by
aclManager.getUserIdACL, which only accepts a session cookie or an opaque
Bearer APIToken -- never the client's own short-lived signed JWT
(generate_authorization_header). The client already had a bridge for this
(POST /api/v1/client/user/webtoken, JWT-authenticated, mints an opaque
token), but CLIENT_WEBTOKEN_ACLS never granted it the notifications/
community scopes, so the exchange succeeded and the minted token still got
403'd on every one of these routes. Extended that ACL list
(04.auth-init.ts) and switched community_api.rs/community_ws.rs to mint and
use that webtoken instead of the JWT for these specific calls.
Client version bumped to 0.4.2 (tauri.conf.json).
Cargo.toml previously built reqwest with default-features=false and no
compression feature, so the client never sent Accept-Encoding on chunk
GETs and never decoded a compressed response -- Caddy-side encode would
have been a silent no-op. zstd is listed first to match the measured
best cost/ratio tradeoff (zstd-3 ~0.70 ratio in 0.18s/64MB chunk vs
zstd-19's 10.8s); gzip stays as the fallback for any proxy hop that
only speaks gzip.
Design proposal only. No behaviour changes; nothing here is compiled,
built or deployed.
Compress chunks once at version import into a derived, content-addressed
sidecar store, rather than compressing at serve time or as a separate
manual pass. Codec is zstd, declared as a versioned per-chunk property so
it can be replaced later without a migration.
Three structural decisions carry the design:
- The compress/skip decision is per chunk, not per version. Measured
zstd-19 ratios across real 64MB samples span 0.057 (.cam) to 0.997
(.bdt); content-type variance dwarfs codec choice by roughly 17x
against 1.10x.
- Raw is not a stored representation, it is the absence of one. A chunk
that does not clear the ratio threshold is simply not written, and is
served by the existing offset-slicing path unchanged. Incompressible
content therefore costs zero extra storage and zero new serve-path code.
- Compression is opt-in at the wire level via X-Drop-Accept-Codecs. A
client that declares nothing gets exactly what it gets today, so old
clients keep working with no change at all.
Also covers: the ManifestWriterFactory seam (already writes chunk bytes,
currently passed None by torrential, so compression adds no read I/O);
a distinct IV for the compressed representation to avoid AES-CTR
keystream reuse; bounded and niced encoder pool held apart from
READER_SEMAPHORE so .88 is not starved; failure paths that all degrade to
raw; and an opportunistic backfill that rides the existing 42-game
metadata reimport backlog instead of a 130 CPU-hour batch.
Deliberately not promising a headline savings figure: the measured 35%
mean is per-file, not byte-weighted, and large archives compress worst.
The pipeline reports the real byte-weighted ratio per import instead.
Open questions for review are in section 15.
The launcher community integration lived only on feat/quasarke-desktop-client:
community.rs, cloud_saves_commands.rs, the Saves tab, and the settings pages
were absent from develop, so the published installer carried features the
mainline did not and the next build from develop would have silently lost them.
The branch also carried an older parallel implementation of the server-side
community work (community.prisma, browseService, profileService,
titleSyncService, titles.get.ts, the ACL lists) which M2-M5 have since
rewritten. All 17 conflicts were in that server/docs/build surface and were
resolved in favour of develop; the merge therefore changes desktop/ only.
Community moved from "in development" to mostly-live: unified browser,
friends/presence, chat, game-server listings, mods, and achievements are
all real and running at drop.quasarke.net/community, not the old
community.quasarke.net sidecar. Rewrote the feature status table against
live DB counts and the running site rather than the design doc, and kept
the same honesty discipline on what's still not true: WoW/DAoC/EQEmu
player counts are unavailable pending a deploy, chat has never been
proven with two real users, the launch wrapper's backend is live but no
device has enrolled, achievement schemas only cover two titles, mods
downloads work but the CDN path in the catalog doesn't, and the desktop
client's own Community tab and Linux packages are still gaps, not shipped
features. Added a short note on keeping this section current going
forward.
- Spire's auth controller is mounted at bare /auth, not /api/v1/login or
/api/v1/auth/login (confirmed live via Spire's own unauthenticated
GET /api/v1/routes introspection endpoint). Two earlier guesses both
405'd. Default login_path corrected to /auth/login.
- mysql_direct gains value_column/observed_at_column/max_staleness_sec:
OpenDAoC's serverstats is a periodic snapshot (written once a minute by
the server's own StatSave.cs), not a live aggregate, so a query must read
the newest row and discard it as unavailable once it's too old rather
than presenting a stale reading -- possibly from a since-crashed server
-- as a current live count.
- Registry updated for both now-confirmed-live pieces: daoc-mysql's
serverproperty.statsave_interval is enabled (1 minute) and verified
writing; the drop-service Spire account exists (user ID 3), credentials
in OpenBao kv/eqemu/spire, materialized host-side into
/opt/drop/spire-service.env (not yet mounted -- compose change is
separate, see homelab-compose).
WoW/TrinityCore and OpenDAoC population is now read via a direct mysql2
connection to trinitycore-mysql/daoc-mysql over the mmo-net bridge (drop
joins that network in homelab-compose), the same shape Drop already uses
for RomM's catalog (rommAdapter.ts) and mmo-portal already uses for these
exact databases. Each query is copied from that game's own management
tooling rather than invented against the schema: WoW's matches FusionCMS's
Characters_model.php::getOnlineCount() verbatim; OpenDAoC's matches
upstream OpenDAoC-Core's own StatSave.cs/stats.php reading serverstats.
EQEmu has no DB-backed online-player source (re-verified live: peq has no
who_all_cache, no characters.online-equivalent, no count anywhere) --
corrects an earlier "genuinely unavailable" claim, which was wrong about
there being no source at all. The real source is World's in-memory client
list, exposed safely through Spire's own authenticated HTTP API
(GET /api/v1/eqemuserver/server-stats, backed by a telnet admin console
that must stay loopback-only). Added a spire_session_api probe method that
logs in as a dedicated service account and reuses the JWT; degrades to
"unavailable" honestly until that account is provisioned.
Removes the ssh_mysql_query and ssh_docker_logs_regex methods -- the
deployed image never had an ssh client, so both always failed. Does not
touch presence_inferred (Goldberg relay), which already degrades to
"unknown" without ssh, by design; not fixed here per explicit instruction
not to add openssh-client to the runtime image or probe the relay's game
ports.
One contract, many providers. Design document plus the TypeScript
interfaces; no implementation, no changes to the existing panel or
services.
Two structural rules, both aimed at bugs we actually hit:
- Population is a discriminated union, not `number | null`. A provider
that declares population unsupported has no code path to a number, so
the "0 online now" the panel showed for EverQuest (whose count is
genuinely unobtainable: Spire needs an authenticated session, the PEQ
schema has no online column) becomes unrepresentable rather than
merely fixed.
- "Down" and "could not check" are separate outcomes. `unreachable`
requires evidence; a transport failure yields `indeterminate`. The
float ConnectTimeout bug that reported a live relay as DOWN would now
produce "status unknown".
Providers declare capabilities (liveness, population, player list,
instances, realms, map/mode, latency, version, join info) and the panel
renders from those declarations, so a new game needs no panel edit.
Every fact carries provenance: measured, queried_side_channel, inferred,
or self_reported.
Four topologies modelled without flattening: persistent_world (EQEmu,
TrinityCore, OpenDAoC), instance_fleet (Quake, Counter-Strike),
lobby_host (Hawken, Impostor -- an instance_fleet whose members have no
own address), peer_mesh (the Goldberg relay). ProbeDiscipline carries
neverContactEndpoint, enforced by the runner rather than by a comment,
because gbe_fork registers any prober as a peer.
Survey findings folded in: we run zero Quake/CS servers today, gamedig
gives direct query of all those protocols for free, dpmaster is the only
credible self-hostable master and Source 2 / CS2 has none at all, so the
recommendation is a configured endpoint list plus direct query rather
than running a master. The relay's journald peer log is a readable,
zero-packet occupancy source we were not using.
Files are new and self-contained; migration is four additive phases
keeping the existing GameServer columns working throughout.
- Resolve gameServerRegistry.json's null deep_link entries against the
live drop-db catalog (WoW, EverQuest, Among Us match real Game rows;
OpenDAoC and the Goldberg relay genuinely have none, left null and
documented why).
- Fix the "0 online now" panel bug: the community landing page's
persistent-world quick-strip was inferring presence from PlaySession
name-matching (presenceService.getServerPresence), which hardcodes
available=true even with zero matches -- always showing a fake "0"
for EQEmu instead of the honest unavailable state. Switched it to the
same probe-backed GameServer feed ServerStatusPanel already uses
(joined by the shared wow/eqemu/daoc slug), so known-zero and
count-unavailable never render the same.
- Header people icon now opens a friends flyout (list, incoming
requests with accept/decline, entry into chat with a friend) instead
of going nowhere, mirroring the existing notifications bell's
Menu/MenuButton/MenuItems pattern; badge shows pending request count.
Mobile sidebar links it straight to /community/friends.
- Community gets a hover submenu in the main nav (desktop) / inline
sublist (mobile) listing Friends, Chat, Devices, Mods, Servers,
Issues, Achievements.
- User dropdown menu gets a "Your profile" entry resolved from the
signed-in user's own username (/community/profile/<username>), not
hardcoded.
getFriendsActivity's declared return type omitted presenceAvailable
entirely (both the early-return-on-no-friends branch and the type
annotation), and community/index.vue's local FriendActivityEntry.playing
was typed {gameId, name} when presenceService.ts's actual FriendPlaying
shape is {platform, id, name} -- the template only ever reads `.name`, so
only the type needed to change. This was breaking `nuxt build`'s typecheck
step on plain `develop` (confirmed via a clean checkout before any M4 code
was involved), which meant nothing downstream of M3's chat/presence
landing could build. Fixed forward rather than reverting anything.
Stable slugs (wow/eqemu/daoc) match M3's chatService.ts KNOWN_SERVERS keys
exactly, so ChatRoom.gameServerId's reserved backfill can match on slug
equality later. Migration timestamp moved past M2/M3/M5's so it applies
last; regenerated via schema-to-schema diff (not `prisma migrate dev`) so it
only contains this milestone's delta, not a whitespace sweep of models this
milestone doesn't own.
GameServer/GameServerStatusHistory/Issue/IssueComment models, a
strategy-per-probe_type ServerProbe (tcp_connect/http_get/presence_inferred,
mirroring script-library's game-server-registry-probe.py including the
integer ConnectTimeout fix), the bundled verified registry seed (WoW, EQEmu,
the Goldberg relay, Impostor, OpenDAoC), and full player+admin UI at
/community/servers and /community/issues. Featured flag pins the
persistent-world servers (WoW/EQEmu/OpenDAoC) to a compact status panel
surfaced on the community landing page. Scheduled sweep respects a
circuit-breaker backoff and never probes on page load; the relay is never
network-probed, only liveness-checked over ssh.
WebSocketHandler throws on the literal "unauthenticated" server response
when no .error() handler is registered, before its listen() callback ever
runs -- and since this composable connects at module scope (Nuxt bundles
auto-imported composables into a shared chunk), that happened on every
signed-out page, /auth/signin included. Found live: a browser check of
/auth/signin hung on an uncaught exception from this exact path.
Friendship (request/accept/decline/remove, self-request and crossing-request
handling, DB-level pair uniqueness via a hand-written expression index),
ChatRoom/ChatMessage/ChatReadState backed by Nitro's built-in websocket
(server/api/v1/community/ws.get.ts) behind a ChatTransport seam
(server/internal/community/chatTransport.ts) with a REST send fallback.
Mid-milestone product redirect: chat's primary job is coordinating the
persistent-world servers (EQEmu/WoW/DAoC), not per-title discussion --
ChatRoomKind gets a first-class `server` case and chatService.ts ships a
static KNOWN_SERVERS registry keyed the same way M4's GameServer will be,
once that lands (gameServerId reserved for that wiring-up pass).
Presence has no new table -- "online" is a live websocket registry
(presenceRuntime.ts), "playing X" reads through an isolated query module
(presenceService.ts) that degrades to an empty, clearly-labeled result if
PlaySession's shape doesn't match what it expects.
Pages: /community/friends, /community/chat, plus a friends/servers strip on
/community itself. Migration 20260803120000_m3_friends_chat.
syncModsFromPipeline resolved each mod's dependencies inline as it upserted
that mod, so a mod listed before its own dependency in catalog.json (e.g.
BroMaker, which appears before RocketLib) resolved against a Mod row that
didn't exist yet on a first-ever sync -- landing as "unresolved" until a
second sync run picked it up. Verified live: /community/mods/bromaker's
install plan showed both deps unresolved after the first sync, and
RocketLib correctly resolved (with its own transitive unresolved UMM dep)
after a second.
Split into two passes: upsert every Mod row first, then resolve
versions/dependencies against the now-complete set. Makes a single sync run
order-independent regardless of catalog.json's array order.
Adds the M5 slice of the community layer: Mod/ModVersion/ModDependency/
ModInstallation/ModInstalledFile/AchievementDefinition (migration
m5_mods_achievements). Gods/Users are referenced as plain soft columns
(gameId/uploaderId/identityId), not Prisma relations, following the
concurrent-edit-avoidance pattern devices.prisma established -- content.prisma
and user.prisma only grow a comment each.
Mods: syncs from script-library/scripts/archive-mod.py's _index/catalog.json
(read-only bind mount, homelab-compose/drop-stack/docker-compose.yml) into
Mod/ModVersion/ModDependency, resolves dependency refs into real Mod rows
where archived, and exposes a topologically-ordered install-plan resolver
with cycle/conflict/unresolved-dependency detection
(GET /mods/:id/versions/:v/plan). The install manifest
(ModInstallation/ModInstalledFile) records created/overwrote/skipped per
file so an uninstall plan is exact, computed server-side from what was
recorded, applied by the caller. Downloads stream directly off the mount
(GET /mods/:id/versions/:v/download) since cdn.quasarke.net has no
file_server wired to /mnt/local24/game-mods yet -- documented as a known gap
in server/internal/community/README.md, deliberately not fixed by editing
the live Caddyfile. A manual add form covers the DESIGN.md open-question #4
fallback (no upload write path exists).
Achievements: imports gbe_fork's archived generate_emu_config output
(/mnt/local24/game-tools/goldberg/schemas, also read-only mounted) via
goldbergAdapter.ts, copying icons into Drop's own object store
(objectHandler.createFromSource, same mechanism as Steam cover art) so
nothing is hotlinked. Display joins AchievementDefinition against M2's
already-landed AchievementUnlock by (steamAppId, apiName), exactly the join
key M2 designed it around -- no stub needed, M2's table was already in the
tree. Verified against real fixtures: appid 245170 (Skullgirls) importing
45/45 definitions with 90 icons, and 274190 (Broforce) importing 17/17.
Pages: /community/mods, /community/mods/:slug, /community/mods/new,
/community/achievements, /community/games/:gameId (achievement grid +
per-title mods, completion %). Small additive nav links on /community's
index rather than touching the actively-changing shared game-detail page.
Adds the M2 slice of the community layer: Device/DeviceToken/EnrollmentCode/
PlaySession/AchievementUnlock (migration m2_devices_playtime), the
device-token-authenticated ingest surface (POST /api/v1/ingest/enroll,
session-start, session-stop, achievements -- every success acks 2xx +
{"ok":true}, idempotent on sessionId), the /community/devices enrollment UI,
a PowerShell launch wrapper (homelab-compose/drop-stack/wrapper), and unified
playtime rails across Drop + RomM on the profile page.
Also implements the community titles listing change: default view is now
played-only (PlaySession + native Playtime, both platforms), sorted by most
recently played, with a friendly empty state and a "browse all" toggle back
to the full catalog. Adds a live-activity panel (now playing / recently
played) sourced from PlaySession, and a clearly-labeled server-status slot
for M4 to fill in.
PlaySession is the join table for M3/M4/M5: userId, deviceId, titlePlatform
('drop'|'romm'), gameId, communityTitleId, steamAppId, sessionId (unique),
startedAt, endedAt, durationSeconds, endReason, exitCode. No stale-session
sweeper in this pass -- "currently playing" is endedAt IS NULL, filtered to
sessions started within the last 12h for the live panel.
runCommunityTitleSync's "hide titles that disappeared from RomM" step
built `externalId: { notIn: Array.from(seenExternalIds) }` -- on a
first sync essentially every title is "seen", so that list is ~34.5k
entries, each becoming a bound parameter and blowing Postgres's query
parameter limit (Prisma P2029: "query parameter limit... is exceeded").
Caught live: the first real "Sync now" click 500'd immediately.
Replaced with a timestamp comparison: capture syncStartedAt before any
row is touched, let every create/update set (or DB-default) lastSyncedAt
to now, then hide anything still older than syncStartedAt. Same "wasn't
touched by this run" semantics as the id-set check, O(1) parameters
regardless of catalog size.
Also hardens server/pages/community/index.vue's initial title fetch and
admin sync-status fetch with try/catch instead of an unguarded top-level
await -- a request that throws for any reason (this bug, a future one,
a transient adapter hiccup) should degrade to an empty/quiet state, not
take the whole page down via Nuxt's full-page error boundary. This is
exactly how a *different*, now-fixed bug (an arktype schema requiring
the `platform` query key to be present) surfaced: a plain 400 from the
API turned into a hard page crash purely because nothing caught it.
Any change anywhere in the repo invalidates torrential-build's
`COPY . .` (and therefore the cargo RUN layer after it) on every single
build, so without a persistent cache every build re-downloads and
recompiles every crate from zero regardless of whether torrential/
itself changed -- the ~50 minutes of crates.io traffic that motivated
the CARGO_NET_RETRY/CARGO_HTTP_LOW_SPEED_LIMIT hardening two commits
back gets paid again on every future commit to unrelated files (like
the previous commit's one-line server/ fix).
--mount=type=cache on the cargo registry and target/ dirs persists them
in the buildx cache store instead, independent of layer invalidation.
Cache mounts are NOT part of the final layer filesystem, so the built
binary is `cp`'d out to a stable path (/build/torrential-bin) before the
RUN ends; the run-system stage's COPY --from reads that stable path
instead of target/release/torrential directly.
sharing=locked because the capped builder can be asked to run more than
one image build at a time.
Arktype needs the KEY suffixed with "?" to make a query param
optional (same pattern already used correctly for `q: "string?"`,
which marks the VALUE nullable, not the key absent) -- marking only the
*value* type as "'drop' | 'romm' | undefined" still requires the
`platform` key to be present, just allowing it to hold undefined. Since
getQuery() simply omits absent query keys rather than setting them to
literal `undefined`, every browse request without an explicit platform
filter -- i.e. every normal page load of /community, both the initial
SSR request and the client-side fetch behind it -- failed arktype
validation with "platform must be 'drop', 'romm' or undefined (was
missing)" and 400'd. browseService.ts already treats platform as an
optional key (`platform?: "drop" | "romm"`) and handles it being absent
correctly (`wantsDrop = !platform || platform === "drop"`), so this was
purely a schema-declaration bug in the API route, not a design issue.
Caught by browser-driving drop.quasarke.net/community end-to-end (not
just curl -- a plain GET returns real content either way, so this
needed an actual page load to surface).
A capped from-source build on .88 got ~50 minutes into the torrential
Rust stage (all deps downloading fine) before one crates.io transfer
stalled below curl's default low-speed floor (10 bytes/s over 30s) long
enough to exhaust CARGO_NET_RETRY's default 3 attempts and hard-fail the
whole build (`failed to download from .../libc/0.2.178/download`, exit
101) -- losing all that download progress since cargo has no persistent
registry cache across a failed RUN layer.
Lower CARGO_HTTP_LOW_SPEED_LIMIT to 1 byte/s (a live-but-slow transfer on
a throttled host shouldn't read as dead), extend the low-speed timeout
and overall HTTP timeout, raise CARGO_NET_RETRY to 10, and disable HTTP/2
multiplexing (single-stream is more resilient to a lossy/bursty link than
multiplexed streams fighting over the same bandwidth).
Third piece of the memory-pressure escape hatch (alongside SKIP_TYPECHECK):
--build-arg CARGO_JOBS=2 bounds cargo's parallel job count for the
torrential build, --build-arg NODE_MAX_OLD_SPACE=4096 bounds Node's
old-space heap for the postinstall/build step. Both default to unset
(no cap, existing behavior unchanged) -- only take effect when explicitly
passed, for building on a host with limited headroom under docker build
--cpus/--memory constraints.
Third piece of the memory-pressure escape hatch (alongside SKIP_TYPECHECK):
--build-arg CARGO_JOBS=2 bounds cargo's parallel job count for the
torrential build, --build-arg NODE_MAX_OLD_SPACE=4096 bounds Node's
old-space heap for the postinstall/build step. Both default to unset
(no cap, existing behavior unchanged) -- only take effect when explicitly
passed, for building on a host with limited headroom under docker build
--cpus/--memory constraints.
Observed on .88 (2026-08-02): docker build got through deps install,
Prisma generate, and the client bundle fine, then got OOM-killed (exit 143)
during vue-tsc's full-project typecheck -- the host had ~600Mi free and a
fully-exhausted 2GB swap at the time (osrm-routed alone holds ~47GB RSS on
that shared box). This isn't a code problem; it's the single most
memory-hungry step of `nuxt build` landing on an already-saturated host.
--build-arg SKIP_TYPECHECK=true (env NUXT_BUILD_SKIP_TYPECHECK) skips it.
Off by default -- this is a resource-pressure escape hatch for a
contended host, not a reason to stop typechecking; re-run
`pnpm run typecheck` separately once there's headroom.
Observed on .88 (2026-08-02): docker build got through deps install,
Prisma generate, and the client bundle fine, then got OOM-killed (exit 143)
during vue-tsc's full-project typecheck -- the host had ~600Mi free and a
fully-exhausted 2GB swap at the time (osrm-routed alone holds ~47GB RSS on
that shared box). This isn't a code problem; it's the single most
memory-hungry step of `nuxt build` landing on an already-saturated host.
--build-arg SKIP_TYPECHECK=true (env NUXT_BUILD_SKIP_TYPECHECK) skips it.
Off by default -- this is a resource-pressure escape hatch for a
contended host, not a reason to stop typechecking; re-run
`pnpm run typecheck` separately once there's headroom.
Cloud saves and the Community tab are no longer gaps; both work in the
Quasarke Edition build now served at cdn.quasarke.net/drop-client (upstream
kept at /upstream/). Documents honestly that Linux wasn't published this
pass -- the build host was too overloaded to finish it, not a source defect.
Community was never a separate community.quasarke.net host -- it lives
inside the Drop server itself at {server}/community, same as the server-side
fold-in two commits up. The desktop client's own community.rs, models.rs,
and the two community.vue pages still assumed the old sidecar design
(Settings.communityUrl/communityEndpoint defaulting to
https://community.quasarke.net), so this corrects them to match:
- community.rs now posts to {server}/api/v1/ingest/{session-start,
session-stop,achievements} (the fixed contract, matching the wrapper) via
the same generate_url()/make_authenticated_post_json() helpers
cloud_saves_commands.rs already uses, instead of an unauthenticated
bespoke HTTP client pointed at a hardcoded second host. {server} is
DB.base_url -- whatever Drop server the user actually signed into.
- database/src/models.rs: dropped the now-meaningless community_endpoint/
community_url Settings fields; kept enable_community_telemetry as the
only toggle.
- community.vue (the tab) and settings/community.vue: resolve the page URL
via the existing gen_drop_url Tauri command (same one the admin-panel
link already uses) instead of a stored/hardcoded URL.
- error.vue: the generic pre-auth error page can render before any server
is configured, so it can't resolve a per-server community link the way
community.vue does -- pointed its "Support" link at our docs instead of
the retired community.quasarke.net.
Also fixes a real release-blocking bug in process_manager.rs found while
verifying this compiles: the drop://game-launch telemetry emit held
install_dir (a reference borrowed out of db_lock via game_status) live past
the db_lock.applications.transient_statuses.insert() mutable borrow a few
lines above it -- an E0502 that fails every build, release or debug.
Fixed by cloning install_dir to an owned String before that mutable borrow
point and using the clone in the emit instead.
Verified: cargo check -p drop-app passes; full `cargo tauri build` release
build succeeds and produces a working NSIS installer (7z archive-test:
"Everything is Ok").
Caught by an actual `docker build` of the fork from source on .88:
descriptions.ts requires an exhaustive description for every ACL name
(missed community:read/community:sync when adding them), and
titleSyncService's update() call needed the same Prisma.InputJsonValue
cast its createMany() sibling already had for the metadata column.
Caught by an actual `docker build` of the fork from source on .88:
descriptions.ts requires an exhaustive description for every ACL name
(missed community:read/community:sync when adding them), and
titleSyncService's update() call needed the same Prisma.InputJsonValue
cast its createMany() sibling already had for the metadata column.
Community was originally documented as a planned separate service at
community.quasarke.net. It now ships as part of Drop itself at
drop.quasarke.net/community -- update the Quasarke Edition docs section
(overview, the community page's feature-status table and identity/chat
sections, the known-gaps note about the desktop client's stub tab, and the
Discord/community social link in astro.config.mjs) to match.
Community was originally documented as a planned separate service at
community.quasarke.net. It now ships as part of Drop itself at
drop.quasarke.net/community -- update the Quasarke Edition docs section
(overview, the community page's feature-status table and identity/chat
sections, the known-gaps note about the desktop client's stub tab, and the
Discord/community social link in astro.config.mjs) to match.
Replaces the server/pages/community.vue stub ({{ $t("todo") }}) with real
Nuxt pages: a unified Drop+RomM title browser, an admin catalog-sync panel,
and a profile page with playtime rails. Corrects the original architecture
mistake of building this as a separate service at community.quasarke.net --
now that we own the fork's source, community is ordinary Nuxt pages instead
of a sidecar with its own auth/DB/vhost.
Data model: one new table, CommunityTitle, for the RomM catalog (ported
from drop-community M1's Title spine). Drop's own Game/User/Playtime models
already ARE the title/identity/playtime spine for Drop content, so nothing
new was needed there -- profile playtime rails read real, already-existing
Playtime rows, no wrapper/ingest milestone required to populate them.
RommAdapter (server/server/internal/community/rommAdapter.ts) reads RomM's
MariaDB directly via the community_ro read-only role, same deviation and
same reasoning as M1's adapter (RomM's /api/roms needs a browser session,
no service-account key). TitleSyncService runs every 6h
(scheduledTasks.communityTitleSync) and via an admin "Sync now" button
(system:community:sync ACL) -- never deletes, hides instead.
Also folds the three post-build JS patches
(homelab-compose/drop-stack/patch-{launcher-button,telemetry-snippet,
footer-links}.js) into source now that the fork builds from source instead
of patching upstream's prebuilt image:
- library/game/[id]/index.vue: the "Open in Launcher" button gets a real
@click handler (drop://open with a timeout fallback to /download).
- plugins/telemetry.client.ts: OpenPanel RUM injected via a Nuxt client
plugin instead of appending to the compiled entry chunk.
- UserFooter.vue + admin/library source-help links + error.vue + the
Weblate link: repointed at our own infra. The Discord/social link
used to point at community.quasarke.net; that service is retired, so
it now points at /community directly.
Replaces the server/pages/community.vue stub ({{ $t("todo") }}) with real
Nuxt pages: a unified Drop+RomM title browser, an admin catalog-sync panel,
and a profile page with playtime rails. Corrects the original architecture
mistake of building this as a separate service at community.quasarke.net --
now that we own the fork's source, community is ordinary Nuxt pages instead
of a sidecar with its own auth/DB/vhost.
Data model: one new table, CommunityTitle, for the RomM catalog (ported
from drop-community M1's Title spine). Drop's own Game/User/Playtime models
already ARE the title/identity/playtime spine for Drop content, so nothing
new was needed there -- profile playtime rails read real, already-existing
Playtime rows, no wrapper/ingest milestone required to populate them.
RommAdapter (server/server/internal/community/rommAdapter.ts) reads RomM's
MariaDB directly via the community_ro read-only role, same deviation and
same reasoning as M1's adapter (RomM's /api/roms needs a browser session,
no service-account key). TitleSyncService runs every 6h
(scheduledTasks.communityTitleSync) and via an admin "Sync now" button
(system:community:sync ACL) -- never deletes, hides instead.
Also folds the three post-build JS patches
(homelab-compose/drop-stack/patch-{launcher-button,telemetry-snippet,
footer-links}.js) into source now that the fork builds from source instead
of patching upstream's prebuilt image:
- library/game/[id]/index.vue: the "Open in Launcher" button gets a real
@click handler (drop://open with a timeout fallback to /download).
- plugins/telemetry.client.ts: OpenPanel RUM injected via a Nuxt client
plugin instead of appending to the compiled entry chunk.
- UserFooter.vue + admin/library source-help links + error.vue + the
Weblate link: repointed at our own infra. The Discord/social link
used to point at community.quasarke.net; that service is retired, so
it now points at /community directly.
cloud_saves was a real, working crate (tar+zstd archive build/extract,
per-platform save-path translation) that was never reachable from the app:
a workspace member but not a drop-app dependency, no Tauri commands, no UI.
Adds six commands (cloud_saves_commands.rs) covering slot list/create/delete
against the server's existing /api/v1/client/saves/* routes (which the
client's handshake already declares the cloudSaves capability for -- see
remote/src/auth.rs) plus push/pull of an archive, and a Saves tab in the
game options modal to drive them.
One thing this does NOT do, because there is nothing to wire it to:
cloud_saves::resolver + BackupManager/CloudSaveMetadata/GameFile expect an
auto-detected, Ludusavi-style per-game save-file manifest
(CloudSaveMetadata.files), but nothing in Drop produces that manifest --
not the GameVersion Prisma model, not any admin UI, not any API response.
So push/pull here archive a directory the user picks explicitly rather than
auto-locating save files. The archive format (tar under a "data/" prefix,
zstd-compressed) stays compatible with what cloud_saves::resolver writes,
so a future manifest-driven auto-detect pass can still read what gets
pushed today.
Also: Drop's saves API only exposes a push (upload) route server-side, no
saves-specific download -- pull reuses the same authenticated
/api/v1/client/object/{id} route the client already uses for depot/image
assets, fetching the most recent entry in a slot's historyObjectIds.
Retarget every upstream Drop-OSS/discord/droposs.org reference to our own
infra (github.com/Drop-OSS -> devops.quasarke.net/drop-oss, Discord ->
community.quasarke.net), rename the product to "Drop Desktop Client
(Quasarke Edition)" with a distinct app identifier so it can't collide with
a genuine upstream install, and note in updates.rs that there was never a
self-updater to repoint (no tauri-plugin-updater dependency exists anywhere
in this client -- confirmed via Cargo.toml/tauri.conf.json).
Community tab (desktop/main/pages/community.vue) replaces the upstream
"Under construction" stub: opens community.quasarke.net in the system
browser via tauri-plugin-shell (already covered by the existing
shell:allow-open capability, no CSP/capability changes needed).
Native play-session + achievements telemetry (desktop/src-tauri/src/community.rs):
process_manager now emits drop://game-launch / drop://game-exit tauri events
around every game launch, picked up by listeners in src/lib.rs that POST
session-start / session-stop / achievements to the community service,
matching homelab-compose's drop-launch-wrapper.ps1 contract exactly
(same endpoint paths, same {"ok":true} ack requirement, same
%LOCALAPPDATA%\drop-community\queue offline-queue format/location so a
machine running both the client and the per-game wrapper shares one
flush point). This gives every game launched through the client the same
tracking the wrapper gives individually-wrapped games, without requiring
the wrapper to be installed.
The community endpoint is a single configurable Settings field
(communityEndpoint/communityUrl/enableCommunityTelemetry in
database/src/models.rs, exposed via a new Settings > Community tab), not
hardcoded at each call site.