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

/**
* 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;
}

Powered by TurnKey Linux.