fix(settings): read the v0.3 username response shape

The v0.3 backend answers GET /users/me/username with {"username": ...};
the legacy backend sent a bare string. The page put the object into state
and rendered it, so /settings threw for every signed-in user.

parseUsernameResponse accepts both shapes and returns only a string or
null. Legacy-only fields on /auth/me are optional. Adds node:test cases,
run with npm test.
This commit is contained in:
2026-10-07 10:20:52 -04:00
parent eaf4c90cab
commit 3b8f03eb9c
6 changed files with 50 additions and 7 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "node --test --experimental-strip-types src/lib/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@such-software/smirk-dapp-api": "^0.3.0", "@such-software/smirk-dapp-api": "^0.3.0",
+2 -2
View File
@@ -150,8 +150,8 @@ export default function Home() {
.then((userData) => { .then((userData) => {
setUser({ setUser({
id: userData.id, id: userData.id,
telegram_id: userData.telegram_id, telegram_id: userData.telegram_id ?? null,
telegram_username: userData.telegram_username, telegram_username: userData.telegram_username ?? null,
}); });
setStep('logged-in'); setStep('logged-in');
}) })
+8 -4
View File
@@ -1,3 +1,5 @@
import { parseUsernameResponse } from './username';
// API client for Smirk backend // API client for Smirk backend
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
@@ -24,9 +26,11 @@ export interface AuthResponse {
export interface MeResponse { export interface MeResponse {
id: string; id: string;
username: string | null; username?: string | null;
telegram_id: number | null; /** Legacy backend only; absent on the v0.3 backend. */
telegram_username: string | null; telegram_id?: number | null;
/** Legacy backend only; absent on the v0.3 backend. */
telegram_username?: string | null;
} }
export async function getChallenge(origin: string): Promise<ChallengeResponse> { export async function getChallenge(origin: string): Promise<ChallengeResponse> {
@@ -353,7 +357,7 @@ export async function getMyUsername(token: string): Promise<string | null> {
throw new Error('Failed to get username'); throw new Error('Failed to get username');
} }
return res.json(); return parseUsernameResponse(await res.json().catch(() => null));
} }
export async function setUsername(token: string, username: string): Promise<{ username: string }> { export async function setUsername(token: string, username: string): Promise<{ username: string }> {
+22
View File
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseUsernameResponse } from './username.ts';
test('accepts the v0.3 object shape', () => {
assert.equal(parseUsernameResponse({ username: 'alice' }), 'alice');
assert.equal(parseUsernameResponse({ username: null }), null);
});
test('accepts the legacy bare string shape', () => {
assert.equal(parseUsernameResponse('alice'), 'alice');
assert.equal(parseUsernameResponse(null), null);
});
test('never returns a non-string', () => {
for (const input of [undefined, '', 42, true, [], {}, { username: 7 }, { username: { a: 1 } }]) {
const result = parseUsernameResponse(input);
assert.ok(result === null || typeof result === 'string');
assert.equal(result, null);
}
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Normalize a `GET /users/me/username` response to a username or null.
*
* The legacy backend answered with a bare JSON string (or null). The v0.3
* backend answers with `{ "username": string | null }`. Anything else, including
* an empty string, is treated as "no username" so a shape change can never put a
* non-string into React state and crash the page.
*/
export function parseUsernameResponse(data: unknown): string | null {
const value =
data !== null && typeof data === 'object' && 'username' in data
? (data as { username: unknown }).username
: data;
return typeof value === 'string' && value.length > 0 ? value : null;
}
+1
View File
@@ -10,6 +10,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",