feat(users): replace mock user helpers with the Prisma-backed Profile… - #154
Merged
3m1n3nc3 merged 1 commit intoAug 29, 2026
Conversation
… API UserController carried five private helpers that never touched a database: findUserById returned a hard-coded [email protected] record, updateUserProfile echoed the request body back, validatePassword returned false unconditionally, updateUserPassword threw "Not implemented", and updateUserWallet faked a persisted user. Worse, the fixture described a schema that does not exist — firstName/lastName/bio/avatar are not columns on User — so /users/me and /users/:id served a shape no query could produce, and the OpenAPI User / PublicUser / UpdateUserInput components documented that shape as real. The persistence was already there, built by earlier issues and explicitly deferred to this one by ADRs 0002 and 0003: LearnerProfile with its visibility model and serializers, OnboardingProgress and ConsentRecord, and the audited-mutation helper. - GET /users/me returns an owner aggregate: account identity, profile, profile completion, onboarding state and current consent per purpose, in one read. The account field list is closed (field-by-field copy, not a spread), so `password` and any column added to User later must be opted in to be disclosed. Completion and outstanding onboarding steps are computed on read, so they cannot disagree with the rows they summarise. - PATCH /users/me is bounded by one strict Zod object, shared with PATCH /users/me/profile so the two routes cannot diverge on what an owner may write. Account fields (status, isVerified, role, email, password, walletAddress) and internal columns (id, userId, archived*) are absent from the allow-list and therefore 400, not silently ignored. The write goes through auditedMutation and records which fields changed, never their values. - GET /users/:id layers two consent gates on the existing visibility threshold: only an ACTIVE account is disclosed, and an explicit data_sharing withdrawal overrides a stale `visibility: public`. Absence of a data_sharing record does not block disclosure — it is optional consent, and setting visibility above private is itself a deliberate choice. Every refusal returns the identical redacted stub, so a caller cannot tell a private profile from a withdrawn consent from a deactivated account. Reads via findFirst so the archive-exclusion extension applies. - PATCH /users/password verifies, rehashes, and revokes every session and refresh-token family in the same transaction as the password write. Doing the revocation separately would leave a window where the password has changed and the attacker's stolen session is still alive. - PATCH /users/wallet persists the learner's Stellar public key, is idempotent, and returns 409 for an address claimed elsewhere — checked up front for a clear error and again by catching P2002, since two concurrent claims both pass the up-front read. Also removes the now-dead mock-era request types, replaces the OpenAPI components, drops the "Preview / not-yet-implemented" banners, and unmounts (with a do-not-rewire comment) the deprecated validateProfileUpdate middleware. Verification evidence: tests/mock-user-scan.test.ts (static scan for mock literals, removed helpers and direct Prisma access from the controller), docs/evidence/profile-api-curl.txt (21 redacted requests against a running server on a disposable test database), and tests/integration/profile-api.test.ts (51 tests through the real app, middleware, JWT and database). Rationale and the debatable consent call are written up in docs/decisions/0004-profile-api.md. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(users): replace mock user helpers with the Prisma-backed Profile API
UserController carried five private helpers that never touched a database:
findUserById returned a hard-coded [email protected] record, updateUserProfile
echoed the request body back, validatePassword returned false unconditionally,
updateUserPassword threw "Not implemented", and updateUserWallet faked a
persisted user. Worse, the fixture described a schema that does not exist —
firstName/lastName/bio/avatar are not columns on User — so /users/me and
/users/:id served a shape no query could produce, and the OpenAPI User /
PublicUser / UpdateUserInput components documented that shape as real.
The persistence was already there, built by earlier issues and explicitly
deferred to this one by ADRs 0002 and 0003: LearnerProfile with its visibility
model and serializers, OnboardingProgress and ConsentRecord, and the
audited-mutation helper.
completion, onboarding state and current consent per purpose, in one read.
The account field list is closed (field-by-field copy, not a spread), so
passwordand any column added to User later must be opted in to bedisclosed. Completion and outstanding onboarding steps are computed on read,
so they cannot disagree with the rows they summarise.
PATCH /users/me/profile so the two routes cannot diverge on what an owner may
write. Account fields (status, isVerified, role, email, password,
walletAddress) and internal columns (id, userId, archived*) are absent from
the allow-list and therefore 400, not silently ignored. The write goes
through auditedMutation and records which fields changed, never their values.
only an ACTIVE account is disclosed, and an explicit data_sharing withdrawal
overrides a stale
visibility: public. Absence of a data_sharing record doesnot block disclosure — it is optional consent, and setting visibility above
private is itself a deliberate choice. Every refusal returns the identical
redacted stub, so a caller cannot tell a private profile from a withdrawn
consent from a deactivated account. Reads via findFirst so the
archive-exclusion extension applies.
refresh-token family in the same transaction as the password write. Doing the
revocation separately would leave a window where the password has changed and
the attacker's stolen session is still alive.
and returns 409 for an address claimed elsewhere — checked up front for a
clear error and again by catching P2002, since two concurrent claims both pass
the up-front read.
Also removes the now-dead mock-era request types, replaces the OpenAPI
components, drops the "Preview / not-yet-implemented" banners, and unmounts
(with a do-not-rewire comment) the deprecated validateProfileUpdate middleware.
Verification evidence: tests/mock-user-scan.test.ts (static scan for mock
literals, removed helpers and direct Prisma access from the controller),
docs/evidence/profile-api-curl.txt (21 redacted requests against a running
server on a disposable test database), and tests/integration/profile-api.test.ts
(51 tests through the real app, middleware, JWT and database). Rationale and
the debatable consent call are written up in docs/decisions/0004-profile-api.md.
closes #133