@asteby/storefront 0.10.0 → 0.11.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 +149 -4
- package/dist/cli.js +665 -0
- package/dist/fiscal-document.cjs +659 -0
- package/dist/fiscal-document.cjs.map +1 -0
- package/dist/fiscal-document.d.cts +215 -0
- package/dist/fiscal-document.d.ts +215 -0
- package/dist/fiscal-document.js +641 -0
- package/dist/fiscal-document.js.map +1 -0
- package/dist/index.cjs +93 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -5
- package/dist/index.d.ts +61 -5
- package/dist/index.js +91 -6
- package/dist/index.js.map +1 -1
- package/dist/mock.cjs +1072 -11
- package/dist/mock.cjs.map +1 -1
- package/dist/mock.d.cts +33 -2
- package/dist/mock.d.ts +33 -2
- package/dist/mock.js +1069 -12
- package/dist/mock.js.map +1 -1
- package/dist/mockup.d.cts +1 -1
- package/dist/mockup.d.ts +1 -1
- package/dist/node.cjs +243 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +100 -0
- package/dist/node.d.ts +100 -0
- package/dist/node.js +209 -0
- package/dist/node.js.map +1 -0
- package/dist/{types-DdLzlxw4.d.cts → types-C-Ley-Mt.d.cts} +293 -3
- package/dist/{types-COSwo4ND.d.ts → types-D7UJxrba.d.ts} +293 -3
- package/package.json +41 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
6
|
+
import { resolve as resolve2 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/errors.ts
|
|
9
|
+
var StorefrontError = class extends Error {
|
|
10
|
+
name = "StorefrontError";
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
field;
|
|
14
|
+
details;
|
|
15
|
+
url;
|
|
16
|
+
/** Scopes a `403 insufficient_scope` says the route needs (empty for any other error). */
|
|
17
|
+
requiredScopes;
|
|
18
|
+
constructor(init) {
|
|
19
|
+
super(init.message, init.cause === void 0 ? void 0 : { cause: init.cause });
|
|
20
|
+
this.status = init.status;
|
|
21
|
+
this.code = init.code;
|
|
22
|
+
this.field = init.field;
|
|
23
|
+
this.details = init.details;
|
|
24
|
+
this.url = init.url;
|
|
25
|
+
this.requiredScopes = init.requiredScopes ?? [];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Affected lines of a `409 insufficient_stock` (empty for any other error or a
|
|
29
|
+
* server older than contract 1.1). The cart is untouched: show the lines and
|
|
30
|
+
* let the buyer adjust or remove them.
|
|
31
|
+
*/
|
|
32
|
+
get stockLines() {
|
|
33
|
+
const lines = this.details?.lines;
|
|
34
|
+
if (this.code !== "insufficient_stock" || !Array.isArray(lines)) return [];
|
|
35
|
+
return lines.filter((l) => typeof l === "object" && l !== null && typeof l.sku === "string");
|
|
36
|
+
}
|
|
37
|
+
toJSON() {
|
|
38
|
+
return {
|
|
39
|
+
name: this.name,
|
|
40
|
+
status: this.status,
|
|
41
|
+
code: this.code,
|
|
42
|
+
message: this.message,
|
|
43
|
+
field: this.field,
|
|
44
|
+
...this.requiredScopes.length ? { requiredScopes: this.requiredScopes } : {}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var STATUS_CODES = {
|
|
49
|
+
400: "validation",
|
|
50
|
+
401: "unauthorized",
|
|
51
|
+
403: "forbidden",
|
|
52
|
+
404: "not_found",
|
|
53
|
+
409: "conflict",
|
|
54
|
+
422: "invalid_state",
|
|
55
|
+
429: "rate_limited"
|
|
56
|
+
};
|
|
57
|
+
function defaultCodeForStatus(status) {
|
|
58
|
+
return STATUS_CODES[status] ?? (status >= 500 ? "server_error" : "invalid_response");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/geo.ts
|
|
62
|
+
var RAD = Math.PI / 180;
|
|
63
|
+
function isValidPoint(p) {
|
|
64
|
+
const { lat, lng } = p ?? {};
|
|
65
|
+
return Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 && !(lat === 0 && lng === 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/money.ts
|
|
69
|
+
function isTokenPublishable(token) {
|
|
70
|
+
return typeof token === "string" && token.startsWith("pk_");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/query.ts
|
|
74
|
+
function appendParams(search, params) {
|
|
75
|
+
if (!params) return search;
|
|
76
|
+
for (const [key, raw] of Object.entries(params)) {
|
|
77
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
78
|
+
for (const v of values) {
|
|
79
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
80
|
+
search.append(key, String(v));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return search;
|
|
84
|
+
}
|
|
85
|
+
function productListQuery(params = {}) {
|
|
86
|
+
const { filters, handles, ...rest } = params;
|
|
87
|
+
const search = appendParams(new URLSearchParams(), rest);
|
|
88
|
+
if (handles && handles.length > 0) search.set("handles", handles.join(","));
|
|
89
|
+
if (filters) {
|
|
90
|
+
for (const [key, raw] of Object.entries(filters)) {
|
|
91
|
+
const name = key.startsWith("filter.") ? key : `filter.${key}`;
|
|
92
|
+
appendParams(search, { [name]: raw });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return search;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/client.ts
|
|
99
|
+
var OWN_OPTION_KEYS = /* @__PURE__ */ new Set(["headers", "timeoutMs", "customerToken", "clientIp", "idempotencyKey", "preview", "signal"]);
|
|
100
|
+
var CLIENT_IP_HEADER = "X-Storefront-Client-IP";
|
|
101
|
+
function normalizeClientIp(raw) {
|
|
102
|
+
if (!raw) return void 0;
|
|
103
|
+
let v = raw.trim();
|
|
104
|
+
if (v.startsWith("[") && v.endsWith("]")) v = v.slice(1, -1);
|
|
105
|
+
if (!v || v.length > 45 || /[\s,%/]/.test(v)) return void 0;
|
|
106
|
+
if (/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(v)) {
|
|
107
|
+
return v.split(".").every((o) => Number(o) <= 255) ? v : void 0;
|
|
108
|
+
}
|
|
109
|
+
if (!v.includes(":") || !/^[0-9a-fA-F:.]+$/.test(v) || v.split("::").length > 2) return void 0;
|
|
110
|
+
try {
|
|
111
|
+
new URL(`http://[${v}]/`);
|
|
112
|
+
} catch {
|
|
113
|
+
return void 0;
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
function generateIdempotencyKey() {
|
|
118
|
+
const c = globalThis.crypto;
|
|
119
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
120
|
+
return "idem-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
121
|
+
}
|
|
122
|
+
function joinUrl(baseUrl, path) {
|
|
123
|
+
return baseUrl.replace(/\/+$/, "") + path;
|
|
124
|
+
}
|
|
125
|
+
function enc(segment) {
|
|
126
|
+
return encodeURIComponent(segment);
|
|
127
|
+
}
|
|
128
|
+
function linkSignals(signals) {
|
|
129
|
+
const active = signals.filter((s) => !!s);
|
|
130
|
+
if (active.length === 0) return { signal: void 0, cleanup: () => {
|
|
131
|
+
} };
|
|
132
|
+
if (active.length === 1) return { signal: active[0], cleanup: () => {
|
|
133
|
+
} };
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const listeners = [];
|
|
136
|
+
for (const s of active) {
|
|
137
|
+
if (s.aborted) {
|
|
138
|
+
controller.abort(s.reason);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const on = () => controller.abort(s.reason);
|
|
142
|
+
s.addEventListener("abort", on, { once: true });
|
|
143
|
+
listeners.push([s, on]);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
cleanup: () => listeners.forEach(([s, on]) => s.removeEventListener("abort", on))
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function scopeList(raw) {
|
|
151
|
+
return Array.isArray(raw) ? raw.filter((s) => typeof s === "string" && s.length > 0) : [];
|
|
152
|
+
}
|
|
153
|
+
async function readError(res, url) {
|
|
154
|
+
let body;
|
|
155
|
+
try {
|
|
156
|
+
const text = await res.text();
|
|
157
|
+
body = text ? JSON.parse(text) : void 0;
|
|
158
|
+
} catch {
|
|
159
|
+
body = void 0;
|
|
160
|
+
}
|
|
161
|
+
const e = body?.error;
|
|
162
|
+
const top = body;
|
|
163
|
+
const code = e?.code ?? (typeof top?.code === "string" ? top.code : void 0);
|
|
164
|
+
const requiredScopes = code === "insufficient_scope" ? scopeList(e?.required_scopes ?? top?.required_scopes ?? e?.details?.required_scopes) : [];
|
|
165
|
+
return new StorefrontError({
|
|
166
|
+
status: res.status,
|
|
167
|
+
code: code ?? defaultCodeForStatus(res.status),
|
|
168
|
+
...requiredScopes.length ? { requiredScopes } : {},
|
|
169
|
+
message: e?.message ?? `Storefront API responded ${res.status}${res.statusText ? " " + res.statusText : ""}`,
|
|
170
|
+
...e?.field ? { field: e.field } : {},
|
|
171
|
+
...e?.details !== void 0 ? { details: e.details } : {},
|
|
172
|
+
url
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function createStorefrontClient(config) {
|
|
176
|
+
if (!config.baseUrl) throw new TypeError("createStorefrontClient: baseUrl is required");
|
|
177
|
+
if (!config.token) throw new TypeError("createStorefrontClient: token is required");
|
|
178
|
+
const currentToken = () => {
|
|
179
|
+
const t = typeof config.token === "function" ? config.token() : config.token;
|
|
180
|
+
if (!t) throw new StorefrontError({ status: 0, code: "unauthorized", message: "Storefront token is empty (token source returned nothing)" });
|
|
181
|
+
return t;
|
|
182
|
+
};
|
|
183
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
184
|
+
const initialToken = typeof config.token === "string" ? config.token : "";
|
|
185
|
+
if (isBrowser && initialToken.startsWith("sk_")) {
|
|
186
|
+
console.warn("[@asteby/storefront] A private sk_ token is being used in a browser. Use a public pk_ token instead.");
|
|
187
|
+
}
|
|
188
|
+
async function request(spec) {
|
|
189
|
+
const fetchImpl = config.fetch ?? (globalThis.fetch ? globalThis.fetch.bind(globalThis) : void 0);
|
|
190
|
+
if (!fetchImpl) throw new TypeError("createStorefrontClient: no fetch available; pass `fetch` in options");
|
|
191
|
+
const options = spec.options ?? {};
|
|
192
|
+
const search = spec.query instanceof URLSearchParams ? spec.query : appendParams(new URLSearchParams(), spec.query);
|
|
193
|
+
const qs = search.toString();
|
|
194
|
+
const url = joinUrl(config.baseUrl, spec.path) + (qs ? `?${qs}` : "");
|
|
195
|
+
const headers = new Headers(config.headers);
|
|
196
|
+
headers.set("Accept", "application/json");
|
|
197
|
+
const token = currentToken();
|
|
198
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
199
|
+
if (config.locale) headers.set("Accept-Language", config.locale);
|
|
200
|
+
if (config.domain) headers.set("X-Storefront-Domain", storefrontDomainHeader(config.domain, config.market));
|
|
201
|
+
const customerToken = options.customerToken !== void 0 ? options.customerToken : config.customerToken;
|
|
202
|
+
if (customerToken) headers.set("X-Customer-Token", customerToken);
|
|
203
|
+
const clientIp = options.clientIp !== void 0 ? options.clientIp : config.clientIp;
|
|
204
|
+
const ip = isTokenPublishable(token) ? void 0 : normalizeClientIp(clientIp);
|
|
205
|
+
if (ip) headers.set(CLIENT_IP_HEADER, ip);
|
|
206
|
+
if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
|
|
207
|
+
if (spec.body !== void 0) headers.set("Content-Type", "application/json");
|
|
208
|
+
for (const [k, v] of Object.entries(options.headers ?? {})) headers.set(k, v);
|
|
209
|
+
const passthrough = {};
|
|
210
|
+
for (const [k, v] of Object.entries(options)) if (!OWN_OPTION_KEYS.has(k) && v !== void 0) passthrough[k] = v;
|
|
211
|
+
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
|
|
212
|
+
const timeoutController = timeoutMs && timeoutMs > 0 ? new AbortController() : void 0;
|
|
213
|
+
const timer = timeoutController ? setTimeout(() => timeoutController.abort(new StorefrontError({ status: 0, code: "timeout", message: `Request timed out after ${timeoutMs}ms`, url })), timeoutMs) : void 0;
|
|
214
|
+
const linked = linkSignals([options.signal, timeoutController?.signal]);
|
|
215
|
+
const init = {
|
|
216
|
+
...passthrough,
|
|
217
|
+
method: spec.method,
|
|
218
|
+
headers,
|
|
219
|
+
...spec.body !== void 0 ? { body: JSON.stringify(spec.body) } : {},
|
|
220
|
+
...linked.signal ? { signal: linked.signal } : {}
|
|
221
|
+
};
|
|
222
|
+
const ctx = { method: spec.method, url, path: spec.path, init, options };
|
|
223
|
+
const started = Date.now();
|
|
224
|
+
let res;
|
|
225
|
+
try {
|
|
226
|
+
const hooked = config.onRequest ? await config.onRequest(ctx) : void 0;
|
|
227
|
+
res = hooked instanceof Response ? hooked : await fetchImpl(url, init);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (timeoutController?.signal.aborted) {
|
|
230
|
+
throw new StorefrontError({ status: 0, code: "timeout", message: `Request timed out after ${timeoutMs}ms`, url, cause: err });
|
|
231
|
+
}
|
|
232
|
+
if (options.signal?.aborted) {
|
|
233
|
+
throw new StorefrontError({ status: 0, code: "aborted", message: "Request aborted", url, cause: err });
|
|
234
|
+
}
|
|
235
|
+
if (err instanceof StorefrontError) throw err;
|
|
236
|
+
throw new StorefrontError({ status: 0, code: "network", message: err instanceof Error ? err.message : "Network error", url, cause: err });
|
|
237
|
+
} finally {
|
|
238
|
+
if (timer) clearTimeout(timer);
|
|
239
|
+
linked.cleanup();
|
|
240
|
+
}
|
|
241
|
+
if (config.onResponse) await config.onResponse({ ...ctx, response: res, durationMs: Date.now() - started });
|
|
242
|
+
if (res.status === 404 && spec.nullOn404) {
|
|
243
|
+
await res.text().catch(() => void 0);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (res.status === 304) {
|
|
247
|
+
throw new StorefrontError({ status: 304, code: "not_modified", message: "Not modified", url });
|
|
248
|
+
}
|
|
249
|
+
if (!res.ok) throw await readError(res, url);
|
|
250
|
+
if (res.status === 204) return void 0;
|
|
251
|
+
const text = await res.text();
|
|
252
|
+
if (!text) return void 0;
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(text);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
throw new StorefrontError({ status: res.status, code: "invalid_response", message: "Response is not valid JSON", url, cause: err });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const get = (path, query, options) => request({ method: "GET", path, ...query ? { query } : {}, ...options ? { options } : {} });
|
|
260
|
+
const getOrNull = (path, query, options) => request({ method: "GET", path, nullOn404: true, ...query ? { query } : {}, ...options ? { options } : {} });
|
|
261
|
+
const write = (method, path, body, options) => request({ method, path, ...body !== void 0 ? { body } : {}, ...options ? { options } : {} });
|
|
262
|
+
const cartPath = (id, rest = "") => `/storefront/carts/${enc(id)}${rest}`;
|
|
263
|
+
const previewQuery = (options) => options?.preview ? { preview: options.preview } : void 0;
|
|
264
|
+
const client = {
|
|
265
|
+
/** Resolved configuration (read-only). */
|
|
266
|
+
config: Object.freeze({ ...config }),
|
|
267
|
+
/** Low-level escape hatch: any route under `baseUrl`. */
|
|
268
|
+
request,
|
|
269
|
+
token: {
|
|
270
|
+
/**
|
|
271
|
+
* What the token in use is and can do (`GET {baseUrl}/_token`, a gateway route, not a `/storefront` one).
|
|
272
|
+
* Needs no scope, so it answers even when the token is under-provisioned: `missing_scopes` says what to repair.
|
|
273
|
+
*/
|
|
274
|
+
introspect: (options) => get("/_token", void 0, options)
|
|
275
|
+
},
|
|
276
|
+
/** Returns a new client that sends the given buyer session token. */
|
|
277
|
+
withCustomerToken(customerToken) {
|
|
278
|
+
return createStorefrontClient({ ...config, customerToken });
|
|
279
|
+
},
|
|
280
|
+
/** Returns a new client that reports the shopper's IP (`X-Storefront-Client-IP`) on every call. */
|
|
281
|
+
withClientIp(clientIp) {
|
|
282
|
+
return createStorefrontClient({ ...config, clientIp: clientIp ?? null });
|
|
283
|
+
},
|
|
284
|
+
shop: {
|
|
285
|
+
get: (options) => get("/storefront/shop", void 0, options)
|
|
286
|
+
},
|
|
287
|
+
/**
|
|
288
|
+
* How the market formats, taxes, addresses and invoices (`fiscal_id` carries the tax-id pattern, the regimes,
|
|
289
|
+
* the invoice uses and the identity documents the buyer can upload). Cacheable; `null` when the organization
|
|
290
|
+
* has no region configured.
|
|
291
|
+
*/
|
|
292
|
+
region: (options) => getOrNull("/storefront/region", void 0, options),
|
|
293
|
+
products: {
|
|
294
|
+
list: (params, options) => get("/storefront/products", productListQuery(params), options),
|
|
295
|
+
/** `null` when the product does not exist or is unpublished. */
|
|
296
|
+
get: (handle, options) => getOrNull(`/storefront/products/${enc(handle)}`, void 0, options),
|
|
297
|
+
recommendations: (handle, options) => get(`/storefront/products/${enc(handle)}/recommendations`, void 0, options)
|
|
298
|
+
},
|
|
299
|
+
collections: {
|
|
300
|
+
list: (params, options) => get("/storefront/collections", params, options),
|
|
301
|
+
/** `null` when the collection does not exist. */
|
|
302
|
+
get: (handle, options) => getOrNull(`/storefront/collections/${enc(handle)}`, void 0, options)
|
|
303
|
+
},
|
|
304
|
+
locations: {
|
|
305
|
+
/** Branches of the shop. With `postal_code` they come ordered by distance (`distance_km`). */
|
|
306
|
+
list: (params, options) => get("/storefront/locations", params, options),
|
|
307
|
+
/** `null` when the branch does not exist or is not visible. */
|
|
308
|
+
get: (id, options) => getOrNull(`/storefront/locations/${enc(id)}`, void 0, options),
|
|
309
|
+
/**
|
|
310
|
+
* Branches ordered by distance to a point, closest first. The server rounds the coordinates (0.01°, ~1 km)
|
|
311
|
+
* and never stores them; this client does not log or keep them either. The answer is `private, no-store`
|
|
312
|
+
* (it depends on the buyer's position), so do not put it in a shared cache. Note `onRequest` hooks and
|
|
313
|
+
* `fetch` wrappers do see the URL: do not log it. Throws `TypeError` for an invalid point.
|
|
314
|
+
*/
|
|
315
|
+
nearest: (point, options) => {
|
|
316
|
+
if (!isValidPoint(point)) throw new TypeError("locations.nearest: lat/lng must be a valid point");
|
|
317
|
+
return get("/storefront/locations/nearest", { lat: point.lat, lng: point.lng }, { cache: "no-store", ...options });
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
stock: {
|
|
321
|
+
/** Stock for up to 250 SKUs. */
|
|
322
|
+
get: (skus, options) => get("/storefront/stock", { sku: skus }, options)
|
|
323
|
+
},
|
|
324
|
+
restock: {
|
|
325
|
+
/**
|
|
326
|
+
* "Notify me when it is back". Idempotent per contact + SKU; always answers `202 pending`.
|
|
327
|
+
* Only offered when `availability.notify_available` is true. Needs `customer:write`.
|
|
328
|
+
*/
|
|
329
|
+
subscribe: (input, options) => write("POST", "/storefront/restock-subscriptions", input, options)
|
|
330
|
+
},
|
|
331
|
+
carts: {
|
|
332
|
+
create: (input = {}, options) => write("POST", "/storefront/carts", input, options),
|
|
333
|
+
/** `null` when the cart does not exist (e.g. expired cookie). */
|
|
334
|
+
get: (id, options) => getOrNull(cartPath(id), void 0, options),
|
|
335
|
+
addLines: (id, lines, options) => write("POST", cartPath(id, "/lines"), { lines }, options),
|
|
336
|
+
/** Quantity `0` removes the line. */
|
|
337
|
+
updateLines: (id, lines, options) => write("PATCH", cartPath(id, "/lines"), { lines }, options),
|
|
338
|
+
removeLines: (id, lineIds, options) => write("PATCH", cartPath(id, "/lines"), { lines: lineIds.map((lid) => ({ id: lid, quantity: 0 })) }, options),
|
|
339
|
+
setDiscountCodes: (id, codes, options) => write("PUT", cartPath(id, "/discount-codes"), { codes }, options),
|
|
340
|
+
setBuyer: (id, buyer, options) => write("PUT", cartPath(id, "/buyer"), buyer, options),
|
|
341
|
+
setShippingAddress: (id, address, options) => write("PUT", cartPath(id, "/shipping-address"), address, options),
|
|
342
|
+
deliveryOptions: (id, options) => get(cartPath(id, "/delivery-options"), void 0, options),
|
|
343
|
+
/** Payment providers active for the cart's market; send the chosen `key` as `payment_method`. */
|
|
344
|
+
paymentMethods: (id, options) => get(cartPath(id, "/payment-methods"), void 0, options),
|
|
345
|
+
setDelivery: (id, delivery, options) => write("PUT", cartPath(id, "/delivery"), delivery, options),
|
|
346
|
+
/** Order note (up to 1000 characters). `null` or empty clears it. */
|
|
347
|
+
setNote: (id, note, options) => write("PUT", cartPath(id, "/note"), { note }, options),
|
|
348
|
+
/**
|
|
349
|
+
* Asks for an invoice for this cart (`null` or `{ requested: false }` withdraws it). The server validates the
|
|
350
|
+
* data against the region BEFORE any charge: `422` with `field: 'invoice.<field>'` (e.g. `invoice.tax_regime`)
|
|
351
|
+
* names what to fix. `409 already_checked_out` when the cart is already an order. Checkout uses it when
|
|
352
|
+
* `CheckoutInput.invoice` is omitted.
|
|
353
|
+
*/
|
|
354
|
+
setInvoice: (id, invoice, options) => write("PUT", cartPath(id, "/invoice"), invoice ?? { requested: false }, options),
|
|
355
|
+
/**
|
|
356
|
+
* Cross-sell suggestions: only sellable variants, with `unlocks` when adding one reaches the next
|
|
357
|
+
* discount tier. `first` 1-8 (default 4). `private, no-store`; ask on demand, not on every render.
|
|
358
|
+
*/
|
|
359
|
+
recommendations: (id, params = {}, options) => get(cartPath(id, "/recommendations"), params, options),
|
|
360
|
+
/** Reserves stock and creates the order. Sends `Idempotency-Key` (auto-generated if omitted). */
|
|
361
|
+
checkout: (id, input, options = {}) => write("POST", cartPath(id, "/checkout"), input, {
|
|
362
|
+
...options,
|
|
363
|
+
idempotencyKey: options.idempotencyKey ?? generateIdempotencyKey()
|
|
364
|
+
})
|
|
365
|
+
},
|
|
366
|
+
promotions: {
|
|
367
|
+
/** Public promotions and the entry pop-up announcement for the request's market (cacheable). */
|
|
368
|
+
list: (options) => get("/storefront/promotions", void 0, options)
|
|
369
|
+
},
|
|
370
|
+
checkouts: {
|
|
371
|
+
/** What to mount for an embedded checkout (public key, options, amount) and whether it can still be paid. Never cache. */
|
|
372
|
+
paymentConfig: (id, options) => getOrNull(`/storefront/checkouts/${enc(id)}/payment-config`, void 0, options),
|
|
373
|
+
/**
|
|
374
|
+
* Pays a checkout in the page. The browser brings only what the provider SDK returned (`provider_data`);
|
|
375
|
+
* the amount is the server's. `idempotencyKey` is REQUIRED (use `paymentIdempotencyKey(checkoutId, attempt)`):
|
|
376
|
+
* repeating a call with the same key never charges twice. Needs a server token (`checkout:write`).
|
|
377
|
+
*/
|
|
378
|
+
pay: (id, input, options) => {
|
|
379
|
+
if (!options || typeof options.idempotencyKey !== "string" || !options.idempotencyKey.trim()) {
|
|
380
|
+
throw new TypeError("checkouts.pay: idempotencyKey is required");
|
|
381
|
+
}
|
|
382
|
+
return write("POST", `/storefront/checkouts/${enc(id)}/payment`, input, options);
|
|
383
|
+
},
|
|
384
|
+
/**
|
|
385
|
+
* Reads a placed checkout (same shape as the `carts.checkout` result). `order.number` is `null` until OPS
|
|
386
|
+
* has created the order (a few hundred ms after checkout); poll once or twice and, if it is still `null`,
|
|
387
|
+
* keep telling the buyer the number will arrive by email. Needs a server token (`checkout:write`).
|
|
388
|
+
* `null` when the checkout does not exist. Never cache.
|
|
389
|
+
*/
|
|
390
|
+
get: (id, options) => getOrNull(`/storefront/checkouts/${enc(id)}`, void 0, options)
|
|
391
|
+
},
|
|
392
|
+
customer: {
|
|
393
|
+
/** Sends a one-time code by email or SMS/WhatsApp. */
|
|
394
|
+
requestOtp: (identifier, options) => write("POST", "/storefront/customer/otp", identifier, options),
|
|
395
|
+
/** Exchanges the code for a buyer session (`token` goes in `customerToken`). */
|
|
396
|
+
createSession: (input, options) => write("POST", "/storefront/customer/session", input, options),
|
|
397
|
+
/** What the store supports (password login, email verification, OTP channels). Drives which forms to show. */
|
|
398
|
+
capabilities: (options) => get("/storefront/customer/capabilities", void 0, options),
|
|
399
|
+
/**
|
|
400
|
+
* Creates an account with email + password. `verification_required` (202): a code was emailed, finish with
|
|
401
|
+
* `verifyRegistration`. `created` (201): the account exists and `session` is ready. `409` when the email is taken.
|
|
402
|
+
*/
|
|
403
|
+
register: async (input, options) => {
|
|
404
|
+
const res = await write("POST", "/storefront/customer/register", input, options);
|
|
405
|
+
return res && "token" in res ? { status: "created", session: res } : { status: "verification_required" };
|
|
406
|
+
},
|
|
407
|
+
/** Confirms the emailed registration code and returns the new buyer session. */
|
|
408
|
+
verifyRegistration: (input, options) => write("POST", "/storefront/customer/register/verify", input, options),
|
|
409
|
+
/** Email + password login. `401` for wrong credentials. */
|
|
410
|
+
loginWithPassword: (input, options) => write("POST", "/storefront/customer/login", input, options),
|
|
411
|
+
/** Sets or changes the password of the signed-in buyer (`options.customerToken`, or a `withCustomerToken` client). */
|
|
412
|
+
setPassword: (input, options) => write("PUT", "/storefront/customer/password", input, options),
|
|
413
|
+
/** Resets a forgotten password with the code from `requestOtp({ email })`. */
|
|
414
|
+
resetPassword: (input, options) => write("POST", "/storefront/customer/password/reset", input, options),
|
|
415
|
+
get: (options) => get("/storefront/customer", void 0, options),
|
|
416
|
+
/** Updates the profile and the saved fiscal data; `fiscal: null` deletes the latter. `422` with `field: 'fiscal.<field>'`. */
|
|
417
|
+
update: (input, options) => write("PATCH", "/storefront/customer", input, options),
|
|
418
|
+
orders: (params, options) => get("/storefront/customer/orders", params, options),
|
|
419
|
+
/** `null` when the order does not exist or belongs to someone else. */
|
|
420
|
+
order: (id, options) => getOrNull(`/storefront/customer/orders/${enc(id)}`, void 0, options),
|
|
421
|
+
requestReturn: (orderId, input, options) => write("POST", `/storefront/customer/orders/${enc(orderId)}/returns`, input, options),
|
|
422
|
+
requestInvoice: (orderId, input, options) => write("POST", `/storefront/customer/orders/${enc(orderId)}/invoice`, input, options),
|
|
423
|
+
addresses: {
|
|
424
|
+
list: (options) => get("/storefront/customer/addresses", void 0, options),
|
|
425
|
+
create: (address, options) => write("POST", "/storefront/customer/addresses", address, options),
|
|
426
|
+
update: (id, address, options) => write("PATCH", `/storefront/customer/addresses/${enc(id)}`, address, options),
|
|
427
|
+
delete: (id, options) => write("DELETE", `/storefront/customer/addresses/${enc(id)}`, void 0, options)
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
content: {
|
|
431
|
+
theme: (options) => get("/storefront/content/theme", void 0, options),
|
|
432
|
+
/** `null` when the page does not exist. */
|
|
433
|
+
page: (handle, options) => getOrNull(`/storefront/content/pages/${enc(handle)}`, previewQuery(options), options),
|
|
434
|
+
/** `null` when the menu does not exist. */
|
|
435
|
+
menu: (handle, options) => getOrNull(`/storefront/content/menus/${enc(handle)}`, void 0, options),
|
|
436
|
+
articles: (params, options) => get("/storefront/content/articles", params, options),
|
|
437
|
+
/** `null` when the article does not exist. */
|
|
438
|
+
article: (handle, options) => getOrNull(`/storefront/content/articles/${enc(handle)}`, previewQuery(options), options)
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
return client;
|
|
442
|
+
}
|
|
443
|
+
function storefrontDomainHeader(domain, market) {
|
|
444
|
+
const host = domain.trim();
|
|
445
|
+
const code = (market ?? "").trim().toLowerCase();
|
|
446
|
+
return code && /^[a-z0-9_-]{1,32}$/.test(code) ? `${host}#${code}` : host;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/diagnose.ts
|
|
450
|
+
function maskToken(token) {
|
|
451
|
+
if (!token) return "(sin token)";
|
|
452
|
+
const prefix = /^[a-z]{2}_/.exec(token)?.[0] ?? "";
|
|
453
|
+
return token.length <= prefix.length + 8 ? `${prefix}\u2026` : `${prefix}\u2026${token.slice(-4)}`;
|
|
454
|
+
}
|
|
455
|
+
function repairPath(connectionName) {
|
|
456
|
+
return `OPS \u2192 Conexiones y API \u2192 ${connectionName ?? "<conexi\xF3n>"} \u2192 Reparar permisos`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/node.ts
|
|
460
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "fs";
|
|
461
|
+
import { dirname, join, resolve } from "path";
|
|
462
|
+
import { hostname } from "os";
|
|
463
|
+
var SDK_VERSION = true ? "0.11.0" : "dev";
|
|
464
|
+
function writeFileAtomic(file, content, mode = 384) {
|
|
465
|
+
const dir = dirname(file);
|
|
466
|
+
mkdirSync(dir, { recursive: true });
|
|
467
|
+
const tmp = join(dir, `.${file.split("/").pop()}.${process.pid}.${Date.now().toString(36)}.tmp`);
|
|
468
|
+
const fd = openSync(tmp, "wx", mode);
|
|
469
|
+
try {
|
|
470
|
+
writeSync(fd, content);
|
|
471
|
+
fsyncSync(fd);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
closeSync(fd);
|
|
474
|
+
try {
|
|
475
|
+
unlinkSync(tmp);
|
|
476
|
+
} catch {
|
|
477
|
+
}
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
closeSync(fd);
|
|
481
|
+
try {
|
|
482
|
+
chmodSync(tmp, mode);
|
|
483
|
+
renameSync(tmp, file);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
try {
|
|
486
|
+
unlinkSync(tmp);
|
|
487
|
+
} catch {
|
|
488
|
+
}
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
var ConnectError = class extends Error {
|
|
493
|
+
constructor(kind, message, status = 0) {
|
|
494
|
+
super(message);
|
|
495
|
+
this.kind = kind;
|
|
496
|
+
this.status = status;
|
|
497
|
+
}
|
|
498
|
+
kind;
|
|
499
|
+
status;
|
|
500
|
+
name = "ConnectError";
|
|
501
|
+
};
|
|
502
|
+
var trimSlash = (u) => u.replace(/\/+$/, "");
|
|
503
|
+
async function post(fetchImpl, url, body, bearer) {
|
|
504
|
+
let res;
|
|
505
|
+
try {
|
|
506
|
+
res = await fetchImpl(url, {
|
|
507
|
+
method: "POST",
|
|
508
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", ...bearer ? { Authorization: `Bearer ${bearer}` } : {} },
|
|
509
|
+
body: JSON.stringify(body),
|
|
510
|
+
signal: AbortSignal.timeout(15e3)
|
|
511
|
+
});
|
|
512
|
+
} catch {
|
|
513
|
+
throw new ConnectError("network", "No pudimos comunicarnos con OPS. Revisa la direcci\xF3n y tu conexi\xF3n a internet.");
|
|
514
|
+
}
|
|
515
|
+
const text = await res.text().catch(() => "");
|
|
516
|
+
if (!res.ok) {
|
|
517
|
+
if (res.status === 429) throw new ConnectError("rate_limited", "Demasiados intentos. Espera unos minutos e int\xE9ntalo de nuevo.", 429);
|
|
518
|
+
if (res.status === 401 || res.status === 403) throw new ConnectError("unauthorized", "OPS rechaz\xF3 la credencial de esta tienda.", res.status);
|
|
519
|
+
if (res.status >= 500) throw new ConnectError("server", "OPS no pudo atender la solicitud. Int\xE9ntalo de nuevo en un momento.", res.status);
|
|
520
|
+
throw new ConnectError("invalid_code", "El c\xF3digo no es v\xE1lido, ya se us\xF3 o venci\xF3. Genera uno nuevo en OPS \u2192 Conexiones y API.", res.status);
|
|
521
|
+
}
|
|
522
|
+
if (!text) return void 0;
|
|
523
|
+
try {
|
|
524
|
+
return JSON.parse(text);
|
|
525
|
+
} catch {
|
|
526
|
+
throw new ConnectError("invalid_response", "OPS respondi\xF3 algo que no entendimos.", res.status);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function assertRedeem(r) {
|
|
530
|
+
const o = r;
|
|
531
|
+
if (!o || typeof o.secret_token !== "string" || !o.secret_token || typeof o.connection_id !== "string" || !Array.isArray(o.scopes)) {
|
|
532
|
+
throw new ConnectError("invalid_response", "OPS respondi\xF3 algo que no entendimos.");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function redeemConnectionCode(opts) {
|
|
536
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
537
|
+
const client_info = { sdk_version: SDK_VERSION, hostname: hostname(), ...opts.siteUrl ? { site_url: opts.siteUrl } : {} };
|
|
538
|
+
const res = await post(fetchImpl, `${trimSlash(opts.opsUrl)}/api/connect/v1/redeem`, { code: opts.code.trim(), client_info });
|
|
539
|
+
assertRedeem(res);
|
|
540
|
+
return res;
|
|
541
|
+
}
|
|
542
|
+
var stateFileFor = (tokenFile) => `${tokenFile}.json`;
|
|
543
|
+
function saveConnection(tokenFile, r, opsUrl = r.ops_url) {
|
|
544
|
+
const state = { ops_url: trimSlash(r.ops_url || opsUrl), connection_id: r.connection_id, environment: r.environment, scopes: r.scopes, rotate_after: r.rotate_after };
|
|
545
|
+
writeFileAtomic(stateFileFor(tokenFile), JSON.stringify(state, null, 2) + "\n");
|
|
546
|
+
writeFileAtomic(tokenFile, r.secret_token + "\n");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/cli.ts
|
|
550
|
+
var HELP = `Uso: npx @asteby/storefront connect --ops <url> [--env-file <ruta>] [--token-file <ruta>] [--site <url>]
|
|
551
|
+
|
|
552
|
+
Conecta esta tienda con OPS. El c\xF3digo de conexi\xF3n (K7QM-2XPA) se pide en la terminal; nunca va como argumento.
|
|
553
|
+
|
|
554
|
+
--ops Direcci\xF3n de OPS (https://ops.ejemplo.com). Tambi\xE9n OPS_URL.
|
|
555
|
+
--token-file D\xF3nde guardar la clave (por omisi\xF3n ./storefront_token). Modo 0600, escritura at\xF3mica.
|
|
556
|
+
--env-file Archivo .env donde dejar SOLO OPS_URL y STOREFRONT_TOKEN_FILE (nunca la clave).
|
|
557
|
+
--site Direcci\xF3n p\xFAblica de la tienda (opcional, informativa).`;
|
|
558
|
+
function parseArgs(argv) {
|
|
559
|
+
const flags = {};
|
|
560
|
+
const [cmd, ...rest] = argv;
|
|
561
|
+
for (let i = 0; i < rest.length; i++) {
|
|
562
|
+
const a = rest[i];
|
|
563
|
+
const eq = a.indexOf("=");
|
|
564
|
+
const key = (eq > 0 ? a.slice(0, eq) : a).replace(/^--/, "");
|
|
565
|
+
if (!a.startsWith("--") || !["ops", "env-file", "token-file", "site", "code"].includes(key)) return { cmd, flags, bad: a };
|
|
566
|
+
if (key === "code") return { cmd, flags, bad: "--code (el c\xF3digo se pide en la terminal, no como argumento)" };
|
|
567
|
+
const val = eq > 0 ? a.slice(eq + 1) : rest[++i];
|
|
568
|
+
if (!val) return { cmd, flags, bad: a };
|
|
569
|
+
flags[key] = val;
|
|
570
|
+
}
|
|
571
|
+
return { cmd, flags };
|
|
572
|
+
}
|
|
573
|
+
function upsertEnv(text, values) {
|
|
574
|
+
const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n");
|
|
575
|
+
for (const [k, v] of Object.entries(values)) {
|
|
576
|
+
const i = lines.findIndex((l) => new RegExp(`^\\s*(export\\s+)?${k}\\s*=`).test(l));
|
|
577
|
+
if (i >= 0) lines[i] = `${k}=${v}`;
|
|
578
|
+
else lines.push(`${k}=${v}`);
|
|
579
|
+
}
|
|
580
|
+
return lines.join("\n") + "\n";
|
|
581
|
+
}
|
|
582
|
+
async function runCli(argv, io) {
|
|
583
|
+
const { cmd, flags, bad } = parseArgs(argv);
|
|
584
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
585
|
+
io.stdout(HELP);
|
|
586
|
+
return cmd ? 0 : 1;
|
|
587
|
+
}
|
|
588
|
+
if (cmd !== "connect") {
|
|
589
|
+
io.stderr(`Comando desconocido: ${cmd}
|
|
590
|
+
${HELP}`);
|
|
591
|
+
return 1;
|
|
592
|
+
}
|
|
593
|
+
if (bad) {
|
|
594
|
+
io.stderr(`Argumento no v\xE1lido: ${bad}
|
|
595
|
+
${HELP}`);
|
|
596
|
+
return 1;
|
|
597
|
+
}
|
|
598
|
+
const env = io.env ?? process.env;
|
|
599
|
+
const cwd = io.cwd ?? process.cwd();
|
|
600
|
+
const opsUrl = (flags.ops ?? env.OPS_URL ?? "").trim().replace(/\/+$/, "");
|
|
601
|
+
if (!/^https?:\/\//.test(opsUrl)) {
|
|
602
|
+
io.stderr("Falta --ops con la direcci\xF3n de OPS (https://\u2026).");
|
|
603
|
+
return 1;
|
|
604
|
+
}
|
|
605
|
+
const tokenFile = resolve2(cwd, flags["token-file"] ?? "storefront_token");
|
|
606
|
+
const envFile = flags["env-file"] ? resolve2(cwd, flags["env-file"]) : null;
|
|
607
|
+
let code;
|
|
608
|
+
try {
|
|
609
|
+
code = (await io.promptCode()).trim();
|
|
610
|
+
} catch (err) {
|
|
611
|
+
io.stderr(err instanceof Error ? err.message : "No se pudo leer el c\xF3digo.");
|
|
612
|
+
return 1;
|
|
613
|
+
}
|
|
614
|
+
if (!code) {
|
|
615
|
+
io.stderr("No escribiste ning\xFAn c\xF3digo.");
|
|
616
|
+
return 1;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const r = await redeemConnectionCode({ opsUrl, code, ...flags.site ? { siteUrl: flags.site } : {}, ...io.fetch ? { fetch: io.fetch } : {} });
|
|
620
|
+
saveConnection(tokenFile, r, opsUrl);
|
|
621
|
+
if (envFile) {
|
|
622
|
+
const current = existsSync2(envFile) ? readFileSync2(envFile, "utf8") : "";
|
|
623
|
+
if (/^\s*(export\s+)?STOREFRONT_TOKEN\s*=/m.test(current)) {
|
|
624
|
+
io.stderr("Aviso: tu .env a\xFAn tiene STOREFRONT_TOKEN con una clave anterior; b\xF3rrala, el archivo de clave tiene prioridad.");
|
|
625
|
+
}
|
|
626
|
+
writeFileAtomic(envFile, upsertEnv(current, { OPS_URL: r.ops_url || opsUrl, STOREFRONT_TOKEN_FILE: tokenFile }), 384);
|
|
627
|
+
}
|
|
628
|
+
io.stdout(`Conectada \u2713 \xB7 ${r.scopes.length} alcances \xB7 ${maskToken(r.secret_token)}`);
|
|
629
|
+
io.stdout(`Clave guardada en ${tokenFile} (modo 0600).${envFile ? ` Variables en ${envFile}.` : ` Usa STOREFRONT_TOKEN_FILE=${tokenFile}.`}`);
|
|
630
|
+
const base = `${(r.ops_url || opsUrl).replace(/\/+$/, "")}/api/gw`;
|
|
631
|
+
const sf = createStorefrontClient({ baseUrl: base, token: r.secret_token, ...io.fetch ? { fetch: io.fetch } : {}, timeoutMs: 1e4 });
|
|
632
|
+
try {
|
|
633
|
+
const info = await sf.token.introspect();
|
|
634
|
+
if (info.missing_scopes?.length) {
|
|
635
|
+
io.stderr(`Aviso: a la clave le faltan permisos (${info.missing_scopes.join(", ")}). Rep\xE1ralo en ${repairPath()}.`);
|
|
636
|
+
} else {
|
|
637
|
+
io.stdout(`Verificada con OPS \xB7 entorno ${info.environment}.`);
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
io.stderr("Aviso: la clave se guard\xF3 pero no pudimos verificarla con OPS todav\xEDa.");
|
|
641
|
+
}
|
|
642
|
+
return 0;
|
|
643
|
+
} catch (err) {
|
|
644
|
+
io.stderr(err instanceof ConnectError ? err.message : "No se pudo completar la conexi\xF3n.");
|
|
645
|
+
return 1;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function ttyPrompt() {
|
|
649
|
+
if (!process.stdin.isTTY) return Promise.reject(new Error("El c\xF3digo se escribe en la terminal (TTY); no se acepta por argumento ni por tuber\xEDa."));
|
|
650
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
651
|
+
return new Promise((res) => rl.question("C\xF3digo de conexi\xF3n: ", (a) => (rl.close(), res(a))));
|
|
652
|
+
}
|
|
653
|
+
var entry = process.argv[1] ?? "";
|
|
654
|
+
if (/(^|[\\/])(storefront|cli)(\.js)?$/.test(entry) && !process.env.VITEST) {
|
|
655
|
+
void runCli(process.argv.slice(2), {
|
|
656
|
+
stdout: (l) => console.log(l),
|
|
657
|
+
stderr: (l) => console.error(l),
|
|
658
|
+
promptCode: ttyPrompt
|
|
659
|
+
}).then((c) => process.exit(c));
|
|
660
|
+
}
|
|
661
|
+
export {
|
|
662
|
+
SDK_VERSION,
|
|
663
|
+
runCli,
|
|
664
|
+
upsertEnv
|
|
665
|
+
};
|