Skip to content

Audit fixes: dupe/economy exploits, race conditions, DB bugs, cleanups#217

Merged
Jakubk15 merged 35 commits into
masterfrom
audit-fixes
Jun 21, 2026
Merged

Audit fixes: dupe/economy exploits, race conditions, DB bugs, cleanups#217
Jakubk15 merged 35 commits into
masterfrom
audit-fixes

Conversation

@Jakubk15

Copy link
Copy Markdown
Member

Full audit of src/ and fixes for every finding, one commit each. See AUDIT.md for the detailed report and resolution status. ./gradlew compileJava and ./gradlew test pass.

🔴 Critical

  • C1 Refund the parcel fee when persistence fails (was charged-then-lost).
  • C2 rollbackSend deletes the parcel and its content and refunds the fee, instead of orphaning content + losing money.
  • C3 Consume the locker item on creation (the cancelled place left the item in hand → unlimited lockers).
  • C4 Cancel the locker break in-tick via a synchronous cache lookup (the late async restore allowed chest/contents duplication).

🟠 High

  • H1 Collection re-checks inventory space on the main thread and deletes before handing items back safely.
  • H2 Close the locker-fullness TOCTOU: occupancy counts in-transit parcels and dispatches are serialized per destination locker.
  • H3 Page lockers from the repository instead of an arbitrary partial cache.
  • H4 Cancel the chest interaction in the same tick so the vanilla chest no longer opens under the locker GUI.
  • H5/H6 User conflict checks consult the database; read misses now populate the cache.

🟡 Medium

M1 (chain delivery delete after status update), M2 (deterministic event-firing threads — fixes two real IllegalStateException paths), M3 (no reentrant cache loader), M4 (surface item-storage save failures, re-give items), M5 (fail fast on table create), M6 (rename upsert/insertIfAbsent), M7 (atomic DAO cache), M8 (configurable connection-pool tuning).

🔵 Low

L1, L3, L4, L5. (L2 was folded into the H2 fix.)

Duplication

  • D1 Shared createTable helper — also fixes the same swallowed table-creation failure in the Delivery and User repos.
  • D2 Shared queryPage helper — also fixes a latent always-false "has next page" in UserRepository.fetchPage.
  • D4 CancellableEvent base for shared cancel handling.
  • D5 Collapse the duplicate createActiveItem overloads.

Intentionally deferred: D3 (generic cache-or-fetch helper) — the managers use their caches differently enough that one abstraction risks masking per-cache nuance, and the concrete inconsistency it could hide was already fixed by H6.

Note: this is a static review; the Critical items (esp. C3/C4 duplication and C1/C2 refunds) should be validated with targeted in-game tests before merge.

🤖 Generated with Claude Code

Jakubk15 and others added 28 commits June 20, 2026 15:42
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The economy fee was withdrawn before the parcel was saved and never
refunded if the save failed, charging the player for a parcel that was
never created. Track the charged amount and deposit it back in the
failure path. Also consolidate the rollback so the fee is refunded and
the failure notice is sent exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
When itemStorageManager.delete failed after the parcel and its content
had been persisted (and the fee charged), the dispatch path deleted only
the parcel row - leaving the ParcelContent orphaned and the fee lost.

Add ParcelService#rollbackSend which deletes the parcel and its content
and refunds the fee, and call it from ParcelDispatchService. Extract a
shared feeFor(size) helper so the charge and refund cannot diverge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
onBlockPlace cancels the placement (so vanilla never consumes the item)
and re-places the block manually, but never removed the locker item from
the player's inventory - letting one item create unlimited lockers.
Consume one matching item from the main/off hand on the main thread when
the locker is actually created.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
onBlockBreak never cancelled the BlockBreakEvent, so a non-admin break
physically destroyed the block (spawning the chest and its drops) before
the block was restored on the next tick - a duplication exploit.

