@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/README.md +204 -0
- package/dist/auth.d.ts +195 -0
- package/dist/auth.js +114 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +34 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +33100 -0
- package/dist/types.js +5 -0
- package/package.json +40 -0
- package/src/auth.ts +172 -0
- package/src/client.ts +49 -0
- package/src/index.ts +16 -0
- package/src/types.ts +33101 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @classic-homes/api
|
|
2
|
+
|
|
3
|
+
Typed client for the CHAPI API. Every path, query param, request body and
|
|
4
|
+
response is typed from CHAPI's OpenAPI contract (`openapi.json` at the repo
|
|
5
|
+
root), so consuming repos get autocomplete and compile-time checks against the
|
|
6
|
+
real API — including the v2 (D1-backed) endpoints. v2 response bodies are typed
|
|
7
|
+
per resource (e.g. `LotV2`, `CommunityV2`) from the field registry.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @classic-homes/api
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This is a scoped, restricted package published under the `@classic-homes` org, so a
|
|
16
|
+
consuming repo must authenticate npm to that scope first. Add an `.npmrc` at the
|
|
17
|
+
repo root:
|
|
18
|
+
|
|
19
|
+
```ini
|
|
20
|
+
# .npmrc (consuming repo)
|
|
21
|
+
@classic-homes:registry=https://registry.npmjs.org/
|
|
22
|
+
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then export a token with read access to the org before installing / in CI:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export NPM_TOKEN=xxxxxxxx # org read token; do NOT commit it
|
|
29
|
+
npm install @classic-homes/api
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createChapiClient } from '@classic-homes/api';
|
|
36
|
+
|
|
37
|
+
const chapi = createChapiClient({
|
|
38
|
+
baseUrl: 'https://api.example.com',
|
|
39
|
+
token: async () => getAccessToken(), // string | () => string | Promise<string>
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// List v2 lots — params, query and response are fully typed.
|
|
43
|
+
const { data, error } = await chapi.GET('/v2/lots', {
|
|
44
|
+
params: { query: { page: 1, limit: 25, sort: '-lotNumber' } },
|
|
45
|
+
});
|
|
46
|
+
if (error) throw new Error('request failed');
|
|
47
|
+
for (const lot of data.data) {
|
|
48
|
+
// lot is typed from the OpenAPI schema
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// PATCH v2 enrichment
|
|
52
|
+
await chapi.PATCH('/v2/filings/{id}', {
|
|
53
|
+
params: { path: { id: 390 } },
|
|
54
|
+
body: { webStatus: 'Production' },
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The client is a thin wrapper over
|
|
59
|
+
[`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/); `GET`/`POST`/`PATCH`/…
|
|
60
|
+
methods and the `params`/`body` shapes come from it.
|
|
61
|
+
|
|
62
|
+
## Authentication
|
|
63
|
+
|
|
64
|
+
Every request is sent as `Authorization: Bearer <token>`. The API accepts either:
|
|
65
|
+
|
|
66
|
+
- **A JWT** — obtained from CHAPI's auth flow. Use a `token` function to refresh
|
|
67
|
+
short-lived JWTs per request.
|
|
68
|
+
- **An API key** — pass the key string as `token`. The API checks the bearer value
|
|
69
|
+
as an API key first, then falls back to JWT verification.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// static API key
|
|
73
|
+
const chapi = createChapiClient({ baseUrl, token: process.env.CHAPI_API_KEY });
|
|
74
|
+
|
|
75
|
+
// or a refreshing JWT
|
|
76
|
+
const chapi = createChapiClient({ baseUrl, token: () => auth.getAccessToken() });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Auth helpers
|
|
80
|
+
|
|
81
|
+
The package also ships typed auth calls, error predicates, and an auto-refreshing
|
|
82
|
+
session that handles CHAPI's short-lived access tokens and **refresh-token rotation**
|
|
83
|
+
(every refresh returns a NEW refresh token that replaces the old one).
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import {
|
|
87
|
+
createChapiClient,
|
|
88
|
+
createAuthSession,
|
|
89
|
+
login,
|
|
90
|
+
isUnauthorized,
|
|
91
|
+
isForbidden,
|
|
92
|
+
isRateLimited,
|
|
93
|
+
} from '@cos/chapi-client';
|
|
94
|
+
|
|
95
|
+
// 1. Log in (typed wrapper over POST /v1/auth/login)
|
|
96
|
+
const bootstrap = createChapiClient({ baseUrl });
|
|
97
|
+
const { data, error } = await login(bootstrap, { email, password });
|
|
98
|
+
if (error) throw new Error('login failed');
|
|
99
|
+
|
|
100
|
+
// 2. Create a session that keeps the access token fresh and follows rotation.
|
|
101
|
+
// Persist BOTH tokens on change — the previous refresh token is now invalid.
|
|
102
|
+
const session = createAuthSession({
|
|
103
|
+
baseUrl,
|
|
104
|
+
tokens: data, // { accessToken, refreshToken, sessionToken }
|
|
105
|
+
onTokensChanged: (t) => saveToStorage(t),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 3. Use the session's token provider — it refreshes transparently before expiry.
|
|
109
|
+
const chapi = createChapiClient({ baseUrl, token: session.token });
|
|
110
|
+
|
|
111
|
+
// 4. Branch on typed errors instead of string-matching codes
|
|
112
|
+
const res = await chapi.GET('/v2/homes', { params: { query: { page: 1 } } });
|
|
113
|
+
if (isUnauthorized(res.error)) redirectToLogin();
|
|
114
|
+
else if (isForbidden(res.error)) showNoAccess();
|
|
115
|
+
else if (isRateLimited(res.error)) backOff();
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`logout(client)` and `refresh(client, refreshToken)` are also exported. See
|
|
119
|
+
`docs/TOKEN_LIFECYCLE.md` in the API repo for the full lifecycle.
|
|
120
|
+
|
|
121
|
+
## Errors, pagination & permissions
|
|
122
|
+
|
|
123
|
+
- **Errors** — `openapi-fetch` returns `{ data, error }` (it does not throw on
|
|
124
|
+
non-2xx). `error` is the typed error body (`{ error: { code, message, ... } }`).
|
|
125
|
+
Always branch on `error` before using `data`.
|
|
126
|
+
- **Pagination** — list responses carry `meta.pagination` (`page`, `limit`, `total`,
|
|
127
|
+
`totalPages`). `limit` max is 500. Page with `params.query.page` / `limit`.
|
|
128
|
+
- **Sources & staleness** — `meta.sources` names the backing edge DB; `meta.sync`
|
|
129
|
+
(`lastCompleted`, `isStale`) and the `X-Data-Stale` header report data freshness.
|
|
130
|
+
- **Permissions are exact-match** — v2 hides fields your token isn't explicitly
|
|
131
|
+
granted. A `*` wildcard does NOT unlock permission-scoped fields (e.g. financial
|
|
132
|
+
fields need `<resource>:read:financial`). Missing fields usually mean a missing
|
|
133
|
+
permission, not a bug.
|
|
134
|
+
- **PATCH write-through** — `meta.writeThrough` is `'ok'` when the edge DB was
|
|
135
|
+
updated in-request, or `'deferred'` when the write is pending the next sync
|
|
136
|
+
(the underlying record was still updated; the read model just lags briefly).
|
|
137
|
+
|
|
138
|
+
## Incremental sync (`updated-since`)
|
|
139
|
+
|
|
140
|
+
Each resource exposes `GET /v2/<resource>/updated-since?since=<ISO>` returning rows
|
|
141
|
+
with `lastUpdated >= since`, ordered by `lastUpdated` then primary key. To pull a
|
|
142
|
+
stable incremental feed, page until empty, then advance `since` to the max
|
|
143
|
+
`lastUpdated` seen and dedupe by `id` (rows sharing a boundary timestamp can repeat
|
|
144
|
+
across polls):
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
async function pullSince(chapi, since: string) {
|
|
148
|
+
const seen = new Set<string>();
|
|
149
|
+
let page = 1;
|
|
150
|
+
let maxUpdated = since;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { data, error } = await chapi.GET('/v2/lots/updated-since', {
|
|
153
|
+
params: { query: { since, page, limit: 500 } },
|
|
154
|
+
});
|
|
155
|
+
if (error) throw new Error('updated-since failed');
|
|
156
|
+
for (const row of data.data) {
|
|
157
|
+
if (seen.has(String(row.id))) continue;
|
|
158
|
+
seen.add(String(row.id));
|
|
159
|
+
if (row.lastUpdated && row.lastUpdated > maxUpdated) maxUpdated = row.lastUpdated;
|
|
160
|
+
// ...upsert row into your store...
|
|
161
|
+
}
|
|
162
|
+
if (data.data.length < 500) break;
|
|
163
|
+
page += 1;
|
|
164
|
+
}
|
|
165
|
+
return maxUpdated; // pass as `since` on the next poll
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## How the types stay accurate
|
|
170
|
+
|
|
171
|
+
- `openapi.json` (repo root) is generated from the endpoint registry via
|
|
172
|
+
`npm run openapi:generate` and committed.
|
|
173
|
+
- CI runs `npm run openapi:check` to fail the build if the committed spec drifts
|
|
174
|
+
from the code.
|
|
175
|
+
- This package's `npm run generate` regenerates `src/types.ts` from that spec
|
|
176
|
+
(`src/types.ts` is git-ignored — always built, never hand-edited).
|
|
177
|
+
|
|
178
|
+
## Scripts
|
|
179
|
+
|
|
180
|
+
| Script | Purpose |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `npm run generate` | Regenerate `src/types.ts` from `../../openapi.json` |
|
|
183
|
+
| `npm run build` | Generate types, then compile to `dist/` |
|
|
184
|
+
| `npm run typecheck` | Type-check without emitting |
|
|
185
|
+
|
|
186
|
+
## Publishing (maintainers)
|
|
187
|
+
|
|
188
|
+
The package is published by the `Publish SDK` workflow on an `sdk-v*` tag. To cut a
|
|
189
|
+
release:
|
|
190
|
+
|
|
191
|
+
1. From the repo root, regenerate and verify the contract:
|
|
192
|
+
`npm run openapi:generate && npm run openapi:check`.
|
|
193
|
+
2. Bump `version` in `packages/chapi-client/package.json` and add a `CHANGELOG.md`
|
|
194
|
+
entry.
|
|
195
|
+
3. Commit, then tag: `git tag sdk-v<version> && git push origin sdk-v<version>`.
|
|
196
|
+
|
|
197
|
+
The workflow rebuilds `src/types.ts` from the committed `openapi.json` and runs
|
|
198
|
+
`npm publish --provenance`, so the published types always match the tagged
|
|
199
|
+
contract. Deploy tags (`v*`) are separate from SDK tags (`sdk-v*`).
|
|
200
|
+
|
|
201
|
+
## Example consumer
|
|
202
|
+
|
|
203
|
+
A minimal runnable example lives at `examples/sdk-consumer/` in the CHAPI repo
|
|
204
|
+
(list lots + PATCH a filing against a local `wrangler dev`).
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
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 { type ChapiClient } from './client';
|
|
11
|
+
/** Shape of a CHAPI error body: `{ error: { code, message } }`. */
|
|
12
|
+
export interface ChapiApiError {
|
|
13
|
+
error?: {
|
|
14
|
+
code?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
} | null;
|
|
17
|
+
}
|
|
18
|
+
/** True for 401-class failures (missing/invalid/expired/revoked credentials). */
|
|
19
|
+
export declare function isUnauthorized(err: unknown): boolean;
|
|
20
|
+
/** True when authenticated but not permitted (403). */
|
|
21
|
+
export declare function isForbidden(err: unknown): boolean;
|
|
22
|
+
/** True when rate limited (429). */
|
|
23
|
+
export declare function isRateLimited(err: unknown): boolean;
|
|
24
|
+
/** True when the account is locked (423). */
|
|
25
|
+
export declare function isAccountLocked(err: unknown): boolean;
|
|
26
|
+
export interface AuthTokens {
|
|
27
|
+
accessToken: string;
|
|
28
|
+
refreshToken?: string;
|
|
29
|
+
sessionToken?: string;
|
|
30
|
+
}
|
|
31
|
+
/** `POST /v1/auth/login`. Returns the openapi-fetch `{ data, error }` result. */
|
|
32
|
+
export declare function login(client: ChapiClient, body: {
|
|
33
|
+
username?: string;
|
|
34
|
+
email?: string;
|
|
35
|
+
password: string;
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
}): Promise<import("openapi-fetch").FetchResponse<{
|
|
38
|
+
parameters: {
|
|
39
|
+
query?: never;
|
|
40
|
+
header?: never;
|
|
41
|
+
path?: never;
|
|
42
|
+
cookie?: never;
|
|
43
|
+
};
|
|
44
|
+
requestBody: {
|
|
45
|
+
content: {
|
|
46
|
+
"application/json": import("./types").components["schemas"]["LoginRequest"];
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
responses: {
|
|
50
|
+
200: {
|
|
51
|
+
headers: {
|
|
52
|
+
[name: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
content: {
|
|
55
|
+
"application/json": import("./types").components["schemas"]["LoginResponse"];
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
400: {
|
|
59
|
+
headers: {
|
|
60
|
+
[name: string]: unknown;
|
|
61
|
+
};
|
|
62
|
+
content: {
|
|
63
|
+
"application/json": import("./types").components["schemas"]["Error"];
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
401: {
|
|
67
|
+
headers: {
|
|
68
|
+
[name: string]: unknown;
|
|
69
|
+
};
|
|
70
|
+
content: {
|
|
71
|
+
"application/json": import("./types").components["schemas"]["Error"];
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
423: {
|
|
75
|
+
headers: {
|
|
76
|
+
[name: string]: unknown;
|
|
77
|
+
};
|
|
78
|
+
content: {
|
|
79
|
+
"application/json": import("./types").components["schemas"]["Error"];
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
429: {
|
|
83
|
+
headers: {
|
|
84
|
+
[name: string]: unknown;
|
|
85
|
+
};
|
|
86
|
+
content: {
|
|
87
|
+
"application/json": import("./types").components["schemas"]["Error"];
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
}, {
|
|
92
|
+
body: never;
|
|
93
|
+
}, `${string}/${string}`>>;
|
|
94
|
+
/** `POST /v1/auth/refresh`. Returns a NEW access token AND a NEW (rotated) refresh token. */
|
|
95
|
+
export declare function refresh(client: ChapiClient, refreshToken: string): Promise<import("openapi-fetch").FetchResponse<{
|
|
96
|
+
parameters: {
|
|
97
|
+
query?: never;
|
|
98
|
+
header?: never;
|
|
99
|
+
path?: never;
|
|
100
|
+
cookie?: never;
|
|
101
|
+
};
|
|
102
|
+
requestBody: {
|
|
103
|
+
content: {
|
|
104
|
+
"application/json": {
|
|
105
|
+
refreshToken: string;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
responses: {
|
|
110
|
+
200: {
|
|
111
|
+
headers: {
|
|
112
|
+
[name: string]: unknown;
|
|
113
|
+
};
|
|
114
|
+
content: {
|
|
115
|
+
"application/json": {
|
|
116
|
+
accessToken?: string;
|
|
117
|
+
refreshToken?: string;
|
|
118
|
+
tokenType?: "Bearer";
|
|
119
|
+
expiresIn?: number;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
401: {
|
|
124
|
+
headers: {
|
|
125
|
+
[name: string]: unknown;
|
|
126
|
+
};
|
|
127
|
+
content: {
|
|
128
|
+
"application/json": import("./types").components["schemas"]["Error"];
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
}, {
|
|
133
|
+
body: never;
|
|
134
|
+
}, `${string}/${string}`>>;
|
|
135
|
+
/** `POST /v1/auth/logout`. Requires the client to be authenticated. */
|
|
136
|
+
export declare function logout(client: ChapiClient): Promise<import("openapi-fetch").FetchResponse<{
|
|
137
|
+
parameters: {
|
|
138
|
+
query?: never;
|
|
139
|
+
header?: never;
|
|
140
|
+
path?: never;
|
|
141
|
+
cookie?: never;
|
|
142
|
+
};
|
|
143
|
+
requestBody?: never;
|
|
144
|
+
responses: {
|
|
145
|
+
200: {
|
|
146
|
+
headers: {
|
|
147
|
+
[name: string]: unknown;
|
|
148
|
+
};
|
|
149
|
+
content: {
|
|
150
|
+
"application/json": import("./types").components["schemas"]["MessageResponse"];
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
401: {
|
|
154
|
+
headers: {
|
|
155
|
+
[name: string]: unknown;
|
|
156
|
+
};
|
|
157
|
+
content: {
|
|
158
|
+
"application/json": import("./types").components["schemas"]["Error"];
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
}, never, `${string}/${string}`>>;
|
|
163
|
+
export interface AuthSessionOptions {
|
|
164
|
+
/** API origin, e.g. https://api.example.com */
|
|
165
|
+
baseUrl: string;
|
|
166
|
+
/** Initial tokens (e.g. from a prior login or SSO callback). */
|
|
167
|
+
tokens: AuthTokens;
|
|
168
|
+
/**
|
|
169
|
+
* Called whenever the tokens change (after a refresh rotates them). Persist BOTH the new
|
|
170
|
+
* access token and the new refresh token — the previous refresh token is now invalid.
|
|
171
|
+
*/
|
|
172
|
+
onTokensChanged?: (tokens: AuthTokens) => void;
|
|
173
|
+
/** Refresh this many seconds before the access token actually expires. Default 30. */
|
|
174
|
+
refreshSkewSeconds?: number;
|
|
175
|
+
}
|
|
176
|
+
export interface AuthSession {
|
|
177
|
+
/** A `token` provider to pass to `createChapiClient({ token })` — refreshes as needed. */
|
|
178
|
+
token: () => Promise<string>;
|
|
179
|
+
/** Current tokens snapshot. */
|
|
180
|
+
getTokens: () => AuthTokens;
|
|
181
|
+
/** Force a refresh now (rotates the refresh token). Returns the new access token. */
|
|
182
|
+
refreshNow: () => Promise<string>;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Create a session that keeps the access token fresh and follows refresh-token rotation.
|
|
186
|
+
*
|
|
187
|
+
* Pass `session.token` as the `token` option of `createChapiClient`. Before each request the
|
|
188
|
+
* provider checks the access token's `exp`; if it is expired (or within the skew window) it
|
|
189
|
+
* calls `/v1/auth/refresh`, stores the rotated tokens, and fires `onTokensChanged`.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* const session = createAuthSession({ baseUrl, tokens, onTokensChanged: save });
|
|
193
|
+
* const chapi = createChapiClient({ baseUrl, token: session.token });
|
|
194
|
+
*/
|
|
195
|
+
export declare function createAuthSession(options: AuthSessionOptions): AuthSession;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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 } from './client';
|
|
11
|
+
function hasCode(err, ...codes) {
|
|
12
|
+
const code = err?.error?.code;
|
|
13
|
+
return typeof code === 'string' && codes.includes(code);
|
|
14
|
+
}
|
|
15
|
+
/** True for 401-class failures (missing/invalid/expired/revoked credentials). */
|
|
16
|
+
export function isUnauthorized(err) {
|
|
17
|
+
return hasCode(err, 'UNAUTHORIZED', 'INVALID_TOKEN', 'TOKEN_REVOKED');
|
|
18
|
+
}
|
|
19
|
+
/** True when authenticated but not permitted (403). */
|
|
20
|
+
export function isForbidden(err) {
|
|
21
|
+
return hasCode(err, 'FORBIDDEN', 'INSUFFICIENT_PERMISSIONS');
|
|
22
|
+
}
|
|
23
|
+
/** True when rate limited (429). */
|
|
24
|
+
export function isRateLimited(err) {
|
|
25
|
+
return hasCode(err, 'RATE_LIMIT_EXCEEDED');
|
|
26
|
+
}
|
|
27
|
+
/** True when the account is locked (423). */
|
|
28
|
+
export function isAccountLocked(err) {
|
|
29
|
+
return hasCode(err, 'ACCOUNT_LOCKED');
|
|
30
|
+
}
|
|
31
|
+
/** `POST /v1/auth/login`. Returns the openapi-fetch `{ data, error }` result. */
|
|
32
|
+
export function login(client, body) {
|
|
33
|
+
// Cast: the generated body type is exact; consumers pass username OR email + password.
|
|
34
|
+
return client.POST('/v1/auth/login', { body: body });
|
|
35
|
+
}
|
|
36
|
+
/** `POST /v1/auth/refresh`. Returns a NEW access token AND a NEW (rotated) refresh token. */
|
|
37
|
+
export function refresh(client, refreshToken) {
|
|
38
|
+
return client.POST('/v1/auth/refresh', { body: { refreshToken } });
|
|
39
|
+
}
|
|
40
|
+
/** `POST /v1/auth/logout`. Requires the client to be authenticated. */
|
|
41
|
+
export function logout(client) {
|
|
42
|
+
return client.POST('/v1/auth/logout', {});
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Auto-refreshing session
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/** Decode a JWT's `exp` (seconds since epoch), or null if it can't be read. */
|
|
48
|
+
function jwtExp(token) {
|
|
49
|
+
try {
|
|
50
|
+
const payload = token.split('.')[1];
|
|
51
|
+
if (!payload)
|
|
52
|
+
return null;
|
|
53
|
+
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
54
|
+
const exp = JSON.parse(json).exp;
|
|
55
|
+
return typeof exp === 'number' ? exp : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create a session that keeps the access token fresh and follows refresh-token rotation.
|
|
63
|
+
*
|
|
64
|
+
* Pass `session.token` as the `token` option of `createChapiClient`. Before each request the
|
|
65
|
+
* provider checks the access token's `exp`; if it is expired (or within the skew window) it
|
|
66
|
+
* calls `/v1/auth/refresh`, stores the rotated tokens, and fires `onTokensChanged`.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const session = createAuthSession({ baseUrl, tokens, onTokensChanged: save });
|
|
70
|
+
* const chapi = createChapiClient({ baseUrl, token: session.token });
|
|
71
|
+
*/
|
|
72
|
+
export function createAuthSession(options) {
|
|
73
|
+
const skew = options.refreshSkewSeconds ?? 30;
|
|
74
|
+
let tokens = { ...options.tokens };
|
|
75
|
+
// A bare client used only to call /v1/auth/refresh (no token middleware → no recursion).
|
|
76
|
+
const refreshClient = createChapiClient({ baseUrl: options.baseUrl });
|
|
77
|
+
let inFlight = null;
|
|
78
|
+
async function doRefresh() {
|
|
79
|
+
if (!tokens.refreshToken) {
|
|
80
|
+
throw new Error('No refresh token available; the user must log in again.');
|
|
81
|
+
}
|
|
82
|
+
const { data, error } = await refresh(refreshClient, tokens.refreshToken);
|
|
83
|
+
if (error || !data) {
|
|
84
|
+
throw new Error('Refresh failed; the user must log in again.');
|
|
85
|
+
}
|
|
86
|
+
const next = data;
|
|
87
|
+
tokens = {
|
|
88
|
+
accessToken: next.accessToken,
|
|
89
|
+
// Rotation: prefer the new refresh token; fall back only if the server omitted it.
|
|
90
|
+
refreshToken: next.refreshToken ?? tokens.refreshToken,
|
|
91
|
+
sessionToken: tokens.sessionToken,
|
|
92
|
+
};
|
|
93
|
+
options.onTokensChanged?.({ ...tokens });
|
|
94
|
+
return tokens.accessToken;
|
|
95
|
+
}
|
|
96
|
+
function refreshNow() {
|
|
97
|
+
// Collapse concurrent refreshes so a burst of requests triggers exactly one refresh.
|
|
98
|
+
if (!inFlight) {
|
|
99
|
+
inFlight = doRefresh().finally(() => {
|
|
100
|
+
inFlight = null;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return inFlight;
|
|
104
|
+
}
|
|
105
|
+
async function token() {
|
|
106
|
+
const exp = jwtExp(tokens.accessToken);
|
|
107
|
+
const now = Math.floor(Date.now() / 1000);
|
|
108
|
+
if (exp !== null && exp - skew <= now) {
|
|
109
|
+
return refreshNow();
|
|
110
|
+
}
|
|
111
|
+
return tokens.accessToken;
|
|
112
|
+
}
|
|
113
|
+
return { token, getTokens: () => ({ ...tokens }), refreshNow };
|
|
114
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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 { type ClientOptions, type Client } from 'openapi-fetch';
|
|
9
|
+
import type { paths } from './types';
|
|
10
|
+
export interface ChapiClientOptions extends ClientOptions {
|
|
11
|
+
/** API origin, e.g. https://api.example.com (no trailing slash needed). */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/**
|
|
14
|
+
* Bearer token, or a function returning one (sync or async) so callers can
|
|
15
|
+
* refresh short-lived tokens per request. Attached as `Authorization: Bearer`.
|
|
16
|
+
*/
|
|
17
|
+
token?: string | (() => string | Promise<string>);
|
|
18
|
+
}
|
|
19
|
+
export type ChapiClient = Client<paths>;
|
|
20
|
+
/**
|
|
21
|
+
* Create a typed CHAPI client.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const chapi = createChapiClient({ baseUrl: 'https://api.example.com', token });
|
|
25
|
+
* const { data, error } = await chapi.GET('/v2/lots', {
|
|
26
|
+
* params: { query: { page: 1, limit: 25, sort: '-lotNumber' } },
|
|
27
|
+
* });
|
|
28
|
+
* if (error) throw new Error('request failed');
|
|
29
|
+
* data.data.forEach((lot) => { ... }); // fully typed
|
|
30
|
+
*/
|
|
31
|
+
export declare function createChapiClient(options: ChapiClientOptions): ChapiClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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 from 'openapi-fetch';
|
|
9
|
+
/**
|
|
10
|
+
* Create a typed CHAPI client.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const chapi = createChapiClient({ baseUrl: 'https://api.example.com', token });
|
|
14
|
+
* const { data, error } = await chapi.GET('/v2/lots', {
|
|
15
|
+
* params: { query: { page: 1, limit: 25, sort: '-lotNumber' } },
|
|
16
|
+
* });
|
|
17
|
+
* if (error) throw new Error('request failed');
|
|
18
|
+
* data.data.forEach((lot) => { ... }); // fully typed
|
|
19
|
+
*/
|
|
20
|
+
export function createChapiClient(options) {
|
|
21
|
+
const { token, ...rest } = options;
|
|
22
|
+
const client = createClient(rest);
|
|
23
|
+
if (token) {
|
|
24
|
+
client.use({
|
|
25
|
+
async onRequest({ request }) {
|
|
26
|
+
const value = typeof token === 'function' ? await token() : token;
|
|
27
|
+
if (value)
|
|
28
|
+
request.headers.set('Authorization', `Bearer ${value}`);
|
|
29
|
+
return request;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return client;
|
|
34
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createChapiClient } from './client';
|
|
2
|
+
export type { ChapiClient, ChapiClientOptions } from './client';
|
|
3
|
+
export type { paths, components, operations } from './types';
|
|
4
|
+
export { login, refresh, logout, createAuthSession, isUnauthorized, isForbidden, isRateLimited, isAccountLocked, } from './auth';
|
|
5
|
+
export type { AuthTokens, AuthSession, AuthSessionOptions, ChapiApiError } from './auth';
|
package/dist/index.js
ADDED