@malawistore/store-sdk 0.0.0-stage → 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.1] - 2026-10-28
10
+
11
+ ### Added
12
+
13
+ - `relevance` as a `ProductSort` value for `products.list` and
14
+ `products.listPage`, matching the API's default full-text ranking when
15
+ `q` is set.
16
+
17
+ ### Changed
18
+
19
+ - `q` is documented as full-text search with a substring fallback instead
20
+ of plain substring matching.
21
+
22
+ ## [0.1.0] - 2026-10-27
23
+
24
+ ### Added
25
+
26
+ - `MalawiStore` client with resources for auth, users, categories, products,
27
+ variants, cart, inventory, orders, payments, wishlist, and reviews.
28
+ - Full coverage of the backend `/api/v1` surface, including admin writes,
29
+ inventory operations, and multipart image uploads.
30
+ - Automatic unwrapping of the `{ data }` success envelope.
31
+ - `MalawiStoreError` with stable API codes, HTTP status, `X-Request-ID`, and
32
+ `Retry-After` information.
33
+ - Automatic access-token rotation on `401` with single-flight refresh, token
34
+ change notifications, and an `onUnauthorized` hook.
35
+ - Timeouts, `AbortSignal` support, and exponential backoff retries for
36
+ idempotent `GET`/`HEAD` requests.
37
+ - `verifyStripeSignature` and `constructStripeEvent` helpers built on Web
38
+ Crypto.
39
+ - Dual ESM/CommonJS output with type declarations, source maps, and zero
40
+ runtime dependencies.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Malawi Store
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,383 @@
1
- # Temporary Holding Version
1
+ # @malawistore/store-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed TypeScript client for the **Malawi Store** e-commerce API — catalog,
4
+ cart, checkout, Stripe payments, wishlist, reviews, auth, and the full admin
5
+ surface.
6
+
7
+ - Zero runtime dependencies (uses the built-in `fetch`, `FormData`, and Web Crypto)
8
+ - Works in browsers, Node.js 20+, Bun, Deno, and edge runtimes
9
+ - ESM + CommonJS builds with complete type declarations
10
+ - Automatic envelope unwrapping and typed API errors
11
+ - Automatic access-token refresh on `401`
12
+ - Timeouts, backoff retries for safe requests, and request cancellation
13
+ - Stripe webhook signature verification helper
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @malawistore/store-sdk
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { MalawiStore } from "@malawistore/store-sdk";
25
+
26
+ const store = new MalawiStore({
27
+ baseUrl: "https://shop.malawi.mw/api/v1",
28
+ });
29
+
30
+ // Browse the catalog without logging in
31
+ const categories = await store.categories.list();
32
+ const products = await store.products.list({ category_id: categories[0].id });
33
+
34
+ // Log in — the client stores the returned tokens for you
35
+ const session = await store.auth.login({
36
+ email: "aisha@example.mw",
37
+ password: "correct horse battery staple",
38
+ });
39
+
40
+ // Authenticated calls now carry the access token
41
+ const me = await store.users.me();
42
+ const cart = await store.cart.get();
43
+ ```
44
+
45
+ The default `baseUrl` is `http://localhost:8080/api/v1`, which matches the Go
46
+ backend's local development server.
47
+
48
+ ## Configuration
49
+
50
+ ```ts
51
+ const store = new MalawiStore({
52
+ baseUrl: "https://shop.malawi.mw/api/v1", // full API base, including /api/v1
53
+ token: savedAccessToken, // optional starting access token
54
+ refreshToken: savedRefreshToken, // enables automatic refresh
55
+ timeoutMs: 30_000, // 0 disables the timeout
56
+ autoRefresh: true, // rotate once on 401, then retry
57
+ retry: { retries: 2, baseDelayMs: 250, maxDelayMs: 2_000 },
58
+ headers: { "X-Client": "storefront-web" },
59
+ onTokenChange: ({ token, refreshToken }) => {
60
+ // Persist however your app does it: localStorage, cookies, secure storage…
61
+ },
62
+ onUnauthorized: () => {
63
+ // Refresh failed or no refresh token: send the user to the login screen.
64
+ },
65
+ });
66
+ ```
67
+
68
+ | Option | Type | Default | Purpose |
69
+ | ----------------- | ----------------------------- | --------------------------- | -------------------------------------------------------------- |
70
+ | `baseUrl` | `string` | `http://localhost:8080/api/v1` | API base URL, including the version prefix |
71
+ | `token` | `string` | — | Access token to start with |
72
+ | `refreshToken` | `string` | — | Enables automatic session renewal |
73
+ | `fetch` | `FetchLike` | global `fetch` | Custom transport (tests, proxies, tracing) |
74
+ | `headers` | `Record<string, string>` | `{}` | Headers added to every request |
75
+ | `timeoutMs` | `number` | `30000` | Per-request timeout; `0` disables it |
76
+ | `autoRefresh` | `boolean` | `true` | Refresh once on `401` and replay the request |
77
+ | `retry` | `RetryOptions \| false` | `{ retries: 2, … }` | Retries `GET`/`HEAD` on `429`, `5xx`, and network errors |
78
+ | `healthUrl` | `string` | derived `<origin>/health` | Override the raw health-check URL |
79
+ | `onTokenChange` | `(tokens) => void` | — | Persist rotated tokens |
80
+ | `onUnauthorized` | `() => void` | — | Called when a request stays unauthorized |
81
+
82
+ ### Session persistence
83
+
84
+ The SDK never touches `localStorage` itself. Persist the pair wherever your app
85
+ keeps secrets, then pass it back on the next boot:
86
+
87
+ ```ts
88
+ const store = new MalawiStore({
89
+ baseUrl: "https://shop.malawi.mw/api/v1",
90
+ token: localStorage.getItem("access_token") ?? undefined,
91
+ refreshToken: localStorage.getItem("refresh_token") ?? undefined,
92
+ onTokenChange: ({ token, refreshToken }) => {
93
+ if (token) localStorage.setItem("access_token", token);
94
+ if (refreshToken) localStorage.setItem("refresh_token", refreshToken);
95
+ },
96
+ onUnauthorized: () => {
97
+ localStorage.removeItem("access_token");
98
+ localStorage.removeItem("refresh_token");
99
+ redirectToLogin();
100
+ },
101
+ });
102
+ ```
103
+
104
+ Concurrent requests that hit `401` share a single refresh call, so a page with
105
+ several parallel queries does not rotate the token multiple times.
106
+
107
+ > ⚠️ Browser storage is readable by any script on the page. For high-value
108
+ > accounts, keep refresh tokens in an `HttpOnly` cookie on your own backend and
109
+ > proxy session handling. The API itself uses Authorization headers, not cookies.
110
+
111
+ ## Resources
112
+
113
+ | Property | Covers |
114
+ | --------------------- | ------------------------------------------------------------------- |
115
+ | `store.auth` | Register, login, refresh, logout, email verification, password reset |
116
+ | `store.users` | Current account, user lookup, role management (admin) |
117
+ | `store.categories` | Taxonomy reads, admin CRUD, image upload |
118
+ | `store.products` | Catalog reads, admin CRUD, image upload |
119
+ | `store.variants` | SKU reads and admin CRUD |
120
+ | `store.cart` | Get, add, update quantity, remove, clear |
121
+ | `store.inventory` | Stock lookup and admin set/reserve/release/confirm |
122
+ | `store.orders` | Checkout, list, get, cancel |
123
+ | `store.payments` | Stripe payment intent creation |
124
+ | `store.wishlist` | List, add, remove |
125
+ | `store.reviews` | Product summary, create, edit, delete, admin moderation |
126
+
127
+ ### Auth
128
+
129
+ ```ts
130
+ await store.auth.register({ email, name, password });
131
+ await store.auth.login({ email, password });
132
+ await store.auth.refresh(); // rotate the stored refresh token
133
+ await store.auth.logout(); // revoke + clear local tokens
134
+
135
+ await store.auth.requestEmailVerification(email);
136
+ await store.auth.verifyEmail({ email, code: "482914" });
137
+
138
+ await store.auth.requestPasswordReset(email);
139
+ await store.auth.resetPassword({ token, new_password });
140
+ ```
141
+
142
+ Register, login, and refresh store the returned `TokenPair` automatically.
143
+ Password reset clears local tokens because the API revokes sessions.
144
+
145
+ ### Catalog
146
+
147
+ ```ts
148
+ const categories = await store.categories.list();
149
+ const category = await store.categories.getBySlug("cooking-oil");
150
+
151
+ const products = await store.products.list({
152
+ category_id: category.id,
153
+ active_only: true,
154
+ limit: 20,
155
+ offset: 0,
156
+ });
157
+
158
+ const detail = await store.products.getBySlug("sunseed-cooking-oil-2l");
159
+ console.log(detail.product.price_cents); // 12500 → K125.00
160
+ console.log(detail.variants); // sellable SKUs
161
+
162
+ const skus = await store.variants.list(detail.product.id);
163
+ ```
164
+
165
+ For paginated grids use `listPage()`, which returns `{ data, meta }`:
166
+
167
+ ```ts
168
+ const page = await store.products.listPage({
169
+ q: "dress",
170
+ category_slug: "dresses",
171
+ min_price_cents: 1_000_00,
172
+ max_price_cents: 9_000_00,
173
+ in_stock: true,
174
+ size: "XL",
175
+ min_rating: 4,
176
+ sort: "price_desc",
177
+ page: 2,
178
+ limit: 24,
179
+ });
180
+
181
+ page.meta; // page, limit, offset, total, total_pages, has_next, has_prev
182
+ ```
183
+
184
+ Categories support `q`, `parent_id`, `root_only`, `has_products`, `sort`,
185
+ `page`, `limit` and `offset` through `categories.list()` or
186
+ `categories.listPage()`. `page` wins over `offset`; the API defaults to 20
187
+ rows and caps a page at 100.
188
+
189
+ Variant rows include `available` (on-hand minus reservations) and `in_stock`,
190
+ both in the product detail and `/products/:id/variants` responses.
191
+
192
+ Prices are integer **USD cents**: `12500` means `$125.00`. Never convert to
193
+ floating point for arithmetic. Historical orders may still carry MWK
194
+ tambala; the API converts those at charge time.
195
+
196
+ ### Cart and checkout
197
+
198
+ ```ts
199
+ await store.cart.addItem({ product_id: "…", qty: 2 });
200
+ await store.cart.addItem({ product_id: "…", variant_id: "…", qty: 1 });
201
+ await store.cart.setQty("cart-line-id", 3);
202
+ await store.cart.removeItem("cart-line-id");
203
+
204
+ const cart = await store.cart.get();
205
+ console.log(cart.subtotal_cents);
206
+
207
+ // Freezes the cart, reserves stock, and clears the cart
208
+ const order = await store.orders.checkout();
209
+
210
+ // Create a Stripe intent and confirm it with Stripe.js
211
+ const payment = await store.payments.createIntent(order.id);
212
+ // stripe.confirmCardPayment(payment.client_secret, { … })
213
+ ```
214
+
215
+ ### Orders
216
+
217
+ ```ts
218
+ const orders = await store.orders.list();
219
+ const order = await store.orders.get(orderId);
220
+ const cancelled = await store.orders.cancel(orderId); // pending orders only
221
+ ```
222
+
223
+ ### Wishlist and reviews
224
+
225
+ ```ts
226
+ await store.wishlist.add(productId);
227
+ const wishlist = await store.wishlist.list();
228
+ await store.wishlist.remove(productId); // product ID, not row ID
229
+
230
+ const summary = await store.reviews.summary(productId);
231
+ await store.reviews.create(productId, { rating: 5, title: "Great", body: "…" });
232
+ await store.reviews.update(reviewId, { rating: 4 });
233
+ await store.reviews.delete(reviewId);
234
+ ```
235
+
236
+ ### Admin
237
+
238
+ All write methods require an account with the `admin` role.
239
+
240
+ ```ts
241
+ const product = await store.products.create({
242
+ name: "Sunseed Cooking Oil 2L",
243
+ category_id: categoryId,
244
+ price_cents: 12500,
245
+ currency: "USD",
246
+ });
247
+
248
+ await store.products.update(product.id, { is_active: false });
249
+ await store.variants.create(product.id, { name: "500ml", sku: "OIL-500" });
250
+ await store.inventory.setStock({ product_id: product.id, qty: 50 });
251
+
252
+ await store.users.setRole(userId, { role: "admin" });
253
+ await store.reviews.deleteAny(reviewId); // moderation
254
+
255
+ // Admin user directory: filters + pagination
256
+ const users = await store.users.list({ role: "customer", q: "aisha", sort: "newest" });
257
+ const page = await store.users.listPage({ email_verified: false, page: 1, limit: 50 });
258
+ page.meta.total; // matching accounts
259
+ ```
260
+
261
+ ### Image uploads
262
+
263
+ Uploads are queued server-side (HTTP `202`) and applied by the worker once
264
+ Cloudinary finishes. The method resolves as soon as the job is accepted.
265
+
266
+ ```ts
267
+ // Browser
268
+ const file = fileInput.files![0];
269
+ await store.products.uploadImage(productId, file);
270
+
271
+ // Node: wrap a Buffer
272
+ import { readFile } from "node:fs/promises";
273
+ const data = await readFile("./photo.jpg");
274
+ await store.categories.uploadImage(categoryId, {
275
+ data,
276
+ name: "photo.jpg",
277
+ type: "image/jpeg",
278
+ });
279
+ ```
280
+
281
+ Maximum size is 10 MiB per image.
282
+
283
+ ## Error handling
284
+
285
+ Every failure is a `MalawiStoreError` with a stable `code`, the HTTP `status`,
286
+ and the API's `X-Request-ID` when available.
287
+
288
+ ```ts
289
+ import { isMalawiStoreError } from "@malawistore/store-sdk";
290
+
291
+ try {
292
+ await store.products.get("missing");
293
+ } catch (error) {
294
+ if (isMalawiStoreError(error)) {
295
+ switch (error.code) {
296
+ case "NOT_FOUND": /* show an empty state */ break;
297
+ case "UNAUTHORIZED": /* log in again */ break;
298
+ case "FORBIDDEN": /* not an admin */ break;
299
+ case "CONFLICT": /* duplicate / state conflict */ break;
300
+ case "RATE_LIMITED": /* back off for error.retryAfterMs */ break;
301
+ case "TIMEOUT": /* network too slow */ break;
302
+ default: /* INTERNAL, NETWORK_ERROR, PARSE_ERROR, ABORTED */
303
+ }
304
+ console.error(error.code, error.message, error.requestId);
305
+ }
306
+ }
307
+ ```
308
+
309
+ API codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`,
310
+ `CONFLICT`, `RATE_LIMITED`, `INTERNAL`.
311
+ Client-side codes: `NETWORK_ERROR`, `TIMEOUT`, `ABORTED`, `PARSE_ERROR`.
312
+
313
+ Cancellation and per-request overrides are supported on every method via the
314
+ escape hatch:
315
+
316
+ ```ts
317
+ const controller = new AbortController();
318
+ await store.request("GET", "/products", {
319
+ signal: controller.signal,
320
+ timeoutMs: 5_000,
321
+ });
322
+ ```
323
+
324
+ ## Stripe webhook verification
325
+
326
+ The Go API verifies its own Stripe webhooks. If you also receive events in an
327
+ edge function or a separate service, the SDK ships a dependency-free verifier
328
+ built on Web Crypto:
329
+
330
+ ```ts
331
+ import { constructStripeEvent, verifyStripeSignature } from "@malawistore/store-sdk";
332
+
333
+ // Edge function / Node handler
334
+ const payload = await request.text(); // raw body, never re-stringified
335
+ const signature = request.headers.get("stripe-signature")!;
336
+
337
+ if (!(await verifyStripeSignature({ payload, signature, secret }))) {
338
+ return new Response("invalid signature", { status: 400 });
339
+ }
340
+
341
+ const event = await constructStripeEvent({ payload, signature, secret });
342
+ if (event.type === "checkout.session.completed") {
343
+ // fulfil the order
344
+ }
345
+ ```
346
+
347
+ The verifier enforces a 5-minute timestamp tolerance by default
348
+ (`toleranceSeconds: 300`; set `0` to disable). It also accepts `Uint8Array`
349
+ payloads.
350
+
351
+ ## Health check
352
+
353
+ `health()` calls the raw `/health` endpoint outside `/api/v1` and does not
354
+ unwrap an envelope:
355
+
356
+ ```ts
357
+ await store.health(); // { status: "ok" }
358
+ ```
359
+
360
+ ## Runtime notes
361
+
362
+ - **Node.js 20+** is required (global `fetch`, `FormData`, and Web Crypto).
363
+ - In browsers, route requests through the same origin or enable CORS on the API
364
+ (`FRONTEND_URL` on the backend).
365
+ - The SDK sets `Content-Type: application/json` only when sending a JSON body;
366
+ multipart uploads let the runtime generate the boundary.
367
+ - All IDs in paths are URL-encoded.
368
+ - `request()` is available as an escape hatch for endpoints added after an SDK
369
+ release.
370
+
371
+ ## Development
372
+
373
+ ```bash
374
+ npm install
375
+ npm run typecheck # strict TypeScript
376
+ npm test # Vitest, 81 tests
377
+ npm run build # ESM + CJS + declarations
378
+ npm run pack:check # inspect the published tarball
379
+ ```
380
+
381
+ ## License
382
+
383
+ MIT