Add LockerManager#getCached for a synchronous, cache-only lookup and use
it to cancel the break within the same tick when the locker is cached.
Admin breaks now clear the block without dropping it. The async fallback
(uncached locker) still restores the block as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
freeSlotsInInventory was evaluated on the async DB thread and the parcel
was deleted before items were handed back on the main thread, so a full
or changing inventory could lose items permanently.

Re-check inventory space on the main thread, delete the parcel/content
first (so it cannot be collected twice), and only then give the items -
guarded by the delete result so a failed delete never destroys items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Two concurrent sends to the same near-full locker could both pass
isLockerFull and both store, exceeding maxParcelsPerLocker.

Count all parcels occupying a destination locker (collected parcels are
already deleted, so in-transit + delivered = the real occupancy), which
reserves a slot at send time, and serialize dispatches per destination
locker via a per-locker future chain so the check and the save cannot
interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
LockerManager.get(Page) returned an arbitrary, partially-evicted subset
of the cache as page 0, hiding any locker that was not currently cached
and ordering results non-deterministically. Always query the repository
and warm the cache with the results.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The locker lookup was async and event.setCancelled ran a tick later, so
the vanilla chest had already opened underneath the locker GUI. Use the
synchronous locker cache to cancel the interaction in the same tick; on a
cache miss, close the just-opened inventory before showing the GUI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
UserManagerImpl.get(UUID)/get(String) returned straight from the
repository without caching, so every lookup of an uncached user hit the
database and the cache only ever filled via create/changeName. Cache the
fetched user (by UUID and name) like the other managers do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
getOrCreate and create only checked the in-memory cache for conflicts, so
a user present in the database but evicted/absent from the cache passed
validation and a duplicate create was attempted. getOrCreate now fetches
from the repository before creating, and create validates UUID/name
conflicts against repository lookups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
ParcelSendTask updated the parcel to DELIVERED and deleted the delivery
as two independent fire-and-forget calls. If the update failed but the
delete succeeded, the parcel was stranded in SENT with no delivery row,
so it was never re-scheduled on restart and never delivered. Chain the
delete after a successful update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
ItemStorageManager.getOrCreate called create() as the cache.get loader,
and create() writes the same key back via cache.put - which Caffeine
forbids for the key being computed and can throw or corrupt the entry.
Check the cache first and call create() outside the loader instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
On GUI close the staged items were read, the storage row deleted, and
the new storage saved fire-and-forget - if the save failed after the
delete, the items were silently lost. Make create/getOrCreate and
GuiManager.saveItemStorage return the save future, chain delete -> save
in ItemStorageGui, and give the items back to the player if saving fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
ItemStorageRepositoryOrmLite swallowed a failed createTableIfNotExists
with printStackTrace, unlike every other repository, so a missing table
only surfaced later as confusing query errors. Throw DatabaseException
for consistency and fail-fast startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The wrapper method named save() performed createOrUpdate (an upsert)
while saveIfNotExist() performed createIfNotExists, an inversion that
made "save" unable to persist updates. Rename to upsert() and
insertIfAbsent() with doc comments, and update all callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
getDao used a non-atomic get-then-put on the ConcurrentHashMap, so two
threads could create and store the DAO twice. Use computeIfAbsent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The Hikari pool was hard-capped at 5 connections with a leak-detection
threshold equal to the connection timeout (5s), which logs false leak
warnings for any slow query. Expose connectionPoolSize,
connectionTimeoutMillis and leakDetectionThresholdMillis in the config
and default the leak threshold to a safer 30s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Whether a callEvent was legal depended on the incidental calling thread:
UserChangeNameEvent (a sync event) was fired from the async user futures,
and LockerDeleteEvent was fired from either the main thread (cache hit)
or an async DB thread (cache miss) - both could throw IllegalStateException.

