diff --git a/package.json b/package.json index 508ecfd..e26b512 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "node --test --experimental-strip-types src/lib/*.test.ts" }, "dependencies": { "@such-software/smirk-dapp-api": "^0.3.0", diff --git a/src/app/page.tsx b/src/app/page.tsx index f003f2f..1b49515 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -150,8 +150,8 @@ export default function Home() { .then((userData) => { setUser({ id: userData.id, - telegram_id: userData.telegram_id, - telegram_username: userData.telegram_username, + telegram_id: userData.telegram_id ?? null, + telegram_username: userData.telegram_username ?? null, }); setStep('logged-in'); }) diff --git a/src/lib/api.ts b/src/lib/api.ts index f13f4b5..7d25f7a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,5 @@ +import { parseUsernameResponse } from './username'; + // API client for Smirk backend const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; @@ -24,9 +26,11 @@ export interface AuthResponse { export interface MeResponse { id: string; - username: string | null; - telegram_id: number | null; - telegram_username: string | null; + username?: string | null; + /** Legacy backend only; absent on the v0.3 backend. */ + telegram_id?: number | null; + /** Legacy backend only; absent on the v0.3 backend. */ + telegram_username?: string | null; } export async function getChallenge(origin: string): Promise { @@ -353,7 +357,7 @@ export async function getMyUsername(token: string): Promise { 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 }> { diff --git a/src/lib/username.test.ts b/src/lib/username.test.ts new file mode 100644 index 0000000..908b4d5 --- /dev/null +++ b/src/lib/username.test.ts @@ -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); + } +}); diff --git a/src/lib/username.ts b/src/lib/username.ts new file mode 100644 index 0000000..8167ac4 --- /dev/null +++ b/src/lib/username.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json index 34939b2..5f77cd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "skipLibCheck": true, "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler",