#49 — Tech debt: callApi's untyped request body hides wire-format mismatches #49
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#49
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tech debt:
callApi's untyped request body hides wire-format mismatchesReported by: Architect / Frontend Engineer (found during
#48User Registration)Priority: Could — no user-facing symptom by itself, but it's the root cause of a bug class that already hit
production twice
Date: 2026-07-11
Problem
callApi<TResult>(requestName: string, request: {}, ...)(ReactUi/src/api/api.tsx) types its request bodyparameter as
{}— TypeScript's "any non-null value" type. This means the compiler checks nothing about theshape of the object passed to any
callApi(...)call against the actual C# command/query it targets.While implementing
#48, this let a real bug ship and go undetected:UserSaveDto's wire key isemailAddress(camelCase of the C# property name), but three separate call sites (
SettingsModal.tsx,ForgotPasswordPage.tsx,and initially the new
Register.tsx) sentemailinstead. Two of the three were pre-existing, shipped bugs —RequestPasswordResetCommandsilently failed on every "forgot password" submission (the anti-enumerationerror-swallowing masked it), and
UpdateUserCommandsilently blanked the user's stored email on every profilesave. Both had passing tests, because the tests' mocks used the same wrong key as the implementation — internal
consistency, not wire correctness.
Proposal
Type
callApi'srequestparameter against the actual request DTO shape instead of{}, so a call site sendingthe wrong field name is a compile error, not a runtime/manual-discovery problem. Options to evaluate:
requestNameliteral → its expected body type (most precise, but requireseither hand-maintaining the map or generating it from the backend's request types).
user:/similar nested DTO fields at each call site againstUserSaveDto(etc.) fromdtos.ts, even without fully typingcallApiitself.Acceptance criteria (finalized by PO 2026-07-12)
callApicall sites get compile-time checking against the DTO shape the backend actually expectscallApi(...)call site failstsc -b, not just at runtimePO decision: Go with the discriminated-union option (most precise). Build a single
ApiRequestMap(ReactUi/src/api/requests.ts) that hand-maps everyrequestNameliteral in useto its request-body shape and its result type, sourced from the actual C#
IRequest<TResult>record in
CqsTodo/Features/**. Bonus beyond the original ask: since the map already knows theresult type per request name,
callApi'sTResultgeneric becomes redundant and is dropped —callApi(requestName, request)return type is inferred automatically, so a mismatched resulttype assumption at a call site is now also caught, not just the request body. This is still a
type-only change (no runtime behavior differs) and keeps the map as the single source of truth
instead of splitting body/result across two maps.
ReactUi/src/api/data.tsx'suseDatahook is unused (zero call sites anywhere in the codebase)and cannot be typed against
ApiRequestMapwithout either becoming generic over every possiblerequest name (dead complexity for a hook nobody calls) or reintroducing an escape-hatch
{}typethat would undermine the whole point of this ticket. Deleting it is the direct, minimal-scope
consequence of doing this typing correctly, not unrelated cleanup — see
02_software_architect.mddesign note in
docs/features/done/once archived.Blockers
None.
security (
49_typed_callapi_request_bodies_security.md)Security Review —
#49TypedcallApiRequest BodiesAuthor: Security Agent
Date: 2026-07-12
Pre-review (before implementation)
No new endpoints, no change to authentication/session handling, no change to what data crosses
the wire — this is a frontend-only compile-time typing change. Approved to proceed without a
dedicated deep review; see the design note's Security section.
Final review (after implementation)
Confirmed the implementation matches the pre-review scope:
ReactUi/src/api/requests.tsand thecallApisignature change inReactUi/src/api/api.tsxare type-level only (erased at buildtime), and every call site sends the same field names/values as before.
Three behavior changes were introduced, all narrowing rather than widening what the client will
do, so none is a new attack surface:
pushSubscription.tsnow throws before callingSubscribeToPushCommandif the browser'sPushSubscriptionJSONis missingendpoint/keys.p256dh/keys.auth, instead of silentlysending a request with those fields
undefined(whichJSON.stringifywould have droppedfrom the body entirely). Previously this could reach the backend as a malformed
SubscribeToPushCommand; now it fails on the client before any request is sent — strictlysafer. Code review caught that the original guard fired after the browser had already
created the push subscription, which would've left the device subscribed client-side with no
matching server-side row if the guard tripped; fixed to
unsubscribe()before throwing, so aguard failure now leaves no residual client-side state either.
AcceptInvitePage.tsxnow shows the existing "invitation invalid" error state instead ofcalling
AcceptListInvitationCommandwith a possibly-undefinedtoken if the route param ismissing — not a fix for an observed bug (React Router won't render this component without a
non-empty
:tokenpath segment in practice), but defense against the compiler-visiblepossibility, consistent with treating every request body as untrusted-until-validated.
SettingsModal.tsx'shandleProfileSavenow requires a validuserId(matching theuserId == null || userId < 0check already used inCurrentUserAvatar.tsx) before callingUpdateUserCommand, and shows an error instead of silently doing nothing if the sessionexpired mid-edit. Same category as
#1/#2: a request that could previously be sent with abad/null value now either isn't sent (guards 1/2) or is now visibly rejected client-side
instead of a no-op (guard 3) — no case where more data or a wider range of requests is now
sent than before.
No secrets, tokens, or user-controlled data handling changed. No new dependencies added.
Verdict: Approved.
design (
49_typed_callapi_request_bodies_design.md)Design Note —
#49TypedcallApiRequest BodiesAuthor: Software Architect
Date: 2026-07-12
Approach
Add
ReactUi/src/api/requests.tsexporting one interface,ApiRequestMap, keyed by everyrequestNamestring literal currently passed tocallApi(...)anywhere inReactUi/src. Eachentry is
{ request: <body shape>; result: <response shape> }, derived by reading the matchingC#
public record XCommand(...) : IRequest<TResult>/XQuery(...) : IRequest<TResult>inCqsTodo/Features/**. Field types reuse the existingvalueTypes.tsaliases anddtos.tsinterfaces wherever one already exists; three new primitive aliases are added to
valueTypes.ts(
PasswordResetToken,EmailVerificationToken,NotificationId— allstring/numbernewtype wrappers on the backend, so a plain alias is correct, matching the existing pattern for
InvitationTokenetc.). C# record parameters with a default value (e.g.TodoDueDate? DueDate = null,bool UnreadOnly = false) become optional (?) TS properties, since the wire contractalready treats an omitted key as "use the default."
callApichanges from:to:
TResultis dropped rather than kept alongsideK: TypeScript requires either all-or-noneexplicit type arguments (no partial lists without defaults), so keeping both would force every
call site to keep writing
callApi<TodoDto>(...)even though the correct result type is nowfully determined by
requestName— a redundant, driftable annotation the map already makesunnecessary. All ~40 call sites drop their explicit
<TResult>generic; the compiler now derivesand checks it from
requestNameinstead of trusting a hand-written annotation.ReactUi/src/api/data.tsx(useDatahook) has zero call sites in the codebase (verified viarepo-wide grep for
api/dataimports) and takesrequestName: string— incompatible with thenew
K extends ApiRequestNameconstraint without an unsafe cast that would defeat the point ofthis change. Deleted rather than kept as dead, untyped code.
Scope check against acceptance criteria
ApiRequestMap[K]['request'].tsc -b— yes; verified manually during implementation by temporarilyrenaming a field at a call site and confirming
tsc -brejects it, then reverting, and pinnedgoing forward by
ReactUi/src/api/requests.typecheck.ts.previously-possible malformed/no-op request with an explicit rejection, never the reverse):
pushSubscription.ts'sSubscribeToPushCommandcall built its body fromsubscription.toJSON().keys?.p256dh/?.auth, bothstring | undefined, against abackend command that requires non-null strings — a latent bug the typed request body now
surfaces as a compile error. Guard added; also unsubscribes the just-created browser-side
subscription before throwing (code review catch: the guard fires after
pushManager.subscribe()'s side effect, so without the rollback a failed guard would leavethe device subscribed client-side with no matching server-side row).
AcceptInvitePage.tsx's routetokenparam is typedstring | undefinedbyuseParams<{token: string}>(), though in practice React Router won't render this componentwithout a non-empty
:tokensegment, so this path is not realistically reachable today —it's compiler-driven defensive code, not a fix for an observed bug, unlike guard 1. Kept
because the alternative (a non-null assertion) would silently reintroduce exactly the kind
of unchecked-assumption bug this ticket exists to eliminate if the route ever changes.
SettingsModal.tsx'shandleProfileSaveneeded auserIdguard to satisfyUpdateUserCommand's non-nulluserId: UserId(previouslyuserId: number | nullcompiledsilently against the untyped
{}body).userIdcan't be the-1loading sentinel whilethis component is mounted (
App.tsxgates the whole route tree onuserId !== -1), but itcan legitimately become
nullmid-session if the session expires while the modal is open(
api.tsx's 403 handler callssetUserId(null)) — a real, if rare, reachable path (codereview catch: an earlier version of this guard used
!userIdand silently no-op'd insteadof surfacing an error, which both mishandled
-1per this codebase's ownuserId == null || userId < 0idiom used inCurrentUserAvatar.tsxand regressed the UXfrom "attempt the call, show an error on failure" to "silently do nothing"). Fixed to match
that idiom and to surface an error instead of no-op'ing.
data.tsxdeletion (dead code) — no behavior change since it had no callers.Known limitation (not fixed this cycle — flagged for PO)
ApiRequestMapis a hand-transcribed snapshot of the backend's request/response contracts, notgenerated from them. The backend already exposes a full OpenAPI spec via Swashbuckle
(
CqsTodo.WebApi/Program.cs'sUseSwagger/AddSwaggerGen), so a codegen step(openapi-typescript/NSwag) could give the same "wrong field name fails
tsc -b" guarantee withzero hand-maintenance and no drift risk against future backend changes — today, a backend field
rename doesn't fail
tsc -b; it only fails once someone manually notices the drift or hits it atruntime, which is a narrower guarantee than "compile-time checking against the DTO shape the
backend actually expects" could ultimately provide. Out of scope for this Could-tier cycle (it's
materially more work than hand-authoring ~40 map entries); drafted as a follow-up story — see
docs/features/new/51_generate_api_request_map_from_openapi.md.Security
No new endpoints, no change to what data is sent or to auth/session handling — purely a
compile-time typing change plus the three narrow, fail-closed guards above. Security pre-review:
no concerns, approved to proceed without a dedicated deep review. Final review after
implementation: confirmed scope match, see the security doc for this feature.
qa (
49_typed_callapi_request_bodies_qa.md)QA Notes —
#49TypedcallApiRequest BodiesAuthor: QA Agent
Date: 2026-07-12
What was verified
tsc -bis clean across the whole frontend after the change (ReactUi/src/api/requests.ts+callApisignature change + ~40 call-site updates).tsc -b":temporarily renamed
emailAddress→emailat theUpdateUserCommandcall site inSettingsModal.tsx, rantsc -b, gotTS2353: Object literal may only specify known properties, and 'email' does not exist in type 'UserSaveDto', then reverted.ReactUi/src/api/requests.typecheck.tsmakes that verification durable instead of a one-offmanual check: it's a compile-only file (not collected by Vitest — no
.test./.spec.in thename) exercising
@ts-expect-erroragainst a wrong field name, an unknown request name, and amissing required field, plus one valid call that must compile clean. If
ApiRequestMapeverregresses back to an untyped
{}body, the@ts-expect-errorlines stop having anything tosuppress and
tsc -bfails on "Unused '@ts-expect-error' directive" — the regression iscaught in CI's frontend build step, same as any other type error.
runtime test needed — this is a compile-time-only guarantee).
npm run lint: 38 problems (30 errors/8 warnings) vs. 42 (33/9) onmasterbefore thischange — net reduction (removed now-unused type imports), no new lint findings introduced by
this cycle. Verified by diffing lint output against
git stash'd baseline.full detail on each):
pushSubscription.ts'sSubscribeToPushCommandcall could previouslysend
undefinedfor required key fields if the browser omitted subscription keys (nowguarded, throws instead);
AcceptInvitePage.tsx's routetokenparam is typed possiblyundefinedbyuseParams(now guarded, though not realistically reachable given the routerequires a non-empty
:tokensegment — kept as compiler-driven defense, not a fix for anobserved bug);
SettingsModal.tsx's profile-save needed auserIdguard, which afour-subagent code review caught was originally written as
!userId(silently no-ops insteadof erroring, and doesn't handle the
-1loading sentinel the same wayCurrentUserAvatar.tsx:13does) — fixed touserId == null || userId < 0with an expliciterror message, matching that existing idiom.
callApisignature(
api.test.ts's fake'TestRequest'name → real'GetCurrentUserIdQuery';AcceptInvitePage.test.tsx,InvitePanel.test.tsx,NotificationBell.test.tsx,pushSubscription.test.tsmock resolved values corrected to matchUnit/DTO shapes insteadof
undefined) — mechanical fixes, no test intent changed./code-reviewpass (correctness×2, reuse, simplification+efficiency,altitude+conventions) ran against the full diff; findings addressed: the
SettingsModal!userIdguard above;pushSubscription.ts's guard now unsubscribes the just-created browsersubscription before throwing (it previously fired after
pushManager.subscribe()'s sideeffect, which would've left an orphaned client-side subscription);
markNotificationRead'sidparameter now uses theNotificationIdtype alias instead of a rawnumber, matchingthis codebase's own "no primitive types for domain concepts" convention
(
ai/roles/04_frontend_engineer.md);CheckTodoCommand/UncheckTodoCommandinrequests.tsnow reuse the existing
TodoIdtype instead of re-declaring its shape inline;ai/roles/04_frontend_engineer.md's stale reference to the deletedapi/data.tsxextractionpattern was corrected to describe the actual convention (extract only when a request is
reused across 2+ components); the "generate
ApiRequestMapfrom OpenAPI instead ofhand-maintaining it" finding was drafted as a follow-up story,
docs/features/new/51_generate_api_request_map_from_openapi.md, rather than expanded intothis cycle's scope.
Verdict
Approved. No behavior change beyond the three guarded fail-closed fixes noted above (each
replaces a previously-possible malformed/no-op request with an explicit rejection or error
message, never the reverse) — consistent with this codebase's existing error-handling
conventions in the same files.