Mark UserChangeNameEvent asynchronous to match its firing context
(consistent with UserCreateEvent), and fire LockerDeleteEvent on the main
thread deterministically via the scheduler so it stays a valid sync event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
databaseType is already a DatabaseType, so DatabaseType.valueOf(
databaseType.toString().toUpperCase()) was a redundant round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
onDisable closed the datasource first, so in-flight async DB tasks failed
against a closed pool. Unregister listeners, cancel plugin tasks, and
shut down commands/Discord before disconnecting the database.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The "Replace with actual config message" comment was outdated - the
locker description prompt already comes from MessageConfig. (The
remaining hardcoded English dialog button/input labels are left for a
dedicated i18n pass.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The collect space check compared item count to fully-empty slots only,
so it could refuse a parcel whose items would stack into partially-filled
slots. Add InventoryUtil.canHold which simulates placement (topping up
matching stacks, then spilling into empty slots) and use it in collect().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The two SendingGui.createActiveItem overloads differed only in add vs
addAll. Have the single-line variant delegate to the list variant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Every *OrmLite constructor duplicated the same createTableIfNotExists
try/catch. Add AbstractRepositoryOrmLite#createTable and call it from each
repository. This also fixes the Delivery and User repositories, which
silently swallowed table-creation failures with printStackTrace (the same
bug fixed for ItemStorage in M5) - they now fail fast like the others.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Parcel, Locker and User repositories each reimplemented the limit+1 /
offset / hasNext / removeLast pagination dance. Add
AbstractRepositoryOrmLite#queryPage and route all three through it. This
also fixes UserRepository.fetchPage, which fetched only `limit` rows yet
checked size > limit, so it never reported a next page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The six cancellable plugin events each repeated the cancelled field plus
isCancelled/setCancelled. Introduce shared/event/CancellableEvent holding
that boilerplate (subclasses still own their HandlerList as Bukkit
requires) and extend it from each event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements comprehensive fixes for critical and high-severity issues identified in a code audit of the ParcelLockers service. Key changes include resolving TOCTOU races in locker fullness checks, preventing item duplication exploits during locker placement and breaking, ensuring proper fee refunds on send failures, and refactoring database repositories to reduce duplication. The review feedback highlights several crucial edge cases where asynchronous database operations or failures could lead to cache-DB desyncs, item duplication, or permanent item loss. Specifically, the reviewer recommends invalidating or reverting caches if saves fail in ItemStorageManager and UserManagerImpl, handling exceptional failures during item storage deletion in ParcelDispatchService to guarantee rollbacks, ensuring items are still delivered even if content deletion fails during collection, and verifying item consumption in LockerPlaceController.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java Outdated
- ItemStorageManager.create: revert the optimistic cache entry if the
  save fails, so the cache never holds an unpersisted storage (which,
  with items re-given to the player, would duplicate them).
- LockerPlaceController: consume the locker item before creating and
  abort if it is gone; refund the item and clear the block if creation
  fails. consumeLockerItem now returns whether it consumed.
- ParcelServiceImpl.collect: once the parcel is deleted, treat content
  deletion as best-effort and always give the items back (a failed
  content delete no longer loses them permanently).
- UserManagerImpl.create: chain the save future and invalidate the cache
  on failure instead of fire-and-forget, avoiding a cache-DB desync.
- ParcelDispatchService: map an exceptional itemStorage delete to false
  so it triggers rollbackSend instead of bypassing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
@Jakubk15

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

Claude finished @Jakubk15's task in 6m 4s —— View job


Code Review

  • Gather PR context
  • Review critical fixes (C1–C4)
  • Review high-severity fixes (H1–H6)
  • Review Gemini follow-up fixes (commit f859387)
  • Review medium/low/duplication fixes
  • Post final review

Summary

Overall, this is a thorough and well-structured audit with correct fixes for the most dangerous issues. The Gemini follow-up (f859387) addressed all five flagged regressions properly. A few issues remain — one medium-severity cache-DB desync that was missed, and a couple of minor problems in changeName.


What's Solid

C1 — Fee refund on persistence failure (ParcelServiceImpl:122–137): refundableFee is correctly captured before the async chain so the value is stable. The exceptionally handler is positioned to cover both the parcel-save and content-save failure paths.

C2 — rollbackSend (ParcelServiceImpl:141–153): Correctly deletes both the parcel row and its content, and refunds the fee via the bypass-permission check.

C3 — Item consumption (LockerPlaceController:131–167): The boolean-returning consumeLockerItem abort-before-create pattern is correct. The item-refund-on-failed-creation path (exceptionally block) cleans up both the consumed item and the placed block.

C4 / H4 — Synchronous cancel via cache (LockerBreakController:49–54, LockerInteractionController:47–53): Fast-path cancellation in the same tick is the right fix. The acknowledged slow-path limitation (cache miss → block breaks before async result arrives) is inherent to the architecture and acceptable given that the cache is warm for all known lockers.

H1 — Collection TOCTOU (ParcelServiceImpl:224–266): Main-thread re-check + delete-before-give is correct. Content deletion is correctly treated as best-effort once the parcel row is deleted (Gemini's fix properly landed here).

H2 — Locker fullness serialization (ParcelDispatchService:58–66): The per-locker chain via ConcurrentHashMap.compute() is correct and race-free. The conditional remove(lockerId, chained) safely handles concurrent dispatches without prematurely evicting a newer chain tail.

H3 — Page from repository (LockerManager:108–119): Cache-only pagination was correctly moved to repository.

H5/H6 — User conflict checks (UserManagerImpl:91–99, 117–127): DB-first validation with cache-miss population on getOrCreate, and the Gemini-flagged optimistic-cache rollback on failed save in create, are both correct.

AbstractRepositoryOrmLite.queryPage: The +1 fetch / removeLast hasNext logic is clean and avoids the always-false bug it replaces in UserRepository.

Gemini's 5 flagged items (f859387): ItemStorageManager.create cache revert, consumeLockerItem boolean return, parcelContentRepository best-effort delete, UserManagerImpl.create cache invalidate on failure, itemStorageManager.delete().exceptionally(t -> false) — all correctly applied.


Issues Found

Medium — LockerManager.create() cache not reverted on save failure

File: src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java:149–153

this.lockersByUUID.put(uniqueId, locker);
this.lockersByPosition.put(position, locker);

return this.lockerRepository.save(locker);   // ← if this fails...

If lockerRepository.save() fails, both cache maps hold a locker entry that was never persisted. The caller in LockerPlaceController refunds the item and clears the block, but does not clear the cache. Consequences:

  • LockerBreakController fast path sees the phantom entry and tries to delete a non-existent locker
  • LockerInteractionController fast path opens the GUI for a position with no DB record
  • A subsequent attempt to place a locker at the same position hits the conflict check (lockersByPosition cache) and is rejected with "already exists" until the cache expires (2 h)

This is the same pattern Gemini caught in UserManagerImpl and ItemStorageManager — but it was missed here. The fix mirrors those cases:

this.lockersByUUID.put(uniqueId, locker);
this.lockersByPosition.put(position, locker);

return this.lockerRepository.save(locker)
    .whenComplete((saved, throwable) -> {
        if (throwable != null) {
            this.lockersByUUID.invalidate(uniqueId);
            this.lockersByPosition.invalidate(position);
        }
    });

Fix this →


Minor — UserManagerImpl.changeName() only consults the cache

File: src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java:149–155

User oldUser = this.usersByUUID.getIfPresent(uuid);
if (oldUser == null) {
    throw new ValidationException("User not found with UUID: " + uuid);
}

If the cache has evicted the user (e.g. 2 h idle), changeName fails with ValidationException even though the user exists in the database. The fix is to fall back to userRepository.fetch(uuid) on a cache miss, consistent with how get(UUID) handles it.


Minor — UserManagerImpl.changeName() cache updated before DB save succeeds

File: src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java:162–167

User updatedUser = new User(uuid, newName);
this.usersByUUID.put(uuid, updatedUser);        // ← cache updated first
this.usersByName.invalidate(oldName);
this.usersByName.put(newName, updatedUser);

return this.userRepository.changeName(uuid, newName); // ← if this fails...

If userRepository.changeName() fails, the cache holds the new name but the database still has the old name — a desync that persists until the entry expires or the server restarts. Same pattern as UserManagerImpl.create() pre-fix. Add a whenComplete rollback that reinstates oldName in both caches on failure.


Minor UX — Double "cannotSend" notification on storage-delete failure

File: src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java:97–98

When itemStorageManager.delete() fails:

  1. parcelService.send() has already sent "Parcel sent!" (at ParcelServiceImpl:126)
  2. dispatchInternal then sends "Cannot send parcel." before calling rollbackSend

The player sees a success message immediately followed by a failure message for the same transaction. This is confusing even though the rollback is correct. Consider suppressing the "sent" notification inside send() and only issuing it once the storage delete has confirmed success — or at minimum document this as a known edge case.


Nit

LockerBreakController.handleDamagedLocker() (fire/explosion handlers) also restores the block asynchronously and doesn't cancel the explosion event, which can drop a vanilla chest item from the locker position before the restore. This pre-dates the PR and is the same slow-path issue as C4, just in a different handler — worth a follow-up.


Note: ./gradlew compileJava and ./gradlew test pass per the PR description. The Critical/High items should still receive targeted in-game testing as flagged in the PR body before merge.

- LockerManager.create: revert both cache maps if the save fails, so the
  cache never holds a locker absent from the DB (the same desync pattern
  fixed earlier for User/ItemStorage, missed here).
- UserManagerImpl.changeName: fall back to the repository on a cache miss
  (no longer fails for an evicted-but-existing user), and revert the
  cache if the persist fails. Run via thenComposeAsync so the async event
  never fires on the main thread.
- ParcelDispatchService/ParcelServiceImpl: issue the "sent" notice only
  after the whole dispatch succeeds, avoiding a "sent" immediately
  followed by "cannot send" on a storage-clear rollback.
- LockerBreakController: explosion/fire/ignite/damage handlers now cancel
  synchronously via the locker cache (removing exploded locker blocks
  from the blast), instead of only restoring after the fact - closing the
  same drop/dupe window as C4 in those handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
@Jakubk15

Copy link
Copy Markdown
Member Author

Addressed the second review round in d6ec8a539587339cdb06d8cc201fb0ed2bfe8e9a. All five items were valid:

  • Medium — LockerManager.create cache desync: correct, same pattern missed here. Now reverts both lockersByUUID/lockersByPosition via whenComplete if the save fails, so the break/interaction fast paths and the re-placement conflict check never see a phantom locker.
  • Minor — changeName cache-only lookup: fixed — falls back to userRepository.fetch(uuid) on a cache miss, consistent with get(UUID).
  • Minor — changeName cache-before-save: fixed — reverts both caches (old name/user) via whenComplete if userRepository.changeName fails. Used thenComposeAsync so the async UserChangeNameEvent still fires off the main thread on a cache hit.
  • Minor UX — double notification: fixed — moved the "sent" notice out of ParcelServiceImpl.send into the dispatcher's success branch, so it only fires once the whole send (incl. storage clear) succeeds.
  • Nit — handleDamagedLocker explosion/fire handlers: fixed — explosion now removes cached locker blocks from blockList() synchronously, and ignite/burn/damage cancel in-tick via getCached, with the async restore kept as the uncached fallback (mirrors the C4 fix).

./gradlew compileJava and ./gradlew test pass.

Comment thread src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java Outdated
Comment thread src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java Outdated
@Jakubk15 Jakubk15 added the ✅ needs testing Issue needs verification. label Jun 20, 2026
@Jakubk15 Jakubk15 merged commit bcc60b6 into master Jun 21, 2026
1 check passed
@Jakubk15 Jakubk15 deleted the audit-fixes branch June 21, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✅ needs testing Issue needs verification.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant