Phase 1: types, zod runtime validation, structured errors, auth restore bugfix#36
Conversation
- problem.ts: Problem, SimplifiedProblem, ProblemSummary, ProblemSearchResult, DailyChallenge, CodeSnippet, TopicTag, SimilarQuestion - user.ts: UserStatus, UserProfile, UserContestInfo, UserAllSubmissions, UserRecentSubmissions, UserRecentACSubmissions, UserSubmissionDetail, UserProgressQuestionList, SubmissionRow - solution.ts: SolutionArticleSummary, SolutionArticleList, SolutionArticleDetail - errors.ts: ErrorCode discriminated union, LeetCodeError class, isLeetCodeError type guard - index.ts: re-exports Types describe the shapes returned by LeetcodeServiceInterface methods — the projected envelopes the service emits, not the raw upstream GraphQL payloads. Existing src/types/credentials.ts and src/types/submission.ts unchanged (their shapes already match the interface). No behavior change; no consumers wired up yet.
src/leetcode/schemas.ts exports zod validators for the four LeetCode response shapes the server actually parses today: - SubmitResponseSchema: response from POST /problems/<slug>/submit/ - CheckResponseSchema: response from GET /submissions/detail/<id>/check/ - QuestionIdResponseSchema: response from the questionId GraphQL query - ValidateCredentialsResponseSchema: response from the userStatus GraphQL query Each schema uses .passthrough() so unexpected fields don't fail the parse — only missing required fields do. CheckResponse is intentionally loose on status_msg, code_answer, and expected_answer because LeetCode omits / changes their shape between PENDING and SUCCESS states. z.infer<> types are exported alongside each schema for use in the service impl. No behavior change; schemas not yet consumed.
LeetcodeServiceInterface:
- Replace every Promise<any> with concrete typed returns drawn from
src/types/{problem,user,solution,submission}
- Add SolutionArticlesQueryOptions for fetchQuestionSolutionArticles
- Method signatures unchanged in name/arity; only return types tightened
LeetCodeGlobalService:
- Implement all methods against the new return types
- Replace stringly-typed throws with LeetCodeError(ErrorCode.AUTH_REQUIRED, …)
on every authenticated path; LeetCodeError(ErrorCode.PROBLEM_NOT_FOUND, …)
on missing problems
- Validate the four LeetCode response shapes via zod at the wire boundary:
submitSolution() now safeParses both SubmitResponse and CheckResponse and
throws LeetCodeError(ErrorCode.UPSTREAM_PAYLOAD_INVALID, …) on schema
failure with the zod issue list attached as .cause
- getQuestionId() validates QuestionIdResponseSchema and surfaces a typed
error instead of returning a stringified shape
- validateCredentials() validates ValidateCredentialsResponseSchema; on
schema failure logs a warning and returns null (preserves the previous
null-on-failure contract for the auth path)
- Project nullable upstream fields (runtime_percentile, total_correct, etc.)
into the SubmissionResult shape via ?? undefined so consumers see
number | undefined, not number | null | undefined
This commit removes 15+ Promise<any> from the interface and ~20 'as any'
casts from the implementation. fetchSolutionArticleDetail() now returns
SolutionArticleDetail | null (LeetCode actually returns null for unknown
topicIds; previous 'as ... | undefined' was a lie).
…n-memory
Fixes the silent-logout-on-restart bug from the assessment: the server
loaded ~/.leetcode-mcp/credentials.json from disk, validated them, and
told the user they were 'authenticated' — but never actually pushed the
cookies into the in-memory Credential the LeetCode client reads from.
Result: every authenticated tool call after a restart failed with
'Authentication required' until the user re-pasted their cookies.
Three pieces:
- src/auth/auth-flow.ts (new): restoreCredentials(service, storage) loads
the persisted credentials, calls service.validateCredentials() to
confirm they're still good, and on success calls
service.updateCredentials() to push them into the running service.
Returns a typed RestoreOutcome ('no_credentials' | 'invalid' |
'restored') for logging. Never throws.
- src/index.ts: invoke restoreCredentials(leetcodeService) at startup
before tools/prompts are registered. Best-effort: any failure leaves
the server unauthenticated as before.
- src/mcp/tools/auth-tools.ts: in check_auth_status, when validation
succeeds, also call leetcodeService.updateCredentials() so the very
next authenticated tool call works without forcing a server restart.
- tests/auth/auth-flow.test.ts (new, 5 tests): exercises restoreCredentials
with a mocked service+storage across the four outcome paths
(no_credentials / load_failed / expired / restored) plus the
validate-throws path
- tests/mcp/tools/auth-tools.test.ts: extend the existing
check_auth_status happy-path test to assert
service.updateCredentials() is called (regression guard for the
silent-logout bug)
- tests/services/{problem,solution}-services.test.ts: tighten access
patterns now that return types are concrete (optional chaining where
upstream fields can legitimately be missing). solution test asserts
toBeNull() for the invalid-topicId case to match
fetchSolutionArticleDetail's actual return.
Test count: 146 → 151 (5 new auth-flow tests). All 151 pass; test:types
passes; build clean; npm audit reports 0.
Drive-by formatting fix from running 'npm run format' during Phase 1. No behavior change.
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
SPerekrestova
left a comment
There was a problem hiding this comment.
Review summary
Phase 1 mostly does what the description claims — concrete types replace any, zod gates the upstream parses, and the silent-logout-on-restart hole is plugged at startup. The auth-flow test suite exercises the new helper well. A few concrete issues below; the first three are worth fixing before merge.
1. LeetcodeServiceInterface and its implementation disagree on two signatures
fetchUserAllSubmissions— interface declareslang?: string | nullandstatus?: string | null(leetcode-service-interface.ts:72-73), implementation declareslang?: stringandstatus?: string(leetcode-global-service.ts:115-116). A caller passingnullper the interface is rejected by the implementation — the implementation type is strictly narrower than the contract.fetchUserProgressQuestionList— interface requiresfilters: { offset: number; limit: number; ... }(filters and both numbers required,leetcode-service-interface.ts:87-92), implementation acceptsoptions?: { offset?: number; limit?: number; ... }(leetcode-global-service.ts:323-328). The default-coalescing in the body (options?.offset || 0) only does anything if the args are optional — under the interface as written the caller must always pass{offset, limit}even when they want defaults. Pick one and align both.
2. restoreCredentials swallows errors that can never escape
auth-flow.ts:45-56 wraps service.validateCredentials(...) in try/catch and maps any throw to {status: "invalid", reason: "expired"}. But the real LeetCodeGlobalService.validateCredentials already wraps everything in try { ... } catch { return null; } (leetcode-global-service.ts:705-707). The catch arm in restoreCredentials is dead code with the only existing implementation, and the auth-flow test at tests/auth/auth-flow.test.ts:78-94 only verifies that defensive code — i.e., it asserts behavior the production code path can't produce. Either remove the try/catch (rely on validateCredentials's string | null contract) or stop swallowing errors inside validateCredentials so the layered handling means something.
3. LeetCodeError.cause shadows the native ES2022 property instead of using it
src/types/errors.ts:39-49 redeclares public readonly cause?: unknown and assigns it manually rather than calling super(message, { cause }). With useDefineForClassFields (default for modern TS targets), the class field is installed on the instance via Object.defineProperty and shadows the inherited Error.cause. Tools/loggers that walk the standard chain (err.cause?.cause?...) will see whatever the field stores, but anything that relied on super(message, opts) setting it will be inconsistent. Prefer super(message, { cause }); this.code = code; and drop the field.
Smaller things
fetchProblemreturnsPromise<Problem>butfetchProblemSimplifiedimmediately checksif (!problem) throw PROBLEM_NOT_FOUND— meaning the upstream genuinely returns null and the tightened return type is a lie. Either narrowfetchProblemtoProblem | null(mirroring thefetchSolutionArticleDetailcleanup the PR description calls out) or havefetchProblemitself throw on miss.fetchUserStatus(leetcode-global-service.ts:102-107) defaultsusername: ""andavatar: ""on null. WithisSignedIn: falseandusername: ""consumers can't distinguish "signed out" from "signed in, no avatar". Marking thesestring | nullinUserStatuswould represent the upstream more honestly.restoreCredentialscallsservice.validateCredentialsand thenservice.updateCredentialson the same csrf/session it just validated. Thecheck_auth_statustool now does the same thing (auth-tools.ts:217-223). Worth pulling into a small helper to avoid the third copy when more entry points need it.submitSolutionvalidates thesubmitandcheckpayloads withsafeParse, butgetQuestionIddoes the same check inline rather than reusing the same pattern — minor consistency nit, not a bug.
Nothing security-sensitive jumped out — credentials are still short-lived, file mode is still 0o600, and zod parsing is passthrough so it can't widen a tainted field into a validated one.
Generated by Claude Code
Address review comments on PR #36: - fetchUserAllSubmissions: drop `| null` from interface lang/status so it matches the impl. No callers ever pass null. - fetchUserProgressQuestionList: tighten impl to require `filters: { offset: number; limit: number; ... }` matching the interface. The single caller (get_user_progress_questions tool) zod-defaults offset/limit, so they're always passed. - fetchProblem: now throws LeetCodeError(PROBLEM_NOT_FOUND) on missing upstream rather than returning a typed-but-actually-null Problem. Removes the contradictory shape where the impl returned null but the signature claimed `Promise<Problem>`. fetchProblemSimplified no longer needs its own null-check. - UserStatus.username and .avatar are now `string | null` (was `string` defaulted to ""). With `isSignedIn: false` and `username: ""` consumers couldn't distinguish 'signed out' from 'signed in, no display name'. No behavior change for happy paths; error paths now use the structured LeetCodeError instead of a stringified misshape.
Addresses review on PR #36: redeclaring 'public readonly cause?: unknown' on LeetCodeError shadowed the native ES2022 Error.cause field via the class-field installer (under useDefineForClassFields). Anything walking the standard chain via `err.cause` saw the right thing only by accident. Use `super(message, { cause })` and drop the field — same external API, but err.cause now refers to the actual native chain so loggers and debuggers that expect ES2022 semantics work consistently.
…ry/catch Addresses review on PR #36: - Extract validate->updateCredentials sequence into a small reusable helper applyValidatedCredentials(service, csrf, session) so both restoreCredentials() and check_auth_status share one implementation. - Drop the try/catch in restoreCredentials that mapped any throw to {status: 'invalid', reason: 'expired'}. The interface contract for validateCredentials is Promise<string | null>; the catch was dead code against any conforming impl. If a future impl violates the contract by throwing, surfacing the error is more useful than silently swallowing it. - Update auth-flow tests: replace the dead 'swallows the error when validate throws' test with one asserting that exceptions propagate; add unit tests for the new applyValidatedCredentials helper. Test count: 153 (was 151). All passing.
|
Thanks — all five concrete points addressed in three follow-up commits on this branch (a62a2e5, 5191a31, 6f3d1b3). 1. Interface vs impl signature drift — fixed both:
2. Dead try/catch in 3. Smaller items:
CI: 153/153 passing (was 151; +2 for |
a7b80ea
into
devin/1778098342-phase-0-hygiene
Summary
Phase 1 of the redesign plan — type safety, runtime validation, and the silent-logout bugfix. Stacked on top of #35 (Phase 0); base will auto-rebase to
mainonce #35 merges.What changes:
Concrete types in
src/types/(problem,user,solution,errors, plus anindexre-export). These describe the shapes returned byLeetcodeServiceInterfacemethods — the projected envelopes the service emits, not the raw upstream GraphQL payloads. Existingcredentials.tsandsubmission.tsare unchanged.Zod runtime validators in
src/leetcode/schemas.tsfor the four LeetCode response shapes the server actually parses today:SubmitResponseSchema(POST/problems/<slug>/submit/)CheckResponseSchema(GET/submissions/detail/<id>/check/)QuestionIdResponseSchema(questionId GraphQL query)ValidateCredentialsResponseSchema(userStatus GraphQL query)Each schema is
.passthrough()so unexpected fields don't fail the parse.CheckResponseSchemais intentionally loose onstatus_msg,code_answer, andexpected_answerbecause LeetCode legitimately omits / changes their shape between PENDING and SUCCESS states.Tightened
LeetcodeServiceInterface— everyPromise<any>(15+ of them) replaced with concrete typed returns drawn fromsrc/types/. Method names and arity unchanged; only return types tightened.fetchSolutionArticleDetail()now correctly returnsSolutionArticleDetail | null(LeetCode actually returnsnullfor unknown topicIds; the previousas ... | undefinedwas a lie).LeetCodeGlobalServiceupdated to satisfy the new interface:as anycasts replaced with proper projection / coercionnew Error("Authentication required")) replaced withLeetCodeError(ErrorCode.AUTH_REQUIRED, …)on every authenticated path;LeetCodeError(ErrorCode.PROBLEM_NOT_FOUND, …)on missing problemssubmitSolution()validates bothSubmitResponseandCheckResponseviasafeParse()and throwsLeetCodeError(ErrorCode.UPSTREAM_PAYLOAD_INVALID, …)(with the zod issue list as.cause) on schema failuregetQuestionId()validatesQuestionIdResponseSchemaand surfaces a typed error instead of a stringified shapevalidateCredentials()validatesValidateCredentialsResponseSchema— on schema failure logs a warning and returnsnull(preserves the previous null-on-failure contract for the auth path)runtime_percentile,total_correct, etc.) projected via?? undefinedso consumers seenumber | undefined, notnumber | null | undefinedSilent-logout-on-restart bugfix (the §3.2.1 issue from the assessment):
src/auth/auth-flow.ts(new):restoreCredentials(service, storage)loads persisted credentials, callsservice.validateCredentials()to confirm they're still good, and on success callsservice.updateCredentials()to push them into the running service. Returns a typedRestoreOutcome('no_credentials' | 'invalid' | 'restored') for logging. Never throws.src/index.ts: invokesrestoreCredentials(leetcodeService)at startup before tools/prompts are registered. Best-effort; any failure leaves the server unauthenticated as before.src/mcp/tools/auth-tools.ts: incheck_auth_status, when validation succeeds we now also callleetcodeService.updateCredentials()so the very next authenticated tool call works without a server restart.What this fixes: Before this PR, after a server restart
~/.leetcode-mcp/credentials.jsonwas loaded from disk and validated, the user was told they were "authenticated", but the cookies were never pushed into the in-memoryCredentialthe LeetCode client reads from. Every authenticated tool call then failed with "Authentication required" until the user re-pasted their cookies.Tests: 146 → 151 (5 new auth-flow tests). All 151 pass;
test:typesclean;npm auditreports 0; build clean.Review & Testing Checklist for Human
src/types/look reasonable (in particular,SubmissionRow.idisstring | numberbecauseleetcode-querytypes it asnumberwhile raw payloads can carry strings — verify this is what you want)fetch_user_status) without re-pasting cookies, confirm it succeedsLEETCODE_MCP_*defaults from the previous summary (none added in this PR — strict mode etc. land in later phases)submitresponse and confirmingLeetCodeError(UPSTREAM_PAYLOAD_INVALID, …)is thrown rather than a generic ErrorNotes
main.scripts/sync-version.cjsis in its own commit (chore: prettier sweep ...) so it can be reverted independently if undesired.tests/services/{problem,solution}-services.test.ts) where the oldPromise<any>return masked accesses that didn't typecheck; one assertion changed fromtoBeNull()against a stale.toBeUndefined()typo to.toBeNull()matching what LeetCode actually returns.StdioClientTransport+ nock) starts next.Link to Devin session: https://app.devin.ai/sessions/d003a60939484686b2953ae32fe2794d
Requested by: @SPerekrestova