Nexo P1 fix — align HTTP client with the real server contract

The bootstrap client was reading error envelopes flat (`payload.code`,
`payload.message`) but the dating server wraps them in
`{ ok: false, error: { code, message, details } }` (see
`servers/dating/http.mjs:handleError`). Result: a real 400 like an
invalid login showed up in the console as a generic
`POST /api/auth/login 400` with the structured `error.code`
discarded — UI couldn't branch on `invalid_credentials` and the user
saw the bare HTTP status.

Several success-shape mismatches surfaced during the audit:

- `/api/auth/login` returns `{ ok: true, user }` (no `profile`,
  no `token`). The client now plucks `user`, defaults
  `profile: null`, and pages call `nexo.refreshSession()` to fetch
  the profile in a second round trip.
- `/api/auth/register` is identical — same fix, same flow.
- `/api/session` returns `{ authenticated, user }` only; the
  profile lives at `/api/profile/me`. `client.session()` now
  fetches both transparently so consumers see a populated
  `DatingSessionResponse`.
- `/api/profile/me` (and every photo endpoint) returns
  `{ profile }` not the bare profile. Each method unwraps.
- `/api/discover` accepts `ageMin` / `ageMax` (not `minAge` /
  `maxAge`). Returns `{ profiles, totalItems }` (not
  `nextCursor`). The TypeScript surface keeps the natural English
  names; the translation lives in `discoverQuery`.
- `/api/likes` returns `{ like, match }`; client maps `liked` from
  `like.state === 'like'`.
- `/api/matches` returns `{ matches }`; client returns
  `payload.matches`.
- `/api/matches/:id/messages` (POST) returns `{ message }`;
  GET returns `{ messages }`.
- `/api/profile/photos` reads `form.getAll('photos')` — client now
  appends as `photos`, not `file`.
- Auth login form: server reads `body.identity || body.email`. The
  client sends both keys for forward/backward compatibility.

Login + register pages no longer try to assemble the session
manually from the auth response — they call `nexo.refreshSession()`
so the layout's session cell goes through the same path as a cold
session restore. That keeps the "user authenticated, profile not
yet completed" branch consistent across cold-load and post-login.

Gates: 1695 tests + check (0/0) + build + bundle 22.52 KB +
aliases — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 9a1aa14ef7
commit 9399cc58a6

