Add separate user.ts file, covering the basic user API.
Publish Package / build-and-publish (push) Successful in 23s

This commit is contained in:
fred.boniface committed 2026-10-26 14:04:14 +00:00
1 parent 0dd11f7b3b
commit add9450e35
2 files changed
+234

No files matched your search

+4
View File
@@ -13,6 +13,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./user": {
"types": "./dist/user.d.ts",
"import": "./dist/user.js"
}
},
"scripts": {
+230
View File
@@ -0,0 +1,230 @@
/**
* Standalone User API client for OwlBoard.
*
* Separate from the main v3 API client — the user API lives at
* /api/user/v1 and returns plain JSON (no envelope wrapper).
*
* AI was used in the creation of the User API library file
*
* @example
* ```ts
* import { UserClient } from '@owlboard/owlboard-ts/user';
* ```
*/
// ── Error types ─────────────────────────────────────────────
export type UserApiErrorCode =
| 'VALIDATION'
| 'AUTH'
| 'NOT_FOUND'
| 'RATE_LIMIT'
| 'SERVER';
const ERROR_DESCRIPTIONS: Record<UserApiErrorCode, string> = {
VALIDATION: 'The request was invalid or contained malformed data',
AUTH: 'Authentication failed — missing or invalid API key',
NOT_FOUND: 'The requested user account was not found',
RATE_LIMIT: 'Rate limit exceeded — too many requests, try again shortly',
SERVER: 'The server encountered an internal error',
};
/**
* Structured error thrown by {@link UserClient} methods.
*
* - `code` — machine-readable category for programmatic branching
* - `status` — HTTP status code from the response
* - `message` — human-readable description (safe to display as-is)
* - `detail` — raw response body, if any, for debugging
*/
export class UserApiError extends Error {
public readonly name = 'UserApiError';
constructor(
public readonly code: UserApiErrorCode,
public readonly status: number,
message: string,
public readonly detail?: string,
) {
super(message);
}
/** Build a {@link UserApiError} from a failed fetch {@link Response}. */
static async fromResponse(res: Response): Promise<UserApiError> {
const code = statusToCode(res.status);
const detail = await res.text().catch(() => undefined);
// If the server sent a JSON body with a `message` or `msg` field, prefer it
const serverMessage = parseJsonMessage(detail);
const message = serverMessage
? `${ERROR_DESCRIPTIONS[code]}: ${serverMessage}`
: ERROR_DESCRIPTIONS[code];
return new UserApiError(code, res.status, message, detail);
}
}
function statusToCode(status: number): UserApiErrorCode {
switch (status) {
case 400: return 'VALIDATION';
case 401:
case 403: return 'AUTH';
case 404: return 'NOT_FOUND';
case 429: return 'RATE_LIMIT';
default: return 'SERVER';
}
}
function parseJsonMessage(body: string | undefined): string | undefined {
if (!body) return undefined;
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
const msg = parsed['message'] ?? parsed['msg'] ?? parsed['error'];
return typeof msg === 'string' ? msg : undefined;
} catch {
return undefined;
}
}
// ── Response types ──────────────────────────────────────────
/** POST /users/registrations */
export interface RegistrationResponse {
message: string;
}
/** POST /users (verify code & create account) */
export interface VerifyCodeResponse {
apiKey: string;
}
/** GET /users */
export interface UserAccount {
uuid: string;
domain: string;
atime: string;
}
// ── Client ──────────────────────────────────────────────────
export class UserClient {
private readonly baseUrl: string;
constructor(
baseUrl: string,
private readonly apiKey?: string,
) {
const root = baseUrl.replace(/\/+$/, '');
this.baseUrl = root.endsWith('/api/user/v1')
? root
: `${root}/api/user/v1`;
}
/**
* Request a registration verification code.
*
* `POST /users/registrations`
*
* No authentication required.
*/
async requestRegistration(
email: string,
fetcher: typeof fetch = fetch,
): Promise<RegistrationResponse> {
const res = await fetcher(`${this.baseUrl}/users/registrations`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ email }),
});
if (!res.ok) throw await this.error(res);
return (await res.json()) as RegistrationResponse;
}
/**
* Verify a registration code and create the user account.
*
* `POST /users`
*
* No authentication required. Returns the new API key.
*/
async verifyCode(
code: string,
fetcher: typeof fetch = fetch,
): Promise<VerifyCodeResponse> {
const res = await fetcher(`${this.baseUrl}/users`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ code }),
});
if (!res.ok) throw await this.error(res);
return (await res.json()) as VerifyCodeResponse;
}
/**
* Check whether the authenticated user exists.
*
* `HEAD /users`
*
* Requires `X-OWL-KEY`.
* @returns `true` if 200, `false` if 404.
*/
async checkExists(fetcher: typeof fetch = fetch): Promise<boolean> {
const res = await fetcher(`${this.baseUrl}/users`, {
method: 'HEAD',
headers: this.headers(),
});
if (res.status === 200) return true;
if (res.status === 404) return false;
throw await this.error(res);
}
/**
* Fetch the authenticated user's account details.
*
* `GET /users`
*
* Requires `X-OWL-KEY`.
*/
async getAccount(fetcher: typeof fetch = fetch): Promise<UserAccount> {
const res = await fetcher(`${this.baseUrl}/users`, {
method: 'GET',
headers: this.headers(),
});
if (!res.ok) throw await this.error(res);
return (await res.json()) as UserAccount;
}
/**
* Delete the authenticated user's account.
*
* `DELETE /users`
*
* Requires `X-OWL-KEY`.
*/
async deleteAccount(fetcher: typeof fetch = fetch): Promise<void> {
const res = await fetcher(`${this.baseUrl}/users`, {
method: 'DELETE',
headers: this.headers(),
});
if (!res.ok) throw await this.error(res);
}
// ── Internals ───────────────────────────────────────────
private headers(hasBody: boolean = false): Record<string, string> {
const h: Record<string, string> = {};
if (hasBody) h['Content-Type'] = 'application/json';
if (this.apiKey) h['X-OWL-KEY'] = this.apiKey;
return h;
}
private async error(res: Response): Promise<UserApiError> {
return UserApiError.fromResponse(res);
}
}