@vibedout/store-sdk
v0.2.0
Published
Typed clients for a shop's two APIs, generated from their OpenAPI 3.1 documents: createStoreClient for a storefront (the catalogue, a cart, the checkout, an order and its tracking, with the store's publishable key) and createServerClient for a server (ord
Maintainers
Readme
@vibedout/store-sdk
Typed clients for the two APIs a vibedout shop serves, generated from their OpenAPI 3.1 documents:
| Client | API | Credential | For |
|---|---|---|---|
| createStoreClient({ baseUrl, publishableKey }) or createStoreClient({ baseUrl, secretKey }) | /api/store (the store feature hosts it; region, catalog, pricing, checkout, fulfilment add to it) | the store's publishable key pk_... (safe in a browser, bound to the origins it lists), or from a server its secret key sk_... (refused where there is a page, and by the shop from any browser) | a storefront or an app: the catalogue, a cart, the checkout, an order and its tracking |
| createServerClient({ baseUrl, apiKey }) | /api/v1 (the connectors feature hosts it; catalog, checkout, fulfilment, inventory add to it) | a secret key vsk_... with the permissions it needs | a server: orders, stock, the fulfilment queue, events and hooks |
No dependency but fetch (the platform's JSON transport, @vibedout/api-client-core, is bundled into the build). It runs in a browser, a Worker, Node 20+ and Deno. Every type the documents name is exported (import type { Order } from "@vibedout/store-sdk/store").
The package is private in this repository for now (@vibedout/store-sdk 0.2.0 is not on npm); the documents it is made from are the contract.
A storefront buying
import { createStoreClient, ApiError } from "@vibedout/store-sdk";
const shop = createStoreClient({ baseUrl: "https://tienda.example", publishableKey: "pk_..." });
const { rows } = await shop.listProducts({ query: { limit: 24, sort: "title:asc" } });
const product = await shop.getProduct({ path: { handle: "etiqueta-nfc" } });
// A cart is a capability: every answer carries a fresh `cartToken`, and the next call sends it back.
let { cartToken } = await shop.createCart();
({ cartToken } = await shop.addCartLine({ cartToken, body: { source: "catalog.variant", ref: product.variants[0].id, quantity: 2 } }));
const quote = await shop.quoteCart({ cartToken, body: { destination: { country: "MX", postalCode: "06600" }, shippingRateId: "standard" } });
if (quote.totalMinor === null) throw new Error(`not ready to check out: ${quote.issues.join(", ")}`); // e.g. a shipping rate is still to be chosen
// The server prices the cart; the buyer is charged only the total they were shown.
const order = await shop.completeCart({
cartToken,
headers: { "idempotency-key": crypto.randomUUID() }, // a UUID (or 32+ random characters): the same key again is the same order, while it waits for its payment
// returnUrl: "https://tienda.example/gracias", // where the provider sends the buyer after paying: an address at an origin the key lists
body: { email: "[email protected]", address, shippingRateId: "standard", expectedTotalMinor: quote.totalMinor },
});
location.href = order.redirectUrl!; // the payment provider's page
// Later (the order's token is the buyer's way back; keep it): the order is paid by the payment's own event, so poll.
const mine = await shop.getOrder({ path: { orderId: order.orderId }, orderToken: order.orderToken });
const tracking = await shop.getOrderTracking({ path: { orderId: order.orderId }, orderToken: order.orderToken });An error answer is thrown as ApiError (status, code, message, details, and reason when the API gives one: order_token_invalid, origin_not_allowed, over_max_quantity, ...). A refusal of the key itself (a revoked key, an origin the key does not list, a limit) is readable from a browser when the page's origin is one the shop lists, so a storefront with a revoked key gets unauthorized with reason: "key_invalid" and not a network_error (unless the revoked key was the only one that listed the page's origin: then no active key vouches for the page, the 401 carries no CORS headers and the browser still hides it; make a new key with the same origins); network_error (status 0) is only a request that got no readable answer.
Branching on a refusal
Each operation says what it can be refused with, and refusalOf(operationId, error) returns the documented refusal or undefined, typed by the operation:
import { refusalOf } from "@vibedout/store-sdk";
try {
await shop.completeCart({ cartToken, body, headers: { "idempotency-key": key } });
} catch (error) {
const refusal = refusalOf("completeCart", error);
if (refusal === undefined) throw error; // not one this operation lists (offline, a fault, a newer reason)
switch (refusal.reason) {
case "price_changed": showTheNewTotal(refusal.error.details); break; // the new quote is in details.quote
case "sold_out": case "over_max_quantity": fixTheCart(); break;
case "idempotency_key_reused": key = crypto.randomUUID(); break;
case "guest_checkout_off": tellTheBuyerTheShopIsClosed(); break;
case "key_invalid": case "key_missing": reloadWithTheRightKey(); break; // the key itself, from any operation
default: throw error; // a reason this page does not handle yet
}
}refusalOf is generated from the x-vibedout-errors of each 4xx/5xx response of the document, so it moves with the shop. To have the compiler ask for a new reason, assign refusal.reason to a never in the default branch (or index a Record<NonNullable<Refusals["completeCart"]["reason"]>, ...>, as test/refusals.test.ts does): a reason added to the document then stops the build until it is handled. A refusal that lists no reason (a validation error) has reason === undefined. serverRefusalOf does the same for the server client.
A server calling the storefront API, and a server reading the shop
const shop = createStoreClient({ baseUrl: "https://tienda.example", secretKey: process.env.SHOP_SECRET_KEY! }); // sk_...: made on the Store page, shown onceA secret key is for a storefront rendered on a server, a backend for an app, a script: it never goes in a page. The client refuses to be made where there is a document, and the shop refuses a secret key that arrives with an Origin or Sec-Fetch-Site header (403 secret_key_in_browser). It names a sales channel like a publishable key and has no permissions of its own. Reading the shop's data (orders, stock, hooks) is the server API, with a vsk_ key and the permissions it was made with:
import { createServerClient } from "@vibedout/store-sdk";
const api = createServerClient({ baseUrl: "https://tienda.example", apiKey: process.env.SHOP_API_KEY! }); // never in a browser
const { data, next } = await api.listOrders({ query: { status: "fulfilled", limit: 50 } }); // orders:read
const order = await api.getOrderDetail({ path: { id: data[0].id } }); // + the delivery summary
const way = await api.getOrderFulfilment({ path: { id: order.id } }); // fulfilment:read: address, parcel, history, changes
const stock = await api.listStock({ query: { sku: "TAG-1,CARD-1" } }); // inventory:read
const hook = await api.createHook({ body: { target_url: "https://example.com/hook", events: ["order.*"] } });A key holds the permissions it was made with (the Connectors page of the admin area): a call it has no permission for is 403 forbidden, a made-up, revoked or missing key is 401.
The webhooks of the server document list every event the shop sends (order.paid, order.shipped, ...) by name; the payloads are signed with Standard Webhooks and carry ids and amounts, never an address.
The documents are the contract
openapi/store.json and openapi/server.json are committed reference documents: what a reference shop answers at GET /api/store/openapi.json and GET /api/v1/openapi.json, which each feature writes from the Zod schemas its own routes validate with
(@vibedout/api-contract). They are OpenAPI 3.1 (JSON Schema 2020-12), have an operationId on every operation and a named component for every shape, and use x-order-token / x-cart-token / x-publishable-api-key as apiKey security schemes and a bearer scheme for the server key.
Because a generator reads only the document, another language's client comes from the same files: point a generator at openapi/store.json (the Flutter apps' Dart client is the intended second consumer) or at a shop's own /api/store/openapi.json, which also lists what that shop has installed (a shop with no cart has no cart operations).
No Dart client was generated or run here; the documents were validated with the checks below, which is what a Dart generator needs to start from. Generators differ in how much of 3.1 they read (nullable types are written type: ["string", "null"]): if one does not, down-convert the document first.
Checks on a document (run on the committed ones and on what the reference shop served):
npx @redocly/cli lint openapi/store.json openapi/server.json # structure, refs, operationIds, security
npx @seriousme/openapi-schema-validator openapi/store.json # the OpenAPI 3.1 JSON Schema, strictlyRegenerating, and the stale check
pnpm --filter @vibedout/store-sdk generate # src/generated/{store,server}.ts from the two documents
pnpm --filter @vibedout/store-sdk check # exits 1 when a generated file is not what the documents make (also part of the root `pnpm lint` and `pnpm sdk:check`)The generator (scripts/generate.mjs) has no dependency. It reads servers, components.schemas, securitySchemes, webhooks, and each operation's id, parameters, JSON body, first 2xx JSON answer and security; anything else it meets is an error, not a guess, so a generated client never silently drops a parameter.
A change to an operation in a feature's store-api.ts / api.ts changes the shop's document; bring the committed documents and the client up to date in this order:
- Start a reference shop: a
cf-react-hono-d1project withvo blocks add store region catalog pricing inventory checkout fulfilment connectors(checkout --option cart=yes, and what they need:payments,shipping,users,admin). Run it (wrangler dev) and make a publishable key (Store page) and a server key holdingevents:read,hooks:read,hooks:write,catalog:read,orders:read,inventory:read,fulfilment:read(Connectors page), so the server document lists every feature's operations. STORE_SDK_API_KEY=vsk_... pnpm --filter @vibedout/store-sdk refresh -- --base http://localhost:8787 --publishable-key pk_...writes the two documents in a canonical form (sorted keys, the shop's name taken out of the title, so another shop with the same blocks makes no diff).--checkcompares only. The key is read from the environment and never printed.pnpm --filter @vibedout/store-sdk generate, review the diff, runpnpm --filter @vibedout/store-sdk test, commit the documents and the generated files together.
Tests
pnpm --filter @vibedout/store-sdk test: the documents (OpenAPI 3.1, an operationId on every operation, every {name} a path parameter, every $ref resolves, a closed list of JSON Schema keywords, the credentials and the buyer's proofs as security schemes, the order transitions among the events a hook can follow, the server API reading only besides its hooks), the generator (names, types, nullable, enum and union, what it does not understand is an error, --check fails on a stale or hand-edited file and says how to fix it),
the clients against a recording fetch (where the path, query, headers, body, key and proofs go; what is refused before anything is sent; how an error answer becomes ApiError), and the package (MIT, no dependency, no @vibedout/* import left in the build).
The proof that the documents tell the truth is the live run in the section's run log: this SDK drives a reference shop on wrangler dev (a storefront buying with a mock payment provider, a server reading the order, the stock and the tracking) and every JSON answer is validated against the schema its document gives it (Ajv, JSON Schema 2020-12).
What it does not do
- It does not make a shop's payment page, sign a webhook or verify one: verify a delivery with any Standard Webhooks library and the hook's secret.
- The storefront buyer is always a guest (an order is opened by its token, a cart by its cart token); a signed-in customer's session is not part of this API yet.
- The payload of each webhook event is not typed in the document (the bus has the event's type, not a runtime schema).
Versions
- 0.2.0 (
50.17-T52,T51,T38):refusalOf/serverRefusalOfover thex-vibedout-errorsof the documents,createStoreClient({ secretKey })(refused where there is adocument), and the proofs read across alternative security requirements. Additive; a client made with 0.1.0 options works. - 0.1.0: the two clients generated from the two documents.