@ -6,9 +6,18 @@
* `dating_session` HTTP-only cookie — every request goes out with
* `credentials: 'include'`.
*
* Errors are normalised into `DatingApiError`. Network / abort
* failures bubble out as the same class with `code: 'network_error'`
* so call sites only need one catch branch.
* Response shape conventions, derived from `servers/dating/routes.mjs`:
*
* - **Errors** are always `{ ok: false, error: { code, message, details? } }`
* with the appropriate HTTP status. The client maps them onto
* `DatingApiError` so call sites only need a single catch branch
* keyed on `error.code`.
* - **Successes** are not wrapped consistently — some endpoints return
* the raw payload (`{ profile }`, `{ matches }`, `{ message }`,
* `{ like, match }`), others piggy-back an `ok: true` flag onto the
* response (auth endpoints). Each method below knows exactly which
* field it needs to pluck so the typed return matches what call
* sites actually use, no envelope detail leaks out of this module.
*/
import {
@ -33,7 +42,7 @@ import {
type DatingReportsResponse,
type DatingResetInput,
type DatingSessionResponse,
type DatingApiErrorPayload
type DatingUser
} from './types.ts';
export const DATING_API_DEFAULT_BASE = 'http://127.0.0.1:8787';
@ -84,6 +93,25 @@ export class DatingApiError extends Error {
}
}
interface ServerErrorEnvelope {
readonly ok: false;
readonly error: {
readonly code?: string;
readonly message?: string;
readonly details?: Readonly<Record<string, unknown>>;
};
}
function isErrorEnvelope(value: unknown): value is ServerErrorEnvelope {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as { ok?: unknown; error?: unknown };
return (
candidate.ok === false &&
typeof candidate.error === 'object' &&
candidate.error !== null
);
}
export interface DatingApiClient {
// auth
register(input: DatingRegisterInput): Promise<DatingLoginResponse>;
@ -135,6 +163,14 @@ export function createDatingApiClient(
const base = options.base ?? DATING_API_DEFAULT_BASE;
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
async function request<T>(
path: string,
init: RequestInit = {},
@ -152,25 +188,32 @@ export function createDatingApiClient(
throw DatingApiError.network(`Request to ${path} failed`, cause);
}
// The dating server returns JSON on success AND on error. Read
// the body once and decide based on `response.ok` — that's the
// only place the structured `error.code` is available.
const text = await response.text();
const payload: unknown = text === '' ? null : safeJson(text);
if (!response.ok) {
let payload: Partial<DatingApiErrorPayload> = {};
try {
payload = (await response.json()) as DatingApiErrorPayload;
} catch {
// non-JSON body; fall back to status text below
if (isErrorEnvelope(payload)) {
throw new DatingApiError(
payload.error.code ?? 'http_error',
payload.error.message ?? response.statusText ?? 'Request failed',
response.status,
payload.error.details ?? {}
);
}
throw new DatingApiError(
payload.code ?? 'http_error',
payload.message ?? response.statusText ?? 'Request failed',
response.status,
payload.details ?? {}
'http_error',
response.statusText || `Request failed with ${response.status}`,
response.status
);
}
if (expect === 'void' || response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
return payload as T;
}
function jsonInit(method: string, body?: unknown): RequestInit {
@ -185,8 +228,12 @@ export function createDatingApiClient(
if (filters === undefined) return '';
const params = new URLSearchParams();
if (filters.intent !== undefined) params.set('intent', filters.intent);
if (filters.minAge !== undefined) params.set('minAge', String(filters.minAge));
if (filters.maxAge !== undefined) params.set('maxAge', String(filters.maxAge));
// The server query parameter names are `ageMin` / `ageMax` (see
// `servers/dating/routes.mjs:discoverGet`). The TypeScript
// fields stay `minAge` / `maxAge` because that's natural English
// reading order; the translation lives here.
if (filters.minAge !== undefined) params.set('ageMin', String(filters.minAge));
if (filters.maxAge !== undefined) params.set('ageMax', String(filters.maxAge));
if (filters.q !== undefined && filters.q !== '') params.set('q', filters.q);
if (filters.cursor !== undefined && filters.cursor !== '')
params.set('cursor', filters.cursor);
@ -194,100 +241,197 @@ export function createDatingApiClient(
return query === '' ? '' : `?${query}`;
}
return {
register(input) {
return request<DatingLoginResponse>('/api/auth/register', jsonInit('POST', input));
const client: DatingApiClient = {
async register(input) {
const payload = await request<{ user: DatingUser }>(
'/api/auth/register',
jsonInit('POST', input)
);
// Auth endpoints return only the `user`. The profile is
// loaded separately on the next session probe.
return { user: payload.user, profile: null };
},
login(input) {
return request<DatingLoginResponse>('/api/auth/login', jsonInit('POST', input));
async login(input) {
// Server normalises identity / email under the hood. Send
// both names so older deployments that only accept
// `identity` keep working.
const payload = await request<{ user: DatingUser }>(
'/api/auth/login',
jsonInit('POST', { identity: input.email, ...input })
);
return { user: payload.user, profile: null };
},
logout() {
return request<void>('/api/auth/logout', jsonInit('POST'), 'void');
},
resetRequest(input) {
return request<void>('/api/auth/reset', jsonInit('POST', input), 'void');
},
mfaVerify(input) {
return request<DatingSessionResponse>(
'/api/auth/mfa/verify',
jsonInit('POST', input)
);
},
session() {
return request<DatingSessionResponse>('/api/session');
async session() {
// Session endpoint returns either `{ authenticated: false }`
// or `{ authenticated: true, user }`. The profile lives at
// `/api/profile/me`; we fetch it inline so callers see a
// fully populated `DatingSessionResponse`.
const payload = await request<
{ authenticated: false } | { authenticated: true; user: DatingUser }
>('/api/session');
if (payload.authenticated === false) return { authenticated: false };
let profile: DatingProfile | null = null;
try {
profile = await client.getMyProfile();
} catch (error) {
// `profile_required` is the server's "not yet onboarded"
// signal; everything else is a real transport problem
// the caller should see, but for a session-restore
// probe we degrade silently and let the subsequent
// page-level guard surface it.
if (
!(error instanceof DatingApiError) ||
(error.code !== 'profile_required' && error.code !== 'http_error')
) {
throw error;
}
}
return { authenticated: true, user: payload.user, profile };
},
getMyProfile() {
return request<DatingProfile | null>('/api/profile/me');
async getMyProfile() {
const payload = await request<{ profile: DatingProfile | null }>('/api/profile/me');
return payload.profile;
},
updateMyProfile(input) {
return request<DatingProfile>('/api/profile/me', jsonInit('PUT', input));
async updateMyProfile(input) {
const payload = await request<{ profile: DatingProfile }>(
'/api/profile/me',
jsonInit('PUT', input)
);
return payload.profile;
},
uploadPhoto(file) {
async uploadPhoto(file) {
const form = new FormData();
form.append('file', file);
return request<DatingProfile>('/api/profile/photos', { method: 'POST', body: form });
// Server reads `form.getAll('photos')` — see
// `servers/dating/routes.mjs:profilePhotosPost`. Keep the
// field name aligned even when the client only sends one
// file at a time.
form.append('photos', file);
const payload = await request<{ profile: DatingProfile }>(
'/api/profile/photos',
{ method: 'POST', body: form }
);
return payload.profile;
},
deletePhoto(filename) {
return request<DatingProfile>(
async deletePhoto(filename) {
const payload = await request<{ profile: DatingProfile }>(
`/api/profile/photos/${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
);
return payload.profile;
},
reorderPhotos(order) {
return request<DatingProfile>(
async reorderPhotos(order) {
const payload = await request<{ profile: DatingProfile }>(
'/api/profile/photos/order',
jsonInit('PATCH', { order: [...order] })
jsonInit('PATCH', { photos: [...order] })
);
return payload.profile;
},
setPrimaryPhoto(filename) {
return request<DatingProfile>(
async setPrimaryPhoto(filename) {
const payload = await request<{ profile: DatingProfile }>(
'/api/profile/photos/main',
jsonInit('PATCH', { filename })
);
return payload.profile;
},
discover(filters) {
return request<DatingDiscoverResponse>(`/api/discover${discoverQuery(filters)}`);
async discover(filters) {
const payload = await request<{
profiles: readonly DatingProfile[];
totalItems?: number;
}>(`/api/discover${discoverQuery(filters)}`);
// `totalItems` is exposed by the server but the public
// surface keeps the shape pagination-ready. The cursor
// stays `null` until the server lands real cursoring.
return { profiles: payload.profiles, nextCursor: null };
},
like(input) {
return request<DatingLikeResponse>('/api/likes', jsonInit('POST', input));
async like(input) {
const payload = await request<{
like: { state: 'like' | 'pass' };
match: DatingMatch | null;
}>('/api/likes', jsonInit('POST', input));
return { liked: payload.like.state === 'like', match: payload.match };
},
matches() {
return request<readonly DatingMatch[]>('/api/matches');
async matches() {
const payload = await request<{ matches: readonly DatingMatch[] }>('/api/matches');
return payload.matches;
},
messages(matchId, cursor) {
async messages(matchId, cursor) {
const path = `/api/matches/${encodeURIComponent(matchId)}/messages${
cursor === undefined || cursor === '' ? '' : `?cursor=${encodeURIComponent(cursor)}`
}`;
return request<DatingMessagesResponse>(path);
const payload = await request<{
messages: readonly DatingMessage[];
cursor?: string | null;
}>(path);
return { messages: payload.messages, cursor: payload.cursor ?? null };
},
sendMessage(matchId, input) {
return request<DatingMessage>(
async sendMessage(matchId, input) {
const payload = await request<{ message: DatingMessage }>(
`/api/matches/${encodeURIComponent(matchId)}/messages`,
jsonInit('POST', input)
);
return payload.message;
},
block(input) {
return request<void>('/api/safety/block', jsonInit('POST', input), 'void');
},
report(input) {
return request<DatingReport>('/api/safety/report', jsonInit('POST', input));
async report(input) {
const payload = await request<{ report: DatingReport }>(
'/api/safety/report',
jsonInit('POST', input)
);
return payload.report;
},
adminReports() {
return request<DatingReportsResponse>('/api/admin/reports');
async adminReports() {
const payload = await request<{ reports: readonly DatingReport[] }>(
'/api/admin/reports'
);
return { reports: payload.reports };
},
adminResolveReport(reportId, input) {
return request<DatingReport>(
async adminResolveReport(reportId, input) {
const payload = await request<{ report: DatingReport }>(
`/api/admin/reports/${encodeURIComponent(reportId)}/resolve`,
jsonInit('POST', input)
);
return payload.report;
},
devtoolsSnapshot() {
return request<Readonly<Record<string, unknown>>>('/api/devtools/snapshot');
}
};
return client;
}

@ -50,15 +50,17 @@
submitting = true;
error = null;
try {
const response = await nexo.api.login({ email: email.trim(), password });
nexo.setSession({
authenticated: true,
user: response.user,
profile: response.profile,
token: response.token
});
await nexo.api.login({ email: email.trim(), password });
// Re-issue the session probe so the cell sees the freshly
// authenticated user AND their profile (which the
// `/api/auth/login` response intentionally does not
// include — it's loaded by `getMyProfile()` inside
// `api.session()`).
const session = await nexo.refreshSession();
const next =
response.profile === null || !response.profile.completed
session?.authenticated !== true ||
session.profile === null ||
!session.profile.completed
? '/dating/onboarding'
: '/dating/discover';
await goto(next, { replaceState: true });

@ -78,19 +78,18 @@
submitting = true;
topError = null;
try {
const response = await nexo.api.register({
await nexo.api.register({
displayName: displayName.trim(),
email: email.trim(),
password,
passwordConfirm,
adultConfirmed
});
nexo.setSession({
authenticated: true,
user: response.user,
profile: response.profile,
token: response.token
});
// Refresh the session cell so the layout sees the freshly
// created user (the register endpoint sets the session
// cookie but doesn't send the profile, which doesn't exist
// yet anyway).
await nexo.refreshSession();
await goto('/dating/onboarding', { replaceState: true });
} catch (caught) {
if (caught instanceof DatingApiError) {

Loading…
Cancel
Save

Powered by TurnKey Linux.