An adversarial review of the 24 bug-fix PRs opened on 2026-09-12 raised twelve findings. Five were
blocking and are already fixed on their branches (F1 route-handler tests; F2 search regression;
F3 mutation-guard resourceKind; F4 results-export feature; F5 team capacity counting departed
members). The reviewer explicitly deferred the seven below as non-blocking — none of them should
hold up a merge, but they are real and recorded here so they are not lost.
Each is independent; split any of them out into its own issue when picked up.
1. scores route returns a hard 401 when a staff session has no selected organization
src/modules/judging/api/scores/route.ts:20 — if (!auth?.tenantId || !auth.orgId) return 401
AuthContext.orgId is typed string | null, and the sibling back-office routes in the same module do
not make that check (judging/api/finalists/route.ts:19 checks only tenantId). A tenant admin in
"all organizations" mode would get Unauthorized (401) on the Scoring Progress tab while Demo Queue
and Leaderboard keep working. PLAUSIBLE — the reviewer could not establish whether a staff session
here can actually carry orgId === null. Worth 10 minutes to confirm either way.
2. STAGE_SEQUENCE duplicates STAGE_ORDER, and its test does not pin them together
src/modules/competitions/lib/stages.ts:11, src/modules/competitions/lib/__tests__/stages.test.ts:10
stages.ts hand-copies the nine stage ids for a legitimate reason (client bundles must not import the
MikroORM-decorated data/entities.ts). The problem is the test: it asserts STAGE_SEQUENCE against
another hardcoded literal and never imports STAGE_ORDER. Insert a stage in entities.ts and the
test stays green while isAtOrAfterStage silently misclassifies. Fix: assert
[...STAGE_SEQUENCE] deep-equals STAGE_ORDER in a server-side test.
3. The email local part is still disclosed through the displayName fallback
competitions/api/portal/resolve-users/route.ts:85, search-participants/route.ts:114, participantSearch.ts:82
Both keep user.email?.split('@')[0] as the display-name fallback. Harmless in resolve-users (no
domain is returned), but search-participants returns the domain separately in maskedEmail, so
charlie.brown + c***@hackon.test reconstructs the full address — defeating the masking for exactly
the population it exists to protect. display_name is text not null, so the fallback only fires on
an empty string; PLAUSIBLE as a live leak, certain as a code path.
4. resolve-users has no caller anywhere in src/
grep -rn "resolve-users" src/ matches only the route's own console.error. The endpoint is reachable
only by direct HTTP. PR #147 fixed it correctly and it now has a route test, but nobody should budget
follow-up work against it without first deciding whether it should exist at all.
5. The portal judging page and its API gate on different features
judging/frontend/[orgSlug]/portal/judging/page.meta.ts requires portal.judging.score;
judging/api/portal/my-assignments/route.ts:22 requires portal.judging.view_assigned.
Both are granted to judge, so nothing breaks today. A custom role with only portal.judging.score
would pass the page gate, render the assignments screen, and get a 403 from the list it is built
around. The two gates should name the same feature, or the page should require both.
6. The project lookup in asset-file is not tenant-scoped
projects/api/portal/asset-file/[id]/route.ts:57 — em.findOne(Project, { id: attachment.recordId, deletedAt: null })
No tenantId / organizationId. The scope checks above it are conditional on the attachment's own
nullable columns, so a NULL-tenant attachment row skips both. The request still fails closed (the
TeamMember / CompetitionParticipation lookups are tenant-scoped and find nothing → 403), so this is
hardening, not a hole — but the entitlement check is currently the only thing standing between a
null-tenant attachment and a cross-tenant read.
7. search-participants decrypts the whole roster on every keystroke
competitions/api/portal/search-participants/route.ts:99-107
The name-prefix branch calls findWithDecryption with no limit, pulling every participant of the
competition and AES-GCM-decrypting email + display_name for each, then filtering in memory and
slicing to 10. In-memory filtering is genuinely unavoidable for encrypted columns, but this runs on
every debounced keystroke of the invite autocomplete — at a few thousand attendees that is a full
roster decrypt per keystroke per searching user.
Also noted, deliberately not counted against any PR
An adversarial review of the 24 bug-fix PRs opened on 2026-09-12 raised twelve findings. Five were
blocking and are already fixed on their branches (F1 route-handler tests; F2 search regression;
F3 mutation-guard resourceKind; F4 results-export feature; F5 team capacity counting departed
members). The reviewer explicitly deferred the seven below as non-blocking — none of them should
hold up a merge, but they are real and recorded here so they are not lost.
Each is independent; split any of them out into its own issue when picked up.
1.
scoresroute returns a hard 401 when a staff session has no selected organizationsrc/modules/judging/api/scores/route.ts:20—if (!auth?.tenantId || !auth.orgId) return 401AuthContext.orgIdis typedstring | null, and the sibling back-office routes in the same module donot make that check (
judging/api/finalists/route.ts:19checks onlytenantId). A tenant admin in"all organizations" mode would get
Unauthorized (401)on the Scoring Progress tab while Demo Queueand Leaderboard keep working. PLAUSIBLE — the reviewer could not establish whether a staff session
here can actually carry
orgId === null. Worth 10 minutes to confirm either way.2.
STAGE_SEQUENCEduplicatesSTAGE_ORDER, and its test does not pin them togethersrc/modules/competitions/lib/stages.ts:11,src/modules/competitions/lib/__tests__/stages.test.ts:10stages.tshand-copies the nine stage ids for a legitimate reason (client bundles must not import theMikroORM-decorated
data/entities.ts). The problem is the test: it assertsSTAGE_SEQUENCEagainstanother hardcoded literal and never imports
STAGE_ORDER. Insert a stage inentities.tsand thetest stays green while
isAtOrAfterStagesilently misclassifies. Fix: assert[...STAGE_SEQUENCE]deep-equalsSTAGE_ORDERin a server-side test.3. The email local part is still disclosed through the
displayNamefallbackcompetitions/api/portal/resolve-users/route.ts:85,search-participants/route.ts:114,participantSearch.ts:82Both keep
user.email?.split('@')[0]as the display-name fallback. Harmless inresolve-users(nodomain is returned), but
search-participantsreturns the domain separately inmaskedEmail, socharlie.brown+c***@hackon.testreconstructs the full address — defeating the masking for exactlythe population it exists to protect.
display_nameistext not null, so the fallback only fires onan empty string; PLAUSIBLE as a live leak, certain as a code path.
4.
resolve-usershas no caller anywhere insrc/grep -rn "resolve-users" src/matches only the route's ownconsole.error. The endpoint is reachableonly by direct HTTP. PR #147 fixed it correctly and it now has a route test, but nobody should budget
follow-up work against it without first deciding whether it should exist at all.
5. The portal judging page and its API gate on different features
judging/frontend/[orgSlug]/portal/judging/page.meta.tsrequiresportal.judging.score;judging/api/portal/my-assignments/route.ts:22requiresportal.judging.view_assigned.Both are granted to
judge, so nothing breaks today. A custom role with onlyportal.judging.scorewould pass the page gate, render the assignments screen, and get a 403 from the list it is built
around. The two gates should name the same feature, or the page should require both.
6. The project lookup in
asset-fileis not tenant-scopedprojects/api/portal/asset-file/[id]/route.ts:57—em.findOne(Project, { id: attachment.recordId, deletedAt: null })No
tenantId/organizationId. The scope checks above it are conditional on the attachment's ownnullable columns, so a NULL-tenant attachment row skips both. The request still fails closed (the
TeamMember/CompetitionParticipationlookups are tenant-scoped and find nothing → 403), so this ishardening, not a hole — but the entitlement check is currently the only thing standing between a
null-tenant attachment and a cross-tenant read.
7.
search-participantsdecrypts the whole roster on every keystrokecompetitions/api/portal/search-participants/route.ts:99-107The name-prefix branch calls
findWithDecryptionwith nolimit, pulling every participant of thecompetition and AES-GCM-decrypting
email+display_namefor each, then filtering in memory andslicing to 10. In-memory filtering is genuinely unavoidable for encrypted columns, but this runs on
every debounced keystroke of the invite autocomplete — at a few thousand attendees that is a full
roster decrypt per keystroke per searching user.
Also noted, deliberately not counted against any PR
search-participantsstill permits bounded roster enumeration: iterating 3-character nameprefixes (~17.6k requests, 10 masked results each) reconstructs the attendee list. The prefix rule
closed the vector search-participants exposes full participant emails and allows substring enumeration #120 actually reported (a short mail-domain fragment returning everyone in one
request); enumerating names within a competition you already attend is a much weaker problem.
my-votesanswers with stage-derived voting configuration for anycompetition_idin the caller'stenant without checking participation. Discloses only configuration.
projects/i18n/{en,pl}.jsonwithout mentioning each other (fix(projects): validate drafts before the demos stage auto-publishes them #148/fix(teams): enforce the configured min/max team size #158 and fix(competitions): stop hiding teams that enter hacking without a track #151). Theymerge cleanly and all six keys survive — verified — but the overlap was undisclosed.