You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
465 lines
15 KiB
465 lines
15 KiB
/**
|
|
* Typed HTTP client over the standalone Nexo server (`servers/dating`).
|
|
*
|
|
* The dating server lives at `http://127.0.0.1:8787` by default
|
|
* (configurable through `DATING_API_BASE`) and authenticates via the
|
|
* `dating_session` HTTP-only cookie — every request goes out with
|
|
* `credentials: 'include'`.
|
|
*
|
|
* 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 {
|
|
type DatingBlockInput,
|
|
type DatingDiscoverFilters,
|
|
type DatingDiscoverResponse,
|
|
type DatingLikeInput,
|
|
type DatingLikeResponse,
|
|
type DatingLoginInput,
|
|
type DatingLoginResponse,
|
|
type DatingMatch,
|
|
type DatingMessage,
|
|
type DatingMessageInput,
|
|
type DatingMessagesResponse,
|
|
type DatingMfaVerifyInput,
|
|
type DatingModerationResolveInput,
|
|
type DatingProfile,
|
|
type DatingProfileUpdateInput,
|
|
type DatingRegisterInput,
|
|
type DatingReport,
|
|
type DatingReportInput,
|
|
type DatingReportsResponse,
|
|
type DatingResetInput,
|
|
type DatingSessionResponse,
|
|
type DatingUser
|
|
} from './types.ts';
|
|
|
|
/** Default port the standalone dating server listens on. */
|
|
export const DATING_API_DEFAULT_PORT = 8787;
|
|
|
|
/**
|
|
* Resolve the API base URL.
|
|
*
|
|
* Browsers treat `localhost` and `127.0.0.1` as different sites, so
|
|
* the session cookie's `SameSite=Lax` policy drops it on cross-origin
|
|
* fetches between them — the user logs in, the cookie is set on the
|
|
* 127.0.0.1 origin, and the next `/api/session` probe from
|
|
* localhost:5173 lands without it. Result: login looks like it
|
|
* silently does nothing.
|
|
*
|
|
* To stay same-site without touching the server, reuse whatever
|
|
* hostname the page is already on (`localhost` ↔ `localhost`,
|
|
* `127.0.0.1` ↔ `127.0.0.1`). On non-browser runtimes (SSR, vitest)
|
|
* fall back to the explicit `127.0.0.1` since `window` doesn't exist.
|
|
*/
|
|
export function resolveDatingApiBase(port: number = DATING_API_DEFAULT_PORT): string {
|
|
if (typeof window !== 'undefined' && window.location?.hostname) {
|
|
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
|
return `${protocol}//${window.location.hostname}:${port}`;
|
|
}
|
|
return `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
/** @deprecated Use `resolveDatingApiBase()`. Kept for callers that import the literal. */
|
|
export const DATING_API_DEFAULT_BASE = `http://127.0.0.1:${DATING_API_DEFAULT_PORT}`;
|
|
|
|
export interface DatingApiClientOptions {
|
|
/** Base URL of the standalone dating server. Defaults to `DATING_API_DEFAULT_BASE`. */
|
|
readonly base?: string;
|
|
/**
|
|
* Override the global `fetch`. Useful for SSR (`event.fetch`) and for
|
|
* test harnesses that mock the network layer.
|
|
*/
|
|
readonly fetch?: typeof fetch;
|
|
/**
|
|
* Per-request signal injection point. Each method falls back to
|
|
* `signal` from this hook if the call site doesn't pass one
|
|
* explicitly — wires `arts/timer`-driven cancellation cleanly
|
|
* without threading AbortController through every site.
|
|
*/
|
|
readonly signal?: () => AbortSignal | undefined;
|
|
}
|
|
|
|
/**
|
|
* Concrete client error. Carries the structured `code` from the
|
|
* server's error envelope so UI can branch on a stable identifier
|
|
* (`weak_password`, `email_already_used`, …) rather than parsing
|
|
* messages.
|
|
*/
|
|
export class DatingApiError extends Error {
|
|
readonly code: string;
|
|
readonly status: number;
|
|
readonly details: Readonly<Record<string, unknown>>;
|
|
|
|
constructor(
|
|
code: string,
|
|
message: string,
|
|
status: number,
|
|
details: Readonly<Record<string, unknown>> = {}
|
|
) {
|
|
super(message);
|
|
this.name = 'DatingApiError';
|
|
this.code = code;
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
|
|
static network(message: string, cause?: unknown): DatingApiError {
|
|
return new DatingApiError('network_error', message, 0, { cause: String(cause ?? '') });
|
|
}
|
|
}
|
|
|
|
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>;
|
|
login(input: DatingLoginInput): Promise<DatingLoginResponse>;
|
|
logout(): Promise<void>;
|
|
resetRequest(input: DatingResetInput): Promise<void>;
|
|
mfaVerify(input: DatingMfaVerifyInput): Promise<DatingSessionResponse>;
|
|
session(): Promise<DatingSessionResponse>;
|
|
|
|
// profile
|
|
getMyProfile(): Promise<DatingProfile | null>;
|
|
updateMyProfile(input: DatingProfileUpdateInput): Promise<DatingProfile>;
|
|
uploadPhoto(file: File): Promise<DatingProfile>;
|
|
deletePhoto(filename: string): Promise<DatingProfile>;
|
|
reorderPhotos(order: readonly string[]): Promise<DatingProfile>;
|
|
setPrimaryPhoto(filename: string): Promise<DatingProfile>;
|
|
|
|
// discover + matches
|
|
discover(filters?: DatingDiscoverFilters): Promise<DatingDiscoverResponse>;
|
|
like(input: DatingLikeInput): Promise<DatingLikeResponse>;
|
|
matches(): Promise<readonly DatingMatch[]>;
|
|
messages(matchId: string, cursor?: string): Promise<DatingMessagesResponse>;
|
|
sendMessage(matchId: string, input: DatingMessageInput): Promise<DatingMessage>;
|
|
|
|
// safety
|
|
block(input: DatingBlockInput): Promise<void>;
|
|
report(input: DatingReportInput): Promise<DatingReport>;
|
|
|
|
// admin
|
|
adminReports(): Promise<DatingReportsResponse>;
|
|
adminResolveReport(
|
|
reportId: string,
|
|
input: DatingModerationResolveInput
|
|
): Promise<DatingReport>;
|
|
|
|
// devtools
|
|
devtoolsSnapshot(): Promise<Readonly<Record<string, unknown>>>;
|
|
}
|
|
|
|
/**
|
|
* `createDatingApiClient` returns a thin typed wrapper that always
|
|
* sends `credentials: 'include'`. The factory is intentionally
|
|
* unaware of `arts/active-app` — apps wire it into `App.http` (or
|
|
* keep it standalone) at composition time.
|
|
*/
|
|
export function createDatingApiClient(
|
|
options: DatingApiClientOptions = {}
|
|
): DatingApiClient {
|
|
const base = options.base ?? resolveDatingApiBase();
|
|
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 = {},
|
|
expect: 'json' | 'void' = 'json'
|
|
): Promise<T> {
|
|
const url = `${base}${path}`;
|
|
let response: Response;
|
|
try {
|
|
response = await doFetch(url, {
|
|
credentials: 'include',
|
|
...init,
|
|
signal: init.signal ?? options.signal?.()
|
|
});
|
|
} catch (cause) {
|
|
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) {
|
|
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(
|
|
'http_error',
|
|
response.statusText || `Request failed with ${response.status}`,
|
|
response.status
|
|
);
|
|
}
|
|
|
|
if (expect === 'void' || response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return payload as T;
|
|
}
|
|
|
|
function jsonInit(method: string, body?: unknown): RequestInit {
|
|
return {
|
|
method,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
};
|
|
}
|
|
|
|
function discoverQuery(filters?: DatingDiscoverFilters): string {
|
|
if (filters === undefined) return '';
|
|
const params = new URLSearchParams();
|
|
if (filters.intent !== undefined) params.set('intent', filters.intent);
|
|
// 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);
|
|
const query = params.toString();
|
|
return query === '' ? '' : `?${query}`;
|
|
}
|
|
|
|
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 };
|
|
},
|
|
|
|
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)
|
|
);
|
|
},
|
|
|
|
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 };
|
|
},
|
|
|
|
async getMyProfile() {
|
|
const payload = await request<{ profile: DatingProfile | null }>('/api/profile/me');
|
|
return payload.profile;
|
|
},
|
|
|
|
async updateMyProfile(input) {
|
|
const payload = await request<{ profile: DatingProfile }>(
|
|
'/api/profile/me',
|
|
jsonInit('PUT', input)
|
|
);
|
|
return payload.profile;
|
|
},
|
|
|
|
async uploadPhoto(file) {
|
|
const form = new FormData();
|
|
// 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;
|
|
},
|
|
|
|
async deletePhoto(filename) {
|
|
const payload = await request<{ profile: DatingProfile }>(
|
|
`/api/profile/photos/${encodeURIComponent(filename)}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
return payload.profile;
|
|
},
|
|
|
|
async reorderPhotos(order) {
|
|
const payload = await request<{ profile: DatingProfile }>(
|
|
'/api/profile/photos/order',
|
|
jsonInit('PATCH', { photos: [...order] })
|
|
);
|
|
return payload.profile;
|
|
},
|
|
|
|
async setPrimaryPhoto(filename) {
|
|
const payload = await request<{ profile: DatingProfile }>(
|
|
'/api/profile/photos/main',
|
|
jsonInit('PATCH', { filename })
|
|
);
|
|
return payload.profile;
|
|
},
|
|
|
|
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 };
|
|
},
|
|
|
|
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 };
|
|
},
|
|
|
|
async matches() {
|
|
const payload = await request<{ matches: readonly DatingMatch[] }>('/api/matches');
|
|
return payload.matches;
|
|
},
|
|
|
|
async messages(matchId, cursor) {
|
|
const path = `/api/matches/${encodeURIComponent(matchId)}/messages${
|
|
cursor === undefined || cursor === '' ? '' : `?cursor=${encodeURIComponent(cursor)}`
|
|
}`;
|
|
const payload = await request<{
|
|
messages: readonly DatingMessage[];
|
|
cursor?: string | null;
|
|
}>(path);
|
|
return { messages: payload.messages, cursor: payload.cursor ?? null };
|
|
},
|
|
|
|
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');
|
|
},
|
|
|
|
async report(input) {
|
|
const payload = await request<{ report: DatingReport }>(
|
|
'/api/safety/report',
|
|
jsonInit('POST', input)
|
|
);
|
|
return payload.report;
|
|
},
|
|
|
|
async adminReports() {
|
|
const payload = await request<{ reports: readonly DatingReport[] }>(
|
|
'/api/admin/reports'
|
|
);
|
|
return { reports: payload.reports };
|
|
},
|
|
|
|
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;
|
|
}
|