This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3d6b0e0d4e
commit 5386bc51d1
65 changed files with 4058 additions and 161 deletions
+14 -2
View File
@@ -19,7 +19,8 @@
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@sport/types": "workspace:*"
@@ -29,6 +30,17 @@
"@sport/eslint-config": "workspace:*",
"@types/node": "^22.19.0",
"eslint": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"@types/jest": "^30.0.0"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": [
"<rootDir>/src"
],
"testRegex": ".*\\.spec\\.ts$"
}
}
+6
View File
@@ -1,4 +1,6 @@
import { HttpClient, type HttpClientOptions } from './http-client';
import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createHealthResource, type HealthResource } from './resources/health';
@@ -12,6 +14,8 @@ export interface ApiClient {
readonly http: HttpClient;
readonly health: HealthResource;
readonly catalog: CatalogResource;
readonly auth: AuthResource;
readonly admin: AdminResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -21,5 +25,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
http,
health: createHealthResource(http),
catalog: createCatalogResource(http),
auth: createAuthResource(http),
admin: createAdminResource(http),
};
}
@@ -0,0 +1,89 @@
import { HttpClient } from './http-client';
/**
* Regression tests for two bugs that only manifested in a browser, and so
* survived every server-side and curl-based check.
*/
describe('HttpClient', () => {
const okResponse = () =>
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
it('calls the default fetch with the global receiver, not the client instance', async () => {
// Browsers require `fetch` to be invoked with Window as `this`. Storing
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
// passes the HttpClient as the receiver and throws "Illegal invocation" —
// in the browser only. Node does not care, which is precisely why every
// server-side check and every curl passed while the browser was broken.
const original = globalThis.fetch;
const receivers: unknown[] = [];
globalThis.fetch = function (this: unknown) {
// Pushed rather than assigned to a local: capturing the receiver is the
// point of the test, and a plain alias trips `no-this-alias`.
receivers.push(this);
return Promise.resolve(okResponse());
} as unknown as typeof fetch;
try {
// No fetchImpl — exercise the default path, which is the one that broke.
const client = new HttpClient({ baseUrl: 'http://api.test' });
await client.get('/thing');
} finally {
globalThis.fetch = original;
}
expect(receivers).toHaveLength(1);
expect(receivers[0]).toBe(globalThis);
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
});
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
// is itself retryable, its own 401 triggers another refresh — an unbounded
// loop that hammers the API from the browser.
let calls = 0;
let refreshes = 0;
const fetchImpl = (() => {
calls += 1;
return Promise.resolve(new Response('{}', { status: 401 }));
}) as unknown as typeof fetch;
const client = new HttpClient({
baseUrl: 'http://api.test',
fetchImpl,
onUnauthorized: () => {
refreshes += 1;
return false;
},
});
await expect(
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
).rejects.toThrow();
expect(calls).toBe(1);
expect(refreshes).toBe(0);
});
it('still offers one retry for ordinary requests', async () => {
let calls = 0;
const fetchImpl = (() => {
calls += 1;
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
}) as unknown as typeof fetch;
const client = new HttpClient({
baseUrl: 'http://api.test',
fetchImpl,
onUnauthorized: () => true,
});
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
expect(calls).toBe(2);
});
});
+61 -5
View File
@@ -3,7 +3,15 @@ import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
import { ApiClientError } from './errors';
export interface HttpClientOptions {
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */
/**
* Origin, e.g. `http://localhost:4000` — or an empty string to call the
* current page's own origin.
*
* Same-origin is what the browser client uses: the refresh cookie is
* `SameSite=Lax` and therefore only sent first-party, so requests must go
* through the app's own host (Nginx in production, a Next.js rewrite in
* development) rather than directly to the API host.
*/
baseUrl: string;
/** Defaults to `v1`. */
apiVersion?: string;
@@ -13,7 +21,13 @@ export interface HttpClientOptions {
onUnauthorized?: () => boolean | Promise<boolean>;
defaultHeaders?: Record<string, string>;
timeoutMs?: number;
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
/**
* Injectable for tests and for runtimes with a patched fetch.
*
* Must be independently callable — pass a mock, or `window.fetch.bind(window)`.
* A bare `window.fetch` reference throws "Illegal invocation" in the browser
* because it loses its `Window` receiver.
*/
fetchImpl?: typeof fetch;
}
@@ -26,6 +40,15 @@ export interface RequestOptions {
cache?: RequestCache;
/** Send cookies (used by the refresh-token flow). */
credentials?: RequestCredentials;
/**
* Opts this request out of the automatic re-authentication retry.
*
* MANDATORY on the auth endpoints themselves. `onUnauthorized` refreshes by
* calling `/auth/refresh`; if that call is itself eligible for the retry,
* its own 401 triggers another refresh, which 401s, which triggers another —
* an unbounded loop that hammers the API from the browser. Ask how I know.
*/
skipAuthRetry?: boolean;
}
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -42,7 +65,17 @@ export class HttpClient {
this.options = options;
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiVersion = options.apiVersion ?? 'v1';
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
/**
* Wrapped, never stored bare.
*
* `globalThis.fetch` must be invoked with `Window` as its receiver in the
* browser. Assigning it to an instance property and calling
* `this.fetchImpl(...)` invokes it with the HttpClient as `this`, which
* throws "Illegal invocation" — in the browser only. Node does not care,
* so server-side rendering and curl both worked while every click in the
* browser silently failed as a network error.
*/
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
}
get<T>(path: string, options?: RequestOptions): Promise<T> {
@@ -106,7 +139,12 @@ export class HttpClient {
throw ApiClientError.network(cause);
}
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
if (
response.status === 401 &&
!isRetry &&
!options?.skipAuthRetry &&
this.options.onUnauthorized
) {
const shouldRetry = await this.options.onUnauthorized();
if (shouldRetry) {
return this.request<T>(method, path, body, options, true);
@@ -122,6 +160,17 @@ export class HttpClient {
try {
payload = (await response.json()) as ApiResponse<T>;
} catch (cause) {
/**
* A non-JSON body on a 5xx means the request never reached the API —
* a reverse proxy or dev rewrite answered with an HTML error page while
* the upstream was down or restarting. Reporting that as "unreadable
* response" sends people hunting for a serialisation bug; it is an
* availability problem, and the offline message says so.
*/
if (response.status >= 500) {
throw ApiClientError.network(cause);
}
throw new ApiClientError({
code: API_ERROR_CODES.INTERNAL_ERROR,
message: 'The server returned an unreadable response.',
@@ -140,7 +189,14 @@ export class HttpClient {
private buildUrl(path: string, query?: RequestOptions['query']): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
const url = new URL(
`${this.baseUrl}/api/${this.apiVersion}${normalized}`,
// Only used when baseUrl is relative. On the server a relative baseUrl is
// a configuration error, and `new URL` will say so loudly.
this.baseUrl.startsWith('http')
? undefined
: (globalThis as { location?: { origin: string } }).location?.origin,
);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined || value === null || value === '') continue;
+8
View File
@@ -16,4 +16,12 @@ export { HttpClient } from './http-client';
export type { HttpClientOptions, RequestOptions } from './http-client';
export { createApiClient } from './create-client';
export type { CatalogResource, ProductListQuery } from './resources/catalog';
export type { AuthResource, LoginCredentials } from './resources/auth';
export type {
AdminResource,
CreateUserPayload,
RolePayload,
UpdateUserPayload,
UserListParams,
} from './resources/admin';
export type { ApiClient } from './create-client';
@@ -0,0 +1,84 @@
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface UserListParams {
page?: number;
perPage?: number;
q?: string;
type?: string;
status?: string;
}
export interface CreateUserPayload {
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
type: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
roleIds: string[];
}
export interface UpdateUserPayload {
firstName?: string;
lastName?: string;
phone?: string | null;
status?: 'ACTIVE' | 'INVITED' | 'SUSPENDED';
type?: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
roleIds?: string[];
}
export interface RolePayload {
key?: string;
name?: string;
description?: string | null;
permissions?: string[];
}
/** Back-office administration. Every call requires an admin-audience token. */
export interface AdminResource {
listUsers(params?: UserListParams): Promise<OffsetPaginated<UserSummary>>;
getUser(id: string): Promise<UserSummary>;
createUser(payload: CreateUserPayload): Promise<UserSummary>;
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
listRoles(): Promise<RoleDetail[]>;
getRole(id: string): Promise<RoleDetail>;
listPermissions(): Promise<PermissionGroup[]>;
createRole(
payload: Required<Pick<RolePayload, 'key' | 'name'>> & RolePayload,
): Promise<RoleDetail>;
updateRole(id: string, payload: RolePayload): Promise<RoleDetail>;
deleteRole(id: string): Promise<void>;
}
export function createAdminResource(http: HttpClient): AdminResource {
// Administration data is never cached: an operator editing permissions must
// see the result of their own change immediately.
const uncached = { cache: 'no-store' } as const;
return {
listUsers: (params = {}) =>
http.get<OffsetPaginated<UserSummary>>('/admin/users', { ...uncached, query: { ...params } }),
getUser: (id) => http.get<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, uncached),
createUser: (payload) => http.post<UserSummary>('/admin/users', payload, uncached),
updateUser: (id, payload) =>
http.patch<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, payload, uncached),
resetUserPassword: (id, password) =>
http.post<{ ok: true }>(
`/admin/users/${encodeURIComponent(id)}/password`,
{ password },
uncached,
),
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
createRole: (payload) => http.post<RoleDetail>('/admin/roles', payload, uncached),
updateRole: (id, payload) =>
http.patch<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, payload, uncached),
deleteRole: (id) => http.delete<void>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { CurrentUser, LoginResult, RefreshResult, SessionSummary } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Storefront and admin have separate endpoints because they issue separate
* cookies for separate audiences. Exposing them as one method with a flag would
* make it far too easy to point a customer credential at the admin surface.
*/
export interface AuthResource {
login(credentials: LoginCredentials): Promise<LoginResult>;
adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
refresh(): Promise<RefreshResult>;
adminRefresh(): Promise<RefreshResult>;
logout(): Promise<void>;
adminLogout(): Promise<void>;
me(): Promise<CurrentUser>;
sessions(): Promise<SessionSummary[]>;
}
export function createAuthResource(http: HttpClient): AuthResource {
/**
* `cache: 'no-store'` on every call. An authentication response must never be
* served from a cache — not Next's data cache, not a CDN, not the browser's.
*/
const uncached = {
cache: 'no-store',
/**
* Every auth endpoint opts out of the 401 retry. Refresh is the mechanism
* the retry *uses*, so letting it retry itself recurses without bound;
* login and logout have nothing to re-authenticate with either.
*/
skipAuthRetry: true,
} as const;
return {
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
logout: () => http.post<void>('/auth/logout', undefined, uncached),
adminLogout: () => http.post<void>('/auth/admin/logout', undefined, uncached),
me: () => http.get<CurrentUser>('/auth/me', uncached),
sessions: () => http.get<SessionSummary[]>('/auth/sessions', uncached),
};
}
+56
View File
@@ -0,0 +1,56 @@
import type { Id, IsoDateTime, Nullable } from '../primitives';
import type { UserType } from './actors';
import type { Permission } from './permissions';
/**
* The `/auth/me` payload — everything a frontend needs to render a session.
*
* Note what is absent: no password hash, no session id, no refresh token, no
* internal flags. This is the whole of what a client is allowed to know about
* itself, and it is assembled explicitly rather than by spreading a database
* row, so a new column can never leak by accident.
*/
export interface CurrentUser {
readonly id: Id;
readonly email: string;
readonly type: UserType;
readonly firstName: Nullable<string>;
readonly lastName: Nullable<string>;
readonly displayName: string;
readonly avatarUrl: Nullable<string>;
readonly roles: readonly string[];
readonly permissions: readonly Permission[];
readonly lastLoginAt: Nullable<IsoDateTime>;
}
/**
* Login response.
*
* The access token is returned in the body because the client holds it in
* memory. The refresh token is NOT here — it is set as an httpOnly cookie the
* page's JavaScript cannot read, which is the entire point: an XSS can steal
* whatever is in memory, but it cannot steal the credential that mints new
* sessions.
*/
export interface LoginResult {
readonly user: CurrentUser;
readonly accessToken: string;
readonly accessTokenExpiresAt: IsoDateTime;
}
/** Returned by the refresh endpoint; the rotated cookie rides along with it. */
export interface RefreshResult {
readonly accessToken: string;
readonly accessTokenExpiresAt: IsoDateTime;
}
/** One signed-in device. Surfaced so a user can review and revoke sessions. */
export interface SessionSummary {
readonly id: Id;
readonly userAgent: Nullable<string>;
readonly ipAddress: Nullable<string>;
readonly createdAt: IsoDateTime;
readonly expiresAt: IsoDateTime;
readonly isCurrent: boolean;
}
+2
View File
@@ -13,6 +13,8 @@ export * from './api/pagination';
export * from './auth/actors';
export * from './auth/permissions';
export * from './auth/tokens';
export * from './auth/session';
export * from './users/user';
export * from './catalog/product';
export * from './catalog/variant';
export * from './catalog/taxonomy';
+57
View File
@@ -0,0 +1,57 @@
import type { UserType } from '../auth/actors';
import type { Permission } from '../auth/permissions';
import type { Id, IsoDateTime, Nullable } from '../primitives';
export const USER_STATUSES = {
ACTIVE: 'ACTIVE',
INVITED: 'INVITED',
SUSPENDED: 'SUSPENDED',
} as const;
export type UserStatus = (typeof USER_STATUSES)[keyof typeof USER_STATUSES];
/** Row shape for the back-office user table. */
export interface UserSummary {
readonly id: Id;
readonly email: string;
readonly type: UserType;
readonly status: UserStatus;
readonly firstName: Nullable<string>;
readonly lastName: Nullable<string>;
readonly displayName: string;
readonly roles: readonly RoleSummary[];
readonly lastLoginAt: Nullable<IsoDateTime>;
readonly createdAt: IsoDateTime;
}
export interface RoleSummary {
readonly id: Id;
readonly key: string;
readonly name: string;
/** System roles are seeded from code and cannot be deleted or renamed. */
readonly isSystem: boolean;
}
export interface RoleDetail extends RoleSummary {
readonly description: Nullable<string>;
readonly permissions: readonly Permission[];
readonly userCount: number;
}
/**
* The permission catalog, grouped by resource for the role editor.
*
* Served from the API rather than read from @sport/types in the browser so the
* admin always shows exactly what the running backend enforces — a deploy skew
* shows up as a missing checkbox, not as a silently ineffective grant.
*/
export interface PermissionGroup {
readonly resource: string;
readonly permissions: readonly PermissionInfo[];
}
export interface PermissionInfo {
readonly key: Permission;
readonly resource: string;
readonly action: string;
}
+7
View File
@@ -28,5 +28,12 @@ export const registerSchema = z.object({
acceptsMarketing: z.boolean().default(false),
});
/**
* The refresh token travels as an httpOnly cookie, never in a body — so this
* schema is deliberately empty. It exists to document that the endpoint takes
* no client-supplied input, which is what makes it safe to expose unauthenticated.
*/
export const refreshSchema = z.object({});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
+1
View File
@@ -15,3 +15,4 @@ export * from './common';
export * from './pagination';
export * from './auth';
export * from './catalog';
export * from './users';
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod';
import { passwordSchema } from './auth';
import { anyIdSchema, emailSchema, phoneSchema } from './common';
import { offsetPageQuerySchema } from './pagination';
/**
* Back-office user and role management.
*
* Note what is *not* here: no `permissions` array on a user. Permissions are
* granted only through roles (ADR-0007). Allowing per-user overrides would make
* "who can refund an order?" unanswerable without inspecting every account.
*/
export const userTypeSchema = z.enum(['CUSTOMER', 'STAFF', 'ADMIN', 'SUPER_ADMIN']);
export const userStatusSchema = z.enum(['ACTIVE', 'INVITED', 'SUSPENDED']);
/** Back-office accounts only — customers are created by registration. */
export const backOfficeUserTypeSchema = z.enum(['STAFF', 'ADMIN', 'SUPER_ADMIN']);
export const userListQuerySchema = offsetPageQuerySchema.extend({
q: z.string().trim().max(120).optional(),
type: userTypeSchema.optional(),
status: userStatusSchema.optional(),
});
export const createUserSchema = z.object({
email: emailSchema,
password: passwordSchema,
firstName: z.string().trim().min(1).max(80),
lastName: z.string().trim().min(1).max(80),
phone: phoneSchema.optional(),
type: backOfficeUserTypeSchema,
roleIds: z.array(anyIdSchema).default([]),
});
export const updateUserSchema = z.object({
firstName: z.string().trim().min(1).max(80).optional(),
lastName: z.string().trim().min(1).max(80).optional(),
phone: phoneSchema.nullish(),
status: userStatusSchema.optional(),
type: backOfficeUserTypeSchema.optional(),
roleIds: z.array(anyIdSchema).optional(),
});
/** An operator resetting someone else's password — no current password needed. */
export const resetUserPasswordSchema = z.object({
password: passwordSchema,
});
/** A user changing their own password — proves possession of the current one. */
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: passwordSchema,
});
export const roleKeySchema = z
.string()
.trim()
.min(2)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores');
export const createRoleSchema = z.object({
key: roleKeySchema,
name: z.string().trim().min(2).max(120),
description: z.string().trim().max(500).nullish(),
permissions: z.array(z.string().max(64)).default([]),
});
export const updateRoleSchema = z.object({
name: z.string().trim().min(2).max(120).optional(),
description: z.string().trim().max(500).nullish(),
permissions: z.array(z.string().max(64)).optional(),
});
export type UserListQuery = z.output<typeof userListQuerySchema>;
export type CreateUserInput = z.output<typeof createUserSchema>;
export type UpdateUserInput = z.output<typeof updateUserSchema>;
export type CreateRoleInput = z.output<typeof createRoleSchema>;
export type UpdateRoleInput = z.output<typeof updateRoleSchema>;
export type ChangePasswordInput = z.output<typeof changePasswordSchema>;