@classic-homes/api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * This file was auto-generated by openapi-typescript.
3
+ * Do not make direct changes to the file.
4
+ */
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@classic-homes/api",
3
+ "version": "1.0.0",
4
+ "description": "Typed client for the CHAPI API, generated from the committed OpenAPI contract.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "src"],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Classic-Homes/chapi.git",
19
+ "directory": "packages/chapi-client"
20
+ },
21
+ "license": "UNLICENSED",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "generate": "openapi-typescript ../../openapi.json -o src/types.ts",
27
+ "build": "npm run generate && tsc -p tsconfig.json",
28
+ "typecheck": "npm run generate && tsc -p tsconfig.json --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "openapi-fetch": "^0.13.8"
32
+ },
33
+ "devDependencies": {
34
+ "openapi-typescript": "^7.13.0",
35
+ "typescript": "^5.6.0"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": ">=5.0.0"
39
+ }
40
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Auth helpers for the CHAPI client.
3
+ *
4
+ * The base client only gives you a `token` injection point. This module adds the pieces
5
+ * every consumer would otherwise hand-roll: typed login/refresh/logout calls, a typed error
6
+ * taxonomy, and an auto-refreshing session that transparently keeps a short-lived access
7
+ * token fresh and follows refresh-token ROTATION (every refresh returns a new refresh token
8
+ * that must replace the old one).
9
+ */
10
+ import { createChapiClient, type ChapiClient } from './client';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Typed error predicates
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Shape of a CHAPI error body: `{ error: { code, message } }`. */
17
+ export interface ChapiApiError {
18
+ error?: { code?: string; message?: string } | null;
19
+ }
20
+
21
+ function hasCode(err: unknown, ...codes: string[]): boolean {
22
+ const code = (err as ChapiApiError | undefined | null)?.error?.code;
23
+ return typeof code === 'string' && codes.includes(code);
24
+ }
25
+
26
+ /** True for 401-class failures (missing/invalid/expired/revoked credentials). */
27
+ export function isUnauthorized(err: unknown): boolean {
28
+ return hasCode(err, 'UNAUTHORIZED', 'INVALID_TOKEN', 'TOKEN_REVOKED');
29
+ }
30
+
31
+ /** True when authenticated but not permitted (403). */
32
+ export function isForbidden(err: unknown): boolean {
33
+ return hasCode(err, 'FORBIDDEN', 'INSUFFICIENT_PERMISSIONS');
34
+ }
35
+
36
+ /** True when rate limited (429). */
37
+ export function isRateLimited(err: unknown): boolean {
38
+ return hasCode(err, 'RATE_LIMIT_EXCEEDED');
39
+ }
40
+
41
+ /** True when the account is locked (423). */
42
+ export function isAccountLocked(err: unknown): boolean {
43
+ return hasCode(err, 'ACCOUNT_LOCKED');
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Typed auth calls
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface AuthTokens {
51
+ accessToken: string;
52
+ refreshToken?: string;
53
+ sessionToken?: string;
54
+ }
55
+
56
+ /** `POST /v1/auth/login`. Returns the openapi-fetch `{ data, error }` result. */
57
+ export function login(
58
+ client: ChapiClient,
59
+ body: { username?: string; email?: string; password: string; [k: string]: unknown },
60
+ ) {
61
+ // Cast: the generated body type is exact; consumers pass username OR email + password.
62
+ return client.POST('/v1/auth/login', { body: body as never });
63
+ }
64
+
65
+ /** `POST /v1/auth/refresh`. Returns a NEW access token AND a NEW (rotated) refresh token. */
66
+ export function refresh(client: ChapiClient, refreshToken: string) {
67
+ return client.POST('/v1/auth/refresh', { body: { refreshToken } as never });
68
+ }
69
+
70
+ /** `POST /v1/auth/logout`. Requires the client to be authenticated. */
71
+ export function logout(client: ChapiClient) {
72
+ return client.POST('/v1/auth/logout', {} as never);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Auto-refreshing session
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Decode a JWT's `exp` (seconds since epoch), or null if it can't be read. */
80
+ function jwtExp(token: string): number | null {
81
+ try {
82
+ const payload = token.split('.')[1];
83
+ if (!payload) return null;
84
+ const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
85
+ const exp = (JSON.parse(json) as { exp?: number }).exp;
86
+ return typeof exp === 'number' ? exp : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export interface AuthSessionOptions {
93
+ /** API origin, e.g. https://api.example.com */
94
+ baseUrl: string;
95
+ /** Initial tokens (e.g. from a prior login or SSO callback). */
96
+ tokens: AuthTokens;
97
+ /**
98
+ * Called whenever the tokens change (after a refresh rotates them). Persist BOTH the new
99
+ * access token and the new refresh token — the previous refresh token is now invalid.
100
+ */
101
+ onTokensChanged?: (tokens: AuthTokens) => void;
102
+ /** Refresh this many seconds before the access token actually expires. Default 30. */
103
+ refreshSkewSeconds?: number;
104
+ }
105
+
106
+ export interface AuthSession {
107
+ /** A `token` provider to pass to `createChapiClient({ token })` — refreshes as needed. */
108
+ token: () => Promise<string>;
109
+ /** Current tokens snapshot. */
110
+ getTokens: () => AuthTokens;
111
+ /** Force a refresh now (rotates the refresh token). Returns the new access token. */
112
+ refreshNow: () => Promise<string>;
113
+ }
114
+
115
+ /**
116
+ * Create a session that keeps the access token fresh and follows refresh-token rotation.
117
+ *
118
+ * Pass `session.token` as the `token` option of `createChapiClient`. Before each request the
119
+ * provider checks the access token's `exp`; if it is expired (or within the skew window) it
120
+ * calls `/v1/auth/refresh`, stores the rotated tokens, and fires `onTokensChanged`.
121
+ *
122
+ * @example
123
+ * const session = createAuthSession({ baseUrl, tokens, onTokensChanged: save });
124
+ * const chapi = createChapiClient({ baseUrl, token: session.token });
125
+ */
126
+ export function createAuthSession(options: AuthSessionOptions): AuthSession {
127
+ const skew = options.refreshSkewSeconds ?? 30;
128
+ let tokens: AuthTokens = { ...options.tokens };
129
+ // A bare client used only to call /v1/auth/refresh (no token middleware → no recursion).
130
+ const refreshClient = createChapiClient({ baseUrl: options.baseUrl });
131
+ let inFlight: Promise<string> | null = null;
132
+
133
+ async function doRefresh(): Promise<string> {
134
+ if (!tokens.refreshToken) {
135
+ throw new Error('No refresh token available; the user must log in again.');
136
+ }
137
+ const { data, error } = await refresh(refreshClient, tokens.refreshToken);
138
+ if (error || !data) {
139
+ throw new Error('Refresh failed; the user must log in again.');
140
+ }
141
+ const next = data as { accessToken: string; refreshToken?: string };
142
+ tokens = {
143
+ accessToken: next.accessToken,
144
+ // Rotation: prefer the new refresh token; fall back only if the server omitted it.
145
+ refreshToken: next.refreshToken ?? tokens.refreshToken,
146
+ sessionToken: tokens.sessionToken,
147
+ };
148
+ options.onTokensChanged?.({ ...tokens });
149
+ return tokens.accessToken;
150
+ }
151
+
152
+ function refreshNow(): Promise<string> {
153
+ // Collapse concurrent refreshes so a burst of requests triggers exactly one refresh.
154
+ if (!inFlight) {
155
+ inFlight = doRefresh().finally(() => {
156
+ inFlight = null;
157
+ });
158
+ }
159
+ return inFlight;
160
+ }
161
+
162
+ async function token(): Promise<string> {
163
+ const exp = jwtExp(tokens.accessToken);
164
+ const now = Math.floor(Date.now() / 1000);
165
+ if (exp !== null && exp - skew <= now) {
166
+ return refreshNow();
167
+ }
168
+ return tokens.accessToken;
169
+ }
170
+
171
+ return { token, getTokens: () => ({ ...tokens }), refreshNow };
172
+ }
package/src/client.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @cos/chapi-client — typed client for the CHAPI API.
3
+ *
4
+ * Thin wrapper over openapi-fetch: every path, method, query param, request
5
+ * body and response is typed from the committed `openapi.json` (regenerated
6
+ * into `types.ts`). No hand-maintained call signatures.
7
+ */
8
+ import createClient, { type ClientOptions, type Client } from 'openapi-fetch';
9
+ import type { paths } from './types';
10
+
11
+ export interface ChapiClientOptions extends ClientOptions {
12
+ /** API origin, e.g. https://api.example.com (no trailing slash needed). */
13
+ baseUrl: string;
14
+ /**
15
+ * Bearer token, or a function returning one (sync or async) so callers can
16
+ * refresh short-lived tokens per request. Attached as `Authorization: Bearer`.
17
+ */
18
+ token?: string | (() => string | Promise<string>);
19
+ }
20
+
21
+ export type ChapiClient = Client<paths>;
22
+
23
+ /**
24
+ * Create a typed CHAPI client.
25
+ *
26
+ * @example
27
+ * const chapi = createChapiClient({ baseUrl: 'https://api.example.com', token });
28
+ * const { data, error } = await chapi.GET('/v2/lots', {
29
+ * params: { query: { page: 1, limit: 25, sort: '-lotNumber' } },
30
+ * });
31
+ * if (error) throw new Error('request failed');
32
+ * data.data.forEach((lot) => { ... }); // fully typed
33
+ */
34
+ export function createChapiClient(options: ChapiClientOptions): ChapiClient {
35
+ const { token, ...rest } = options;
36
+ const client = createClient<paths>(rest);
37
+
38
+ if (token) {
39
+ client.use({
40
+ async onRequest({ request }) {
41
+ const value = typeof token === 'function' ? await token() : token;
42
+ if (value) request.headers.set('Authorization', `Bearer ${value}`);
43
+ return request;
44
+ },
45
+ });
46
+ }
47
+
48
+ return client;
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { createChapiClient } from './client';
2
+ export type { ChapiClient, ChapiClientOptions } from './client';
3
+ export type { paths, components, operations } from './types';
4
+
5
+ // Auth helpers: typed login/refresh/logout, error predicates, auto-refreshing session.
6
+ export {
7
+ login,
8
+ refresh,
9
+ logout,
10
+ createAuthSession,
11
+ isUnauthorized,
12
+ isForbidden,
13
+ isRateLimited,
14
+ isAccountLocked,
15
+ } from './auth';
16
+ export type { AuthTokens, AuthSession, AuthSessionOptions, ChapiApiError } from './auth';