@porulle/adapter-woocommerce 0.73.2 → 0.74.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.
@@ -0,0 +1,195 @@
1
+ import { Err, Ok, toMinorUnits } from "@porulle/core";
2
+ import { z } from "zod";
3
+ const id = z.union([z.number(), z.string()]).transform(String);
4
+ const money = z.string().nullish();
5
+ const image = z.object({ id, src: z.string(), alt: z.string().nullish() });
6
+ export const wooVariationSchema = z.object({
7
+ id,
8
+ sku: z.string().nullish(),
9
+ price: money,
10
+ regular_price: money,
11
+ sale_price: money,
12
+ status: z.string().nullish(),
13
+ attributes: z.array(z.object({ name: z.string(), option: z.string().nullish() })).default([]),
14
+ image: image.nullish(),
15
+ manage_stock: z.union([z.boolean(), z.literal("parent")]).nullish(),
16
+ stock_quantity: z.number().nullish(),
17
+ stock_status: z.string().nullish(),
18
+ });
19
+ export const wooProductSchema = z.object({
20
+ id,
21
+ name: z.string(),
22
+ slug: z.string().nullish(),
23
+ type: z.string(),
24
+ status: z.string().nullish(),
25
+ description: z.string().nullish(),
26
+ permalink: z.string().nullish(),
27
+ sku: z.string().nullish(),
28
+ price: money,
29
+ regular_price: money,
30
+ sale_price: money,
31
+ images: z.array(image).default([]),
32
+ attributes: z.array(z.object({ name: z.string(), position: z.number().nullish(), variation: z.boolean().nullish(), options: z.array(z.string()).default([]) })).default([]),
33
+ tags: z.array(z.object({ slug: z.string().nullish() })).default([]),
34
+ categories: z.array(z.object({ slug: z.string().nullish() })).default([]),
35
+ variations: z.array(id).default([]),
36
+ manage_stock: z.boolean().nullish(),
37
+ stock_quantity: z.number().nullish(),
38
+ stock_status: z.string().nullish(),
39
+ });
40
+ /** Products a shopper can buy through us: a grouped product is a list of others; an external one is sold elsewhere. */
41
+ export function isPurchasable(product) {
42
+ return product.type === "simple" || product.type === "variable";
43
+ }
44
+ /** The amount a shopper pays and, when on sale, the price it is reduced from. Missing price → no price. */
45
+ function prices(row, currency) {
46
+ if (!currency)
47
+ return undefined;
48
+ const sale = toMinorUnits(row.sale_price, currency);
49
+ const regular = toMinorUnits(row.regular_price, currency);
50
+ const amount = sale ?? regular ?? toMinorUnits(row.price, currency);
51
+ if (amount === undefined)
52
+ return undefined;
53
+ return [{ currency, amount, ...(sale !== undefined && regular !== undefined && regular > sale ? { compareAtAmount: regular } : {}) }];
54
+ }
55
+ function status(value) {
56
+ if (value === "publish")
57
+ return "active";
58
+ if (value === "draft" || value === "pending" || value === "private")
59
+ return "draft";
60
+ return undefined;
61
+ }
62
+ function variant(row, currency) {
63
+ const optionValues = Object.fromEntries(row.attributes.flatMap((attribute) => (attribute.option ? [[attribute.name, attribute.option]] : [])));
64
+ const rowPrices = prices(row, currency);
65
+ return {
66
+ externalId: row.id,
67
+ ...(row.sku ? { sku: row.sku } : {}),
68
+ ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
69
+ ...(rowPrices ? { prices: rowPrices } : {}),
70
+ };
71
+ }
72
+ /**
73
+ * One store product as a catalogue item. A simple product is one variant named by the product's own
74
+ * id (only variations carry a price otherwise, so a simple product would import unpriced).
75
+ */
76
+ export function catalogItem(product, variations, currency) {
77
+ const images = product.images.map((entry, index) => ({
78
+ externalId: entry.id,
79
+ url: entry.src,
80
+ ...(entry.alt ? { alt: entry.alt } : {}),
81
+ role: index === 0 ? "primary" : "gallery",
82
+ sortOrder: index,
83
+ }));
84
+ const seen = new Set(images.map((entry) => entry.externalId));
85
+ for (const row of variations) {
86
+ if (!row.image || row.image.id === "0")
87
+ continue;
88
+ const existing = images.find((entry) => entry.externalId === row.image?.id);
89
+ if (existing) {
90
+ existing.variantExternalIds = [...(existing.variantExternalIds ?? []), row.id];
91
+ }
92
+ else if (!seen.has(row.image.id)) {
93
+ seen.add(row.image.id);
94
+ images.push({ externalId: row.image.id, url: row.image.src, ...(row.image.alt ? { alt: row.image.alt } : {}), role: "gallery", sortOrder: images.length, variantExternalIds: [row.id] });
95
+ }
96
+ }
97
+ const options = product.attributes.filter((attribute) => attribute.variation === true).map((attribute, index) => ({
98
+ name: attribute.name,
99
+ displayName: attribute.name,
100
+ sortOrder: attribute.position ?? index,
101
+ values: attribute.options.map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
102
+ }));
103
+ const variants = product.type === "simple"
104
+ ? [variant({ id: product.id, sku: product.sku, price: product.price, regular_price: product.regular_price, sale_price: product.sale_price, attributes: [] }, currency)]
105
+ : variations.filter((row) => row.status !== "private").map((row) => variant(row, currency));
106
+ const itemStatus = status(product.status);
107
+ return {
108
+ externalId: product.id,
109
+ slug: product.slug || product.id,
110
+ title: product.name,
111
+ ...(product.description ? { description: product.description } : {}),
112
+ attributes: [{ locale: "en", title: product.name, ...(product.description ? { description: product.description } : {}) }],
113
+ variants,
114
+ ...(images.length > 0 ? { images } : {}),
115
+ ...(options.length > 0 ? { options } : {}),
116
+ tags: product.tags.flatMap((tag) => (tag.slug ? [tag.slug] : [])),
117
+ categories: product.categories.flatMap((category) => (category.slug ? [category.slug] : [])),
118
+ ...(itemStatus ? { status: itemStatus } : {}),
119
+ ...(product.permalink ? { storefrontUrl: product.permalink } : {}),
120
+ };
121
+ }
122
+ /** Every variation of a variable product, whatever changed: a changed product is re-read whole. */
123
+ export async function variationsOf(client, product) {
124
+ if (product.type !== "variable" || product.variations.length === 0)
125
+ return Ok([]);
126
+ return client.all(`/wc/v3/products/${encodeURIComponent(product.id)}/variations`, wooVariationSchema);
127
+ }
128
+ /**
129
+ * The import cursor, as JSON: `{ page, after? }`. `after` is the `date_modified_gmt` an incremental
130
+ * import resumes from; pages are walked oldest change first so a resumed walk never skips one.
131
+ */
132
+ const cursorSchema = z.object({ page: z.number().int().positive(), after: z.string().optional() });
133
+ export async function importPage(client, cursor) {
134
+ let position = { page: 1 };
135
+ if (cursor) {
136
+ let raw;
137
+ try {
138
+ raw = JSON.parse(cursor);
139
+ }
140
+ catch {
141
+ raw = undefined;
142
+ }
143
+ const parsed = cursorSchema.safeParse(raw);
144
+ // Anything else is an ISO time: "everything changed since".
145
+ position = parsed.success ? parsed.data : { page: 1, after: cursor };
146
+ }
147
+ const query = { per_page: "50", page: String(position.page), orderby: "modified", order: "asc" };
148
+ if (position.after) {
149
+ query.modified_after = position.after;
150
+ query.dates_are_gmt = "true";
151
+ }
152
+ const read = await client.get("/wc/v3/products", z.array(wooProductSchema), query);
153
+ if (!read.ok)
154
+ return read;
155
+ const items = [];
156
+ for (const product of read.value.data) {
157
+ if (!isPurchasable(product))
158
+ continue;
159
+ const variations = await variationsOf(client, product);
160
+ if (!variations.ok)
161
+ return variations;
162
+ items.push(catalogItem(product, variations.value, client.credentials.currency));
163
+ }
164
+ const nextCursor = position.page < read.value.totalPages ? JSON.stringify({ ...position, page: position.page + 1 }) : null;
165
+ return Ok({ items, nextCursor });
166
+ }
167
+ /** The named products as they are now; an id the store no longer has, or cannot sell through us, is absent. */
168
+ export async function catalogItems(client, externalIds) {
169
+ const items = [];
170
+ for (let offset = 0; offset < externalIds.length; offset += 100) {
171
+ const ids = externalIds.slice(offset, offset + 100);
172
+ const read = await client.get("/wc/v3/products", z.array(wooProductSchema), { include: ids.join(","), per_page: "100", status: "any" });
173
+ if (!read.ok)
174
+ return read;
175
+ for (const product of read.value.data) {
176
+ if (!isPurchasable(product))
177
+ continue;
178
+ const variations = await variationsOf(client, product);
179
+ if (!variations.ok)
180
+ return variations;
181
+ items.push(catalogItem(product, variations.value, client.credentials.currency));
182
+ }
183
+ }
184
+ return Ok(items);
185
+ }
186
+ const rootSchema = z.object({ name: z.string() });
187
+ export async function storeProfile(client) {
188
+ const root = await client.get("/", rootSchema);
189
+ if (!root.ok)
190
+ return root;
191
+ const currency = client.credentials.currency;
192
+ if (!currency)
193
+ return Err({ code: "WOO_STORE_NOT_DISCOVERED", message: "The store's currency is not known yet.", retriable: true });
194
+ return Ok({ name: root.value.data.name, currency, storefrontHosts: [new URL(client.base).host] });
195
+ }
@@ -0,0 +1,85 @@
1
+ import type { ChannelConnectorError, ChannelStore, Result } from "@porulle/core";
2
+ import { z } from "zod";
3
+ /**
4
+ * Everything the adapter learns about one store, kept in its credentials. The keys come from
5
+ * WooCommerce's approval screen; the rest is discovered once by `discoverStore` and persisted through
6
+ * the plugin's `liveCredentials` hook, so no call has to rediscover it.
7
+ */
8
+ export declare const wooCredentialsSchema: z.ZodObject<{
9
+ consumerKey: z.ZodString;
10
+ consumerSecret: z.ZodString;
11
+ authMode: z.ZodOptional<z.ZodEnum<{
12
+ query: "query";
13
+ header: "header";
14
+ }>>;
15
+ restRoute: z.ZodOptional<z.ZodEnum<{
16
+ query: "query";
17
+ pretty: "pretty";
18
+ }>>;
19
+ currency: z.ZodOptional<z.ZodString>;
20
+ priceDecimals: z.ZodOptional<z.ZodNumber>;
21
+ hpos: z.ZodOptional<z.ZodBoolean>;
22
+ }, z.core.$strip>;
23
+ export type WooCredentials = z.infer<typeof wooCredentialsSchema>;
24
+ export interface WooTransportOptions {
25
+ fetchImpl: typeof fetch;
26
+ userAgent: string;
27
+ /** Tests point the adapter at a store on localhost; production never does. */
28
+ allowPrivateHosts: boolean;
29
+ }
30
+ export type WooProbeFailure = "not_json" | "rest_unavailable" | "tls" | "not_woocommerce" | "private_host" | "unreachable";
31
+ export declare const WOO_BLOCKED_BY_FIREWALL = "WOO_BLOCKED_BY_FIREWALL";
32
+ export declare const WOO_API_FAILED = "WOO_API_FAILED";
33
+ /**
34
+ * The canonical store URL a merchant's typing names: https only, lower-case host, any sub-directory
35
+ * install path kept, no trailing slash or `/wp-json` suffix. Undefined when it cannot name a store.
36
+ */
37
+ export declare function normalizeStoreDomain(input: string, options?: {
38
+ allowPrivateHosts?: boolean;
39
+ }): string | undefined;
40
+ /** A store URL the adapter refuses outright, for a message better than "does not name a store". */
41
+ export declare function refusedStoreUrl(input: string): WooProbeFailure | "http" | undefined;
42
+ /**
43
+ * Finds the store's REST API without credentials: `/wp-json/`, else `/?rest_route=/` for a store with
44
+ * plain permalinks. It must list `wc/v3`. Each failure is classified for a message the merchant can act on.
45
+ */
46
+ export declare function probeStore(options: WooTransportOptions, storeUrl: string): Promise<Result<{
47
+ restRoute: "pretty" | "query";
48
+ name: string;
49
+ }, {
50
+ code: WooProbeFailure;
51
+ message: string;
52
+ }>>;
53
+ export interface WooPage<T> {
54
+ data: T;
55
+ totalPages: number;
56
+ }
57
+ /** A store's REST client: keys sent as the store accepts them, every answer parsed, no secret in an error. */
58
+ export interface WooClient {
59
+ readonly base: string;
60
+ readonly credentials: WooCredentials;
61
+ get<T>(path: string, schema: z.ZodType<T>, query?: Record<string, string>): Promise<Result<WooPage<T>, WooRequestError>>;
62
+ send<T>(method: "POST" | "PUT" | "DELETE", path: string, body: unknown, schema: z.ZodType<T>, query?: Record<string, string>): Promise<Result<T, WooRequestError>>;
63
+ /** Every page of a list endpoint, 100 at a time. */
64
+ all<T>(path: string, schema: z.ZodType<T>, query?: Record<string, string>): Promise<Result<T[], WooRequestError>>;
65
+ }
66
+ declare const wooErrorSchema: z.ZodObject<{
67
+ code: z.ZodString;
68
+ message: z.ZodOptional<z.ZodString>;
69
+ data: z.ZodOptional<z.ZodUnknown>;
70
+ }, z.core.$strip>;
71
+ export type WooErrorBody = z.infer<typeof wooErrorSchema>;
72
+ /** The REST error WooCommerce answered, kept for callers that act on its code (e.g. a leftover draft order). */
73
+ export interface WooRequestError extends ChannelConnectorError {
74
+ status?: number;
75
+ body?: WooErrorBody;
76
+ }
77
+ export declare function wooClient(store: ChannelStore, options: WooTransportOptions): Result<WooClient, ChannelConnectorError>;
78
+ export declare function clientFor(base: string, credentials: WooCredentials, options: WooTransportOptions): WooClient;
79
+ /**
80
+ * What the adapter needs to know about a store before it can call it well: where its REST API
81
+ * answers, how it accepts our keys, and how it prices. Read once and persisted in the credentials.
82
+ * A key both auth modes refuse is {@link CHANNEL_CREDENTIALS_REJECTED}.
83
+ */
84
+ export declare function discoverStore(options: WooTransportOptions, storeDomain: string, credentials: WooCredentials): Promise<Result<WooCredentials, ChannelConnectorError>>;
85
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,269 @@
1
+ import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
2
+ import { z } from "zod";
3
+ /**
4
+ * Everything the adapter learns about one store, kept in its credentials. The keys come from
5
+ * WooCommerce's approval screen; the rest is discovered once by `discoverStore` and persisted through
6
+ * the plugin's `liveCredentials` hook, so no call has to rediscover it.
7
+ */
8
+ export const wooCredentialsSchema = z.object({
9
+ consumerKey: z.string().regex(/^ck_[a-f0-9]{40}$/),
10
+ consumerSecret: z.string().regex(/^cs_[a-f0-9]{40}$/),
11
+ /** `query` when the host strips the Authorization header (some proxies and caches do). */
12
+ authMode: z.enum(["header", "query"]).optional(),
13
+ /** `query` when pretty permalinks are off and the REST API answers only at `/?rest_route=`. */
14
+ restRoute: z.enum(["pretty", "query"]).optional(),
15
+ currency: z.string().optional(),
16
+ priceDecimals: z.number().int().min(0).max(4).optional(),
17
+ hpos: z.boolean().optional(),
18
+ });
19
+ export const WOO_BLOCKED_BY_FIREWALL = "WOO_BLOCKED_BY_FIREWALL";
20
+ export const WOO_API_FAILED = "WOO_API_FAILED";
21
+ const FIREWALL_MESSAGE = "The store answered with a web page instead of its API. A firewall in front of it (Cloudflare Bot Fight Mode, Wordfence or another security plugin) is blocking us; allow requests to /wp-json/wc/ from our service.";
22
+ function isPrivateHost(host) {
23
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
24
+ if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal") || h === "metadata.google.internal")
25
+ return true;
26
+ // IPv6 loopback, unique-local, link-local, IPv4-mapped.
27
+ if (h.includes(":"))
28
+ return h === "::1" || h === "::" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h) || h.startsWith("::ffff:");
29
+ // Any all-numeric host: dotted, single integer, hex or octal encodings all name an IP.
30
+ if (/^[0-9.]+$/.test(h) || /^0x[0-9a-f]+$/i.test(h)) {
31
+ const parts = h.split(".").map((part) => Number(part));
32
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
33
+ return true;
34
+ const [a = 0, b = 0] = parts;
35
+ return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
36
+ }
37
+ return false;
38
+ }
39
+ /**
40
+ * The canonical store URL a merchant's typing names: https only, lower-case host, any sub-directory
41
+ * install path kept, no trailing slash or `/wp-json` suffix. Undefined when it cannot name a store.
42
+ */
43
+ export function normalizeStoreDomain(input, options = {}) {
44
+ const typed = input.trim();
45
+ if (typed === "")
46
+ return undefined;
47
+ let url;
48
+ try {
49
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ const local = options.allowPrivateHosts === true && isPrivateHost(url.hostname);
55
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:"))
56
+ return undefined;
57
+ if (url.username || url.password || !url.hostname.includes(".") && !local)
58
+ return undefined;
59
+ if (isPrivateHost(url.hostname) && options.allowPrivateHosts !== true)
60
+ return undefined;
61
+ const path = url.pathname.replace(/\/+$/, "").replace(/\/wp-json$/i, "").replace(/\/+$/, "");
62
+ return `${url.protocol}//${url.host.toLowerCase()}${path}`;
63
+ }
64
+ /** A store URL the adapter refuses outright, for a message better than "does not name a store". */
65
+ export function refusedStoreUrl(input) {
66
+ try {
67
+ const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(input.trim()) ? input.trim() : `https://${input.trim()}`);
68
+ if (url.protocol === "http:")
69
+ return "http";
70
+ if (isPrivateHost(url.hostname))
71
+ return "private_host";
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ return undefined;
77
+ }
78
+ const restRootSchema = z.object({ name: z.string().optional(), namespaces: z.array(z.string()).optional() });
79
+ function stripBom(text) {
80
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
81
+ }
82
+ function looksLikeJson(response) {
83
+ return (response.headers.get("content-type") ?? "").toLowerCase().includes("json");
84
+ }
85
+ /** One request with no redirect followed: a redirect to another host, or to http, must not carry our keys. */
86
+ async function fetchOnce(options, url, init = {}) {
87
+ return options.fetchImpl(url, {
88
+ ...init,
89
+ redirect: "manual",
90
+ headers: { accept: "application/json", "user-agent": options.userAgent, ...(init.headers ?? {}) },
91
+ });
92
+ }
93
+ function restUrl(base, path, restRoute, query = {}) {
94
+ const url = restRoute === "pretty" ? new URL(`${base}/wp-json${path}`) : new URL(`${base}/`);
95
+ if (restRoute === "query")
96
+ url.searchParams.set("rest_route", path);
97
+ for (const [name, value] of Object.entries(query))
98
+ url.searchParams.set(name, value);
99
+ return url;
100
+ }
101
+ /**
102
+ * Finds the store's REST API without credentials: `/wp-json/`, else `/?rest_route=/` for a store with
103
+ * plain permalinks. It must list `wc/v3`. Each failure is classified for a message the merchant can act on.
104
+ */
105
+ export async function probeStore(options, storeUrl) {
106
+ const base = normalizeStoreDomain(storeUrl, { allowPrivateHosts: options.allowPrivateHosts });
107
+ if (!base)
108
+ return Err({ code: "private_host", message: "That address cannot be a public WooCommerce store." });
109
+ let sawHtml = false;
110
+ for (const restRoute of ["pretty", "query"]) {
111
+ let response;
112
+ try {
113
+ response = await fetchOnce(options, restUrl(base, "/", restRoute));
114
+ }
115
+ catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ if (/certificate|ssl|tls/i.test(message))
118
+ return Err({ code: "tls", message: "The store's HTTPS certificate was refused. It needs a valid certificate." });
119
+ return Err({ code: "unreachable", message: `The store could not be reached: ${message}` });
120
+ }
121
+ if (response.ok && looksLikeJson(response)) {
122
+ const root = restRootSchema.safeParse(JSON.parse(stripBom(await response.text())));
123
+ if (!root.success)
124
+ return Err({ code: "not_json", message: FIREWALL_MESSAGE });
125
+ if (!(root.data.namespaces ?? []).includes("wc/v3"))
126
+ return Err({ code: "not_woocommerce", message: "That site runs WordPress but not WooCommerce (or its REST API is turned off)." });
127
+ return Ok({ restRoute, name: root.data.name ?? base });
128
+ }
129
+ if (!looksLikeJson(response) && response.status !== 404)
130
+ sawHtml = true;
131
+ }
132
+ return sawHtml
133
+ ? Err({ code: "not_json", message: FIREWALL_MESSAGE })
134
+ : Err({ code: "rest_unavailable", message: "The store's REST API did not answer. Turn on pretty permalinks (Settings → Permalinks → Post name) and make sure no plugin disables the REST API." });
135
+ }
136
+ const wooErrorSchema = z.object({ code: z.string(), message: z.string().optional(), data: z.unknown().optional() });
137
+ function classify(status, json, body, what) {
138
+ if (status === 401 && json) {
139
+ return { code: CHANNEL_CREDENTIALS_REJECTED, message: `The store refused our key for ${what}${body?.message ? `: ${body.message}` : ""}.`, retriable: false, status, ...(body ? { body } : {}) };
140
+ }
141
+ if (!json && (status === 403 || status === 429 || status === 503 || status === 406)) {
142
+ return { code: WOO_BLOCKED_BY_FIREWALL, message: FIREWALL_MESSAGE, retriable: true, status };
143
+ }
144
+ const retriable = status >= 500 || status === 408 || status === 429;
145
+ return { code: WOO_API_FAILED, message: `WooCommerce answered ${status} for ${what}${body?.message ? `: ${body.message}` : ""}.`, retriable, status, ...(body ? { body } : {}) };
146
+ }
147
+ export function wooClient(store, options) {
148
+ const credentials = wooCredentialsSchema.safeParse(store.credentials);
149
+ if (!credentials.success)
150
+ return Err({ code: "WOO_CREDENTIALS_REQUIRED", message: "The store holds no valid WooCommerce keys; it must be reconnected.", retriable: false });
151
+ const base = normalizeStoreDomain(store.storeDomain, { allowPrivateHosts: options.allowPrivateHosts });
152
+ if (!base)
153
+ return Err({ code: "WOO_INVALID_STORE_DOMAIN", message: "The store's address is not an https URL.", retriable: false });
154
+ return Ok(clientFor(base, credentials.data, options));
155
+ }
156
+ export function clientFor(base, credentials, options) {
157
+ const authMode = credentials.authMode ?? "header";
158
+ const restRoute = credentials.restRoute ?? "pretty";
159
+ const authHeader = `Basic ${btoa(`${credentials.consumerKey}:${credentials.consumerSecret}`)}`;
160
+ async function call(method, path, query, body) {
161
+ const url = restUrl(base, path, restRoute, query);
162
+ if (authMode === "query") {
163
+ url.searchParams.set("consumer_key", credentials.consumerKey);
164
+ url.searchParams.set("consumer_secret", credentials.consumerSecret);
165
+ }
166
+ // Named in errors without its query string, which may carry the keys.
167
+ const what = `${method} ${path}`;
168
+ let response;
169
+ try {
170
+ response = await fetchOnce(options, url, {
171
+ method,
172
+ headers: { ...(authMode === "header" ? { authorization: authHeader } : {}), ...(body !== undefined ? { "content-type": "application/json" } : {}) },
173
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
174
+ });
175
+ }
176
+ catch (error) {
177
+ return Err({ code: WOO_API_FAILED, message: `The store could not be reached for ${what}: ${error instanceof Error ? error.message : String(error)}.`, retriable: true });
178
+ }
179
+ if (response.status >= 300 && response.status < 400) {
180
+ return Err({ code: WOO_API_FAILED, message: `The store redirected ${what} elsewhere (${response.status}); its address may have changed. Reconnect it at its current address.`, retriable: false, status: response.status });
181
+ }
182
+ const json = looksLikeJson(response);
183
+ const text = stripBom(await response.text());
184
+ let data;
185
+ if (json) {
186
+ try {
187
+ data = JSON.parse(text);
188
+ }
189
+ catch {
190
+ return Err({ code: WOO_BLOCKED_BY_FIREWALL, message: FIREWALL_MESSAGE, retriable: true, status: response.status });
191
+ }
192
+ }
193
+ if (!response.ok)
194
+ return Err(classify(response.status, json, json ? wooErrorSchema.safeParse(data).data : undefined, what));
195
+ if (!json)
196
+ return Err({ code: WOO_BLOCKED_BY_FIREWALL, message: FIREWALL_MESSAGE, retriable: true, status: response.status });
197
+ return Ok({ data, response });
198
+ }
199
+ function parse(schema, data, what) {
200
+ const parsed = schema.safeParse(data);
201
+ return parsed.success ? Ok(parsed.data) : Err({ code: "WOO_UNEXPECTED_RESPONSE", message: `WooCommerce answered ${what} in a shape we do not recognise: ${parsed.error.message.slice(0, 400)}`, retriable: false });
202
+ }
203
+ const client = {
204
+ base,
205
+ credentials,
206
+ async get(path, schema, query = {}) {
207
+ const answered = await call("GET", path, query, undefined);
208
+ if (!answered.ok)
209
+ return answered;
210
+ const parsed = parse(schema, answered.value.data, `GET ${path}`);
211
+ if (!parsed.ok)
212
+ return parsed;
213
+ const totalPages = Number.parseInt(answered.value.response.headers.get("x-wp-totalpages") ?? "1", 10);
214
+ return Ok({ data: parsed.value, totalPages: Number.isFinite(totalPages) && totalPages > 0 ? totalPages : 1 });
215
+ },
216
+ async send(method, path, body, schema, query = {}) {
217
+ const answered = await call(method, path, query, body);
218
+ if (!answered.ok)
219
+ return answered;
220
+ return parse(schema, answered.value.data, `${method} ${path}`);
221
+ },
222
+ async all(path, schema, query = {}) {
223
+ const rows = [];
224
+ for (let page = 1;; page += 1) {
225
+ const read = await client.get(path, z.array(schema), { per_page: "100", ...query, page: String(page) });
226
+ if (!read.ok)
227
+ return read;
228
+ rows.push(...read.value.data);
229
+ if (page >= read.value.totalPages || read.value.data.length === 0)
230
+ return Ok(rows);
231
+ }
232
+ },
233
+ };
234
+ return client;
235
+ }
236
+ const systemStatusSchema = z.object({
237
+ settings: z.object({
238
+ currency: z.string(),
239
+ number_of_decimals: z.coerce.number().int(),
240
+ HPOS_enabled: z.boolean().optional(),
241
+ }),
242
+ });
243
+ /**
244
+ * What the adapter needs to know about a store before it can call it well: where its REST API
245
+ * answers, how it accepts our keys, and how it prices. Read once and persisted in the credentials.
246
+ * A key both auth modes refuse is {@link CHANNEL_CREDENTIALS_REJECTED}.
247
+ */
248
+ export async function discoverStore(options, storeDomain, credentials) {
249
+ const base = normalizeStoreDomain(storeDomain, { allowPrivateHosts: options.allowPrivateHosts });
250
+ if (!base)
251
+ return Err({ code: "WOO_INVALID_STORE_DOMAIN", message: "The store's address is not an https URL.", retriable: false });
252
+ const probe = await probeStore(options, base);
253
+ if (!probe.ok)
254
+ return Err({ code: probe.error.code === "not_json" ? WOO_BLOCKED_BY_FIREWALL : "WOO_STORE_UNREACHABLE", message: probe.error.message, retriable: probe.error.code === "not_json" || probe.error.code === "unreachable" });
255
+ let firstError;
256
+ for (const authMode of ["header", "query"]) {
257
+ const client = clientFor(base, { ...credentials, authMode, restRoute: probe.value.restRoute }, options);
258
+ const status = await client.get("/wc/v3/system_status", systemStatusSchema);
259
+ if (status.ok) {
260
+ const { currency, number_of_decimals, HPOS_enabled } = status.value.data.settings;
261
+ return Ok({ ...credentials, authMode, restRoute: probe.value.restRoute, currency: currency.toUpperCase(), priceDecimals: number_of_decimals, hpos: HPOS_enabled === true });
262
+ }
263
+ firstError ??= status.error;
264
+ // Only a refused key is worth a second try in the other mode: a stripped header reads as no key.
265
+ if (status.error.code !== CHANNEL_CREDENTIALS_REJECTED)
266
+ return status;
267
+ }
268
+ return Err(firstError ?? { code: CHANNEL_CREDENTIALS_REJECTED, message: "The store refused our key.", retriable: false });
269
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import type { ChannelConnector } from "@porulle/core";
2
+ export { normalizeStoreDomain, probeStore, refusedStoreUrl, WOO_BLOCKED_BY_FIREWALL } from "./client.js";
3
+ export type { WooCredentials, WooProbeFailure } from "./client.js";
4
+ export { ORDER_META_KEY } from "./orders.js";
5
+ export { WOO_WEBHOOK_TOPICS } from "./webhooks.js";
2
6
  export interface WooConnectorOptions {
3
7
  fetchImpl?: typeof fetch;
8
+ /** Sent on every request: a store's firewall can then allow us by name. */
9
+ userAgent?: string;
10
+ /** Only for a test store on localhost; production stores are public https sites. */
11
+ allowPrivateHosts?: boolean;
12
+ /** How many units a product the store does not count (`manage_stock: false`) can sell while in stock. */
13
+ untrackedStockQuantity?: number;
14
+ /** How the store's order (and the email it sends the shopper) names the payment. */
15
+ paymentMethodTitle?: string;
16
+ /** The application name WooCommerce shows the merchant on its approval screen. */
17
+ appName?: string;
4
18
  }
5
19
  export declare function wooConnector(options?: WooConnectorOptions): ChannelConnector;