react-shopwave-connect 0.1.2 → 0.3.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 +281 -39
- package/dist/core/index.cjs +435 -93
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +241 -28
- package/dist/core/index.d.ts +241 -28
- package/dist/core/index.js +405 -94
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +328 -116
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +57 -12
- package/dist/hooks/index.d.ts +57 -12
- package/dist/hooks/index.js +329 -118
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +553 -112
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +265 -29
- package/dist/index.d.ts +265 -29
- package/dist/index.js +523 -114
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +1019 -0
- package/dist/next/index.cjs.map +1 -0
- package/dist/next/index.d.cts +423 -0
- package/dist/next/index.d.ts +423 -0
- package/dist/next/index.js +987 -0
- package/dist/next/index.js.map +1 -0
- package/dist/server/index.cjs +670 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +346 -0
- package/dist/server/index.d.ts +346 -0
- package/dist/server/index.js +650 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +41 -8
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
let next_headers = require("next/headers");
|
|
25
|
+
next_headers = __toESM(next_headers);
|
|
26
|
+
let next_server = require("next/server");
|
|
27
|
+
next_server = __toESM(next_server);
|
|
28
|
+
let iron_session = require("iron-session");
|
|
29
|
+
iron_session = __toESM(iron_session);
|
|
30
|
+
|
|
31
|
+
//#region src/server/errors.ts
|
|
32
|
+
var ShopwaveAuthError = class extends Error {
|
|
33
|
+
code;
|
|
34
|
+
status;
|
|
35
|
+
/** Raw response body from the auth server, if any. Never contains our secret. */
|
|
36
|
+
body;
|
|
37
|
+
constructor(code, message, details = {}) {
|
|
38
|
+
super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
|
|
39
|
+
this.name = "ShopwaveAuthError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.status = details.status;
|
|
42
|
+
this.body = details.body;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* True when the auth server rejected the grant itself (bad/expired code or
|
|
46
|
+
* refresh token) rather than failing for a transient reason. Callers should
|
|
47
|
+
* treat the user as logged out.
|
|
48
|
+
*/
|
|
49
|
+
get isInvalidGrant() {
|
|
50
|
+
return (this.code === "token_exchange_failed" || this.code === "token_refresh_failed") && this.status !== void 0 && this.status >= 400 && this.status < 500;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/server/token.ts
|
|
56
|
+
/** Shopwave API error id meaning "access token expired / invalid". */
|
|
57
|
+
const SHOPWAVE_TOKEN_EXPIRED_ERROR_ID = 908;
|
|
58
|
+
/**
|
|
59
|
+
* Converts a token-endpoint response into a {@link ShopwaveToken}.
|
|
60
|
+
* Returns `null` when the response has no access token.
|
|
61
|
+
*
|
|
62
|
+
* @param previous the token being refreshed; its refresh token is kept when
|
|
63
|
+
* the server doesn't send a new one.
|
|
64
|
+
*/
|
|
65
|
+
function tokenFromResponse(response, previous, now = Date.now()) {
|
|
66
|
+
if (!response || typeof response.access_token !== "string" || !response.access_token) return null;
|
|
67
|
+
const expiresIn = Number(response.expires_in);
|
|
68
|
+
return {
|
|
69
|
+
accessToken: response.access_token,
|
|
70
|
+
refreshToken: typeof response.refresh_token === "string" && response.refresh_token || previous?.refreshToken,
|
|
71
|
+
tokenType: typeof response.token_type === "string" && response.token_type || "OAuth",
|
|
72
|
+
expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? now + expiresIn * 1e3 : void 0
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reads a token from session storage. Accepts both the current shape and the
|
|
77
|
+
* legacy raw response that older apps (e.g. AdminUI ≤ 0.1) stored directly in
|
|
78
|
+
* the session, so existing sessions survive an upgrade.
|
|
79
|
+
*/
|
|
80
|
+
function normalizeStoredToken(raw) {
|
|
81
|
+
if (!raw || typeof raw !== "object") return null;
|
|
82
|
+
const t = raw;
|
|
83
|
+
if (typeof t.accessToken === "string" && t.accessToken) return {
|
|
84
|
+
accessToken: t.accessToken,
|
|
85
|
+
refreshToken: typeof t.refreshToken === "string" ? t.refreshToken : void 0,
|
|
86
|
+
tokenType: typeof t.tokenType === "string" && t.tokenType ? t.tokenType : "OAuth",
|
|
87
|
+
expiresAt: typeof t.expiresAt === "number" ? t.expiresAt : void 0
|
|
88
|
+
};
|
|
89
|
+
if (typeof t.access_token === "string" && t.access_token) return {
|
|
90
|
+
accessToken: t.access_token,
|
|
91
|
+
refreshToken: typeof t.refresh_token === "string" ? t.refresh_token : void 0,
|
|
92
|
+
tokenType: typeof t.token_type === "string" && t.token_type ? t.token_type : "OAuth"
|
|
93
|
+
};
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* True when the token has passed its expiry (minus `skewSeconds`).
|
|
98
|
+
* Tokens without a known expiry are treated as valid.
|
|
99
|
+
*
|
|
100
|
+
* Shopwave only issues a new access token once the old one has actually
|
|
101
|
+
* expired, so the default skew is 0 — refreshing early just returns the same
|
|
102
|
+
* token.
|
|
103
|
+
*/
|
|
104
|
+
function isTokenExpired(token, { skewSeconds = 0, now = Date.now() } = {}) {
|
|
105
|
+
if (token.expiresAt === void 0) return false;
|
|
106
|
+
return now >= token.expiresAt - skewSeconds * 1e3;
|
|
107
|
+
}
|
|
108
|
+
/** `Authorization` header value, e.g. `OAuth 111ad…`. */
|
|
109
|
+
function authorizationHeader(token) {
|
|
110
|
+
return `${token.tokenType || "OAuth"} ${token.accessToken}`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* True when a Shopwave API response means "your access token is no longer
|
|
114
|
+
* valid": HTTP 401, or the API error 908 in the response envelope.
|
|
115
|
+
* Use it to trigger a forced refresh + a single retry.
|
|
116
|
+
*/
|
|
117
|
+
function isExpiredTokenResponse(status, body) {
|
|
118
|
+
if (status === 401) return true;
|
|
119
|
+
const message = body?.api?.message;
|
|
120
|
+
const errors = message?.errors ?? message?.error;
|
|
121
|
+
if (!errors || typeof errors !== "object") return false;
|
|
122
|
+
return Object.entries(errors).some(([key, value]) => key === String(SHOPWAVE_TOKEN_EXPIRED_ERROR_ID) || Number(value?.id) === SHOPWAVE_TOKEN_EXPIRED_ERROR_ID);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/server/oauth.ts
|
|
127
|
+
const REQUIRED_KEYS = [
|
|
128
|
+
"authServerUrl",
|
|
129
|
+
"clientId",
|
|
130
|
+
"clientSecret",
|
|
131
|
+
"redirectUri"
|
|
132
|
+
];
|
|
133
|
+
/** Throws a readable error when required settings are missing. */
|
|
134
|
+
function assertOAuthConfig(config) {
|
|
135
|
+
const missing = REQUIRED_KEYS.filter((k) => !config?.[k]);
|
|
136
|
+
if (missing.length > 0) throw new ShopwaveAuthError("config_invalid", `Shopwave auth is missing required settings: ${missing.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
function trimSlash(url) {
|
|
139
|
+
return url.replace(/\/+$/, "");
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Creates a framework-agnostic Shopwave OAuth client (authorization-code flow
|
|
143
|
+
* with a client secret — the Shopwave auth server does not support PKCE, so
|
|
144
|
+
* this must run on a server).
|
|
145
|
+
*
|
|
146
|
+
* Config is validated lazily on first use, so creating the client at module
|
|
147
|
+
* scope doesn't break builds where env vars aren't present.
|
|
148
|
+
*/
|
|
149
|
+
function createShopwaveOAuth(config) {
|
|
150
|
+
const endpoints = {
|
|
151
|
+
login: config.endpoints?.login ?? "/login",
|
|
152
|
+
token: config.endpoints?.token ?? "/oauth/token",
|
|
153
|
+
logout: config.endpoints?.logout ?? "/logout"
|
|
154
|
+
};
|
|
155
|
+
const base = () => {
|
|
156
|
+
assertOAuthConfig(config);
|
|
157
|
+
return trimSlash(config.authServerUrl);
|
|
158
|
+
};
|
|
159
|
+
const commonParams = (redirectUri) => new URLSearchParams({
|
|
160
|
+
access_type: config.accessType ?? "online",
|
|
161
|
+
redirect_uri: redirectUri,
|
|
162
|
+
response_type: "code",
|
|
163
|
+
client_id: config.clientId,
|
|
164
|
+
scope: config.scope ?? "application"
|
|
165
|
+
});
|
|
166
|
+
async function postToken(fields, errorCode) {
|
|
167
|
+
const url = `${base()}${endpoints.token}`;
|
|
168
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
169
|
+
let body;
|
|
170
|
+
if (config.tokenRequestFormat === "multipart") {
|
|
171
|
+
const fd = new FormData();
|
|
172
|
+
for (const [k, v] of Object.entries(fields)) fd.append(k, v);
|
|
173
|
+
body = fd;
|
|
174
|
+
} else body = new URLSearchParams(fields);
|
|
175
|
+
let response;
|
|
176
|
+
try {
|
|
177
|
+
response = await doFetch(url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
body,
|
|
180
|
+
headers: { Accept: "application/json" },
|
|
181
|
+
cache: "no-store"
|
|
182
|
+
});
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
throw new ShopwaveAuthError("network_error", `Could not reach the Shopwave auth server`, { cause });
|
|
185
|
+
}
|
|
186
|
+
const text = await response.text();
|
|
187
|
+
if (!response.ok) throw new ShopwaveAuthError(errorCode, `Shopwave auth server returned ${response.status} for ${fields.grant_type}`, {
|
|
188
|
+
status: response.status,
|
|
189
|
+
body: text.slice(0, 2e3)
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(text);
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
throw new ShopwaveAuthError("token_response_invalid", "Token response was not JSON", {
|
|
195
|
+
status: response.status,
|
|
196
|
+
body: text.slice(0, 2e3),
|
|
197
|
+
cause
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
config,
|
|
203
|
+
buildLoginUrl({ state } = {}) {
|
|
204
|
+
const params = commonParams(config.redirectUri);
|
|
205
|
+
if (state) params.set("state", state);
|
|
206
|
+
return `${base()}${endpoints.login}?${params.toString()}`;
|
|
207
|
+
},
|
|
208
|
+
buildLogoutUrl({ redirectUri } = {}) {
|
|
209
|
+
const params = commonParams(redirectUri ?? config.postLogoutRedirectUri ?? config.redirectUri);
|
|
210
|
+
return `${base()}${endpoints.logout}?${params.toString()}`;
|
|
211
|
+
},
|
|
212
|
+
async exchangeCode(code) {
|
|
213
|
+
if (!code) throw new ShopwaveAuthError("token_exchange_failed", "Missing authorization code");
|
|
214
|
+
const token = tokenFromResponse(await postToken({
|
|
215
|
+
code,
|
|
216
|
+
redirect_uri: config.redirectUri,
|
|
217
|
+
client_id: config.clientId,
|
|
218
|
+
client_secret: config.clientSecret,
|
|
219
|
+
scope: config.scope ?? "application",
|
|
220
|
+
grant_type: "authorization_code"
|
|
221
|
+
}, "token_exchange_failed"));
|
|
222
|
+
if (!token) throw new ShopwaveAuthError("token_response_invalid", "Token response had no access_token");
|
|
223
|
+
return token;
|
|
224
|
+
},
|
|
225
|
+
async refreshToken(tokenOrRefreshToken) {
|
|
226
|
+
const previous = typeof tokenOrRefreshToken === "string" ? {
|
|
227
|
+
accessToken: "",
|
|
228
|
+
refreshToken: tokenOrRefreshToken,
|
|
229
|
+
tokenType: "OAuth"
|
|
230
|
+
} : tokenOrRefreshToken;
|
|
231
|
+
if (!previous.refreshToken) throw new ShopwaveAuthError("token_refresh_failed", "No refresh token available", { status: 400 });
|
|
232
|
+
const token = tokenFromResponse(await postToken({
|
|
233
|
+
refresh_token: previous.refreshToken,
|
|
234
|
+
redirect_uri: config.redirectUri,
|
|
235
|
+
client_id: config.clientId,
|
|
236
|
+
client_secret: config.clientSecret,
|
|
237
|
+
grant_type: "refresh_token"
|
|
238
|
+
}, "token_refresh_failed"), previous);
|
|
239
|
+
if (!token) throw new ShopwaveAuthError("token_response_invalid", "Refresh response had no access_token");
|
|
240
|
+
return token;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/server/returnTo.ts
|
|
247
|
+
/**
|
|
248
|
+
* Helpers for the round trip through the auth server.
|
|
249
|
+
*/
|
|
250
|
+
/**
|
|
251
|
+
* Only allows same-origin relative paths ("/products?tab=1"). Anything that
|
|
252
|
+
* could send the user to another site after login — absolute URLs,
|
|
253
|
+
* protocol-relative "//evil.com", backslash tricks, control characters —
|
|
254
|
+
* falls back to `fallback`.
|
|
255
|
+
*/
|
|
256
|
+
function sanitizeReturnTo(value, fallback = "/") {
|
|
257
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) return fallback;
|
|
258
|
+
if (!/^\/(?![/\\])/.test(value)) return fallback;
|
|
259
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
|
|
260
|
+
if (value.includes("\\")) return fallback;
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
/** Random, URL-safe value for the OAuth `state` parameter (128 bits, hex). */
|
|
264
|
+
function createState() {
|
|
265
|
+
const bytes = new Uint8Array(16);
|
|
266
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
267
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
268
|
+
}
|
|
269
|
+
/** Constant-time string comparison for state values. */
|
|
270
|
+
function safeEqual(a, b) {
|
|
271
|
+
if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
|
|
272
|
+
let diff = 0;
|
|
273
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
274
|
+
return diff === 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/core/entities.ts
|
|
279
|
+
/**
|
|
280
|
+
* Addressing per entity, following the Shopwave API reference
|
|
281
|
+
* (https://developer.merchantstack.com/api-reference.html) and checked against
|
|
282
|
+
* the live API where noted:
|
|
283
|
+
* - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
|
|
284
|
+
* - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
|
|
285
|
+
* - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
|
|
286
|
+
* changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
|
|
287
|
+
* - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
|
|
288
|
+
*/
|
|
289
|
+
const SHOPWAVE_ENTITIES = Object.freeze({
|
|
290
|
+
product: {
|
|
291
|
+
route: "products",
|
|
292
|
+
collection: "products",
|
|
293
|
+
upstream: "product",
|
|
294
|
+
idsHeader: "productIds",
|
|
295
|
+
idHeader: "productId",
|
|
296
|
+
writable: true,
|
|
297
|
+
deleteMode: "delete"
|
|
298
|
+
},
|
|
299
|
+
category: {
|
|
300
|
+
route: "categories",
|
|
301
|
+
collection: "categories",
|
|
302
|
+
upstream: "category",
|
|
303
|
+
idsHeader: "categoryIds",
|
|
304
|
+
idHeader: "categoryId",
|
|
305
|
+
writable: true,
|
|
306
|
+
deleteMode: "delete"
|
|
307
|
+
},
|
|
308
|
+
store: {
|
|
309
|
+
route: "stores",
|
|
310
|
+
collection: "stores",
|
|
311
|
+
upstream: "store",
|
|
312
|
+
idsHeader: "storeIds",
|
|
313
|
+
idHeader: "storeId",
|
|
314
|
+
writable: true,
|
|
315
|
+
deleteMode: "delete"
|
|
316
|
+
},
|
|
317
|
+
promotion: {
|
|
318
|
+
route: "promotions",
|
|
319
|
+
collection: "promotions",
|
|
320
|
+
upstream: "promotion",
|
|
321
|
+
idsHeader: "promotionIds",
|
|
322
|
+
idHeader: "promotionId",
|
|
323
|
+
writable: true,
|
|
324
|
+
deleteMode: "retire",
|
|
325
|
+
retireField: "endDate"
|
|
326
|
+
},
|
|
327
|
+
employee: {
|
|
328
|
+
route: "employees",
|
|
329
|
+
collection: "employees",
|
|
330
|
+
upstream: "employee",
|
|
331
|
+
idsHeader: "employeeIds",
|
|
332
|
+
idHeader: "employeeId",
|
|
333
|
+
writable: true,
|
|
334
|
+
deleteMode: "retire",
|
|
335
|
+
retireField: "exitDate"
|
|
336
|
+
},
|
|
337
|
+
consumer: {
|
|
338
|
+
route: "consumer",
|
|
339
|
+
collection: "consumers",
|
|
340
|
+
upstream: "consumer",
|
|
341
|
+
idsHeader: "ids",
|
|
342
|
+
idHeader: "consumerId",
|
|
343
|
+
writable: false,
|
|
344
|
+
deleteMode: "none"
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/server/api.ts
|
|
350
|
+
/**
|
|
351
|
+
* The OAuth token a caller sent with the request, without its scheme, or `null`.
|
|
352
|
+
*
|
|
353
|
+
* Reads, in order: `Authorization: OAuth|Bearer <token>` (what the SDK sends
|
|
354
|
+
* from 0.3), the `token` header (SDK ≤ 0.2 writes), and `extras.token`
|
|
355
|
+
* (SDK ≤ 0.2 reads).
|
|
356
|
+
*/
|
|
357
|
+
function readRequestToken(request) {
|
|
358
|
+
const authz = request.headers.get("authorization");
|
|
359
|
+
if (authz) {
|
|
360
|
+
const match = /^(?:oauth|bearer)\s+(\S+)\s*$/i.exec(authz);
|
|
361
|
+
if (match) return match[1];
|
|
362
|
+
}
|
|
363
|
+
const legacyHeader = request.headers.get("token");
|
|
364
|
+
if (legacyHeader && legacyHeader.trim()) return legacyHeader.trim();
|
|
365
|
+
const extras = request.headers.get("extras");
|
|
366
|
+
if (extras) try {
|
|
367
|
+
const token = JSON.parse(extras)?.token;
|
|
368
|
+
if (typeof token === "string" && token.trim()) return token.trim();
|
|
369
|
+
} catch {}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* `extras` keys that are never forwarded upstream (case-insensitive).
|
|
374
|
+
* `Content-Type` is allowed (SDK clients have always sent `application/json`
|
|
375
|
+
* on reads) but is replaced by the form content type on writes.
|
|
376
|
+
*/
|
|
377
|
+
const BLOCKED_EXTRAS = new Set([
|
|
378
|
+
"authorization",
|
|
379
|
+
"token",
|
|
380
|
+
"cookie",
|
|
381
|
+
"host",
|
|
382
|
+
"x-accept-version",
|
|
383
|
+
"content-length",
|
|
384
|
+
"connection",
|
|
385
|
+
"transfer-encoding",
|
|
386
|
+
"te",
|
|
387
|
+
"upgrade",
|
|
388
|
+
"expect",
|
|
389
|
+
"keep-alive",
|
|
390
|
+
"proxy-authorization",
|
|
391
|
+
"forwarded"
|
|
392
|
+
]);
|
|
393
|
+
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
394
|
+
function headerValue(value) {
|
|
395
|
+
if (value == null) return null;
|
|
396
|
+
let text;
|
|
397
|
+
if (Array.isArray(value)) text = value.map((v) => v != null && typeof v === "object" ? JSON.stringify(v) : String(v)).join(",");
|
|
398
|
+
else if (typeof value === "object") text = JSON.stringify(value);
|
|
399
|
+
else text = String(value);
|
|
400
|
+
return /[\r\n\0]/.test(text) ? null : text;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Parses the `extras` request header into upstream headers.
|
|
404
|
+
* Returns `null` when the header isn't a JSON object.
|
|
405
|
+
*/
|
|
406
|
+
function parseExtras(raw, blocked = BLOCKED_EXTRAS) {
|
|
407
|
+
if (!raw) return {};
|
|
408
|
+
let parsed;
|
|
409
|
+
try {
|
|
410
|
+
parsed = JSON.parse(raw);
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
415
|
+
const headers = {};
|
|
416
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
417
|
+
const lower = key.toLowerCase();
|
|
418
|
+
if (!HEADER_NAME.test(key) || blocked.has(lower) || lower.startsWith("x-forwarded-") || lower.startsWith("proxy-")) continue;
|
|
419
|
+
const text = headerValue(value);
|
|
420
|
+
if (text !== null) headers[key] = text;
|
|
421
|
+
}
|
|
422
|
+
return headers;
|
|
423
|
+
}
|
|
424
|
+
var RequestBodyError = class extends Error {};
|
|
425
|
+
function isPlainObject(value) {
|
|
426
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Normalises a write request body into the Shopwave envelope
|
|
430
|
+
* `{ <collection>: { <ref>: entity, … } }`.
|
|
431
|
+
*
|
|
432
|
+
* Accepts:
|
|
433
|
+
* - the SDK ≥ 0.3 shape `{ <collection>: { "0": {...}, "1": {...} } }` (refs kept),
|
|
434
|
+
* - the older AdminUI shapes `{ <collection>: { new: {...} } }` / `{ updated: {...} }`,
|
|
435
|
+
* - a bare entity object `{ title: … }`.
|
|
436
|
+
*
|
|
437
|
+
* With `forcedId` (item routes) exactly one entity is allowed and its `id` is
|
|
438
|
+
* set from the URL.
|
|
439
|
+
*/
|
|
440
|
+
function normalizeWriteBody(def, body, forcedId) {
|
|
441
|
+
if (!isPlainObject(body)) throw new RequestBodyError("Request body must be a JSON object");
|
|
442
|
+
let refs;
|
|
443
|
+
const wrapped = body[def.collection];
|
|
444
|
+
if (wrapped !== void 0) {
|
|
445
|
+
if (!isPlainObject(wrapped)) throw new RequestBodyError(`"${def.collection}" must be an object keyed by ref`);
|
|
446
|
+
const keys = Object.keys(wrapped);
|
|
447
|
+
const entries = keys.length === 1 && (keys[0] === "new" || keys[0] === "updated") ? [["0", wrapped[keys[0]]]] : Object.entries(wrapped);
|
|
448
|
+
refs = {};
|
|
449
|
+
for (const [ref, entity] of entries) {
|
|
450
|
+
if (!isPlainObject(entity)) throw new RequestBodyError(`"${def.collection}.${ref}" must be an object`);
|
|
451
|
+
refs[ref] = { ...entity };
|
|
452
|
+
}
|
|
453
|
+
} else refs = { "0": { ...body } };
|
|
454
|
+
const count = Object.keys(refs).length;
|
|
455
|
+
if (count === 0) throw new RequestBodyError(`No ${def.collection} to save`);
|
|
456
|
+
if (forcedId !== void 0) {
|
|
457
|
+
if (count !== 1) throw new RequestBodyError("Only one entity can be saved at an id route");
|
|
458
|
+
const only = refs[Object.keys(refs)[0]];
|
|
459
|
+
only.id = /^\d+$/.test(forcedId) ? Number(forcedId) : forcedId;
|
|
460
|
+
}
|
|
461
|
+
return { [def.collection]: refs };
|
|
462
|
+
}
|
|
463
|
+
const NO_STORE = { "Cache-Control": "no-store" };
|
|
464
|
+
function jsonError(status, error, message) {
|
|
465
|
+
return new Response(JSON.stringify({
|
|
466
|
+
error,
|
|
467
|
+
message
|
|
468
|
+
}), {
|
|
469
|
+
status,
|
|
470
|
+
headers: {
|
|
471
|
+
"Content-Type": "application/json",
|
|
472
|
+
...NO_STORE
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
/** `YYYY-MM-DD HH:mm:ss` in UTC, the datetime format Shopwave writes use (e.g. promotions). */
|
|
477
|
+
function shopwaveDateTime(date = /* @__PURE__ */ new Date()) {
|
|
478
|
+
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
479
|
+
}
|
|
480
|
+
const NULL_BODY_STATUS = new Set([
|
|
481
|
+
101,
|
|
482
|
+
103,
|
|
483
|
+
204,
|
|
484
|
+
205,
|
|
485
|
+
304
|
|
486
|
+
]);
|
|
487
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
488
|
+
async function readUpstream(response) {
|
|
489
|
+
const text = await response.text();
|
|
490
|
+
if (!text) return {
|
|
491
|
+
text,
|
|
492
|
+
json: null
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
return {
|
|
496
|
+
text,
|
|
497
|
+
json: JSON.parse(text)
|
|
498
|
+
};
|
|
499
|
+
} catch {
|
|
500
|
+
return {
|
|
501
|
+
text,
|
|
502
|
+
json: null
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function routeParam(context, name) {
|
|
507
|
+
const value = (await context?.params)?.[name];
|
|
508
|
+
const id = Array.isArray(value) ? value[0] : value;
|
|
509
|
+
return typeof id === "string" && ID_PATTERN.test(id) ? id : null;
|
|
510
|
+
}
|
|
511
|
+
function createShopwaveApiHandlers(config) {
|
|
512
|
+
const allowRequestToken = config.allowRequestToken ?? true;
|
|
513
|
+
const apiVersion = config.apiVersion ?? "2.0";
|
|
514
|
+
const blocked = new Set([...BLOCKED_EXTRAS, ...(config.blockedExtras ?? []).map((k) => k.toLowerCase())]);
|
|
515
|
+
const onError = config.onError ?? ((error, ctx) => console.error(`[react-shopwave-connect] Shopwave API ${ctx.method} /${ctx.path} failed:`, error?.message ?? error));
|
|
516
|
+
function apiBase() {
|
|
517
|
+
const url = config.apiUrl;
|
|
518
|
+
if (!url) throw new Error("Shopwave API: apiUrl is not set (e.g. SHOPWAVE_API_SERVER_URL).");
|
|
519
|
+
return url.replace(/\/+$/, "");
|
|
520
|
+
}
|
|
521
|
+
function entity(kind) {
|
|
522
|
+
const base = SHOPWAVE_ENTITIES[kind];
|
|
523
|
+
if (!base) throw new Error(`Unknown Shopwave entity "${kind}"`);
|
|
524
|
+
return {
|
|
525
|
+
...base,
|
|
526
|
+
...config.entities?.[kind] ?? {}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
async function forward(request, init) {
|
|
530
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
531
|
+
const path = init.path.replace(/^\/+/, "");
|
|
532
|
+
const extras = init.forwardExtras === false ? {} : parseExtras(request.headers.get("extras"), blocked);
|
|
533
|
+
if (!extras) return jsonError(400, "invalid_extras", "The extras header must be a JSON object");
|
|
534
|
+
const requestToken = allowRequestToken ? readRequestToken(request) : null;
|
|
535
|
+
let authorization;
|
|
536
|
+
try {
|
|
537
|
+
authorization = requestToken ? `OAuth ${requestToken}` : await config.getAuthorization?.() ?? null;
|
|
538
|
+
} catch (error) {
|
|
539
|
+
onError(error, {
|
|
540
|
+
method,
|
|
541
|
+
path: "oauth/token"
|
|
542
|
+
});
|
|
543
|
+
return jsonError(502, "auth_unavailable", "Could not refresh the Shopwave session");
|
|
544
|
+
}
|
|
545
|
+
if (!authorization) return jsonError(401, "unauthorized", "Not logged in");
|
|
546
|
+
const body = init.postBody === void 0 ? void 0 : new URLSearchParams({ postBody: JSON.stringify(init.postBody) });
|
|
547
|
+
const call = (authz) => {
|
|
548
|
+
const headers = new Headers(extras);
|
|
549
|
+
for (const [k, v] of Object.entries(init.headers ?? {})) headers.set(k, v);
|
|
550
|
+
headers.set("Authorization", authz);
|
|
551
|
+
headers.set("x-accept-version", apiVersion);
|
|
552
|
+
if (body) headers.set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
553
|
+
return (config.fetch ?? fetch)(`${apiBase()}/${path}`, {
|
|
554
|
+
method,
|
|
555
|
+
headers,
|
|
556
|
+
body,
|
|
557
|
+
cache: "no-store"
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
try {
|
|
561
|
+
let response = await call(authorization);
|
|
562
|
+
let upstream = await readUpstream(response);
|
|
563
|
+
if (!requestToken && config.getAuthorization && isExpiredTokenResponse(response.status, upstream.json)) {
|
|
564
|
+
const fresh = await config.getAuthorization({ forceRefresh: true });
|
|
565
|
+
if (!fresh) return jsonError(401, "unauthorized", "The Shopwave session has expired");
|
|
566
|
+
response = await call(fresh);
|
|
567
|
+
upstream = await readUpstream(response);
|
|
568
|
+
}
|
|
569
|
+
if (NULL_BODY_STATUS.has(response.status) || !upstream.text) return new Response(null, {
|
|
570
|
+
status: response.status,
|
|
571
|
+
headers: NO_STORE
|
|
572
|
+
});
|
|
573
|
+
return new Response(upstream.text, {
|
|
574
|
+
status: response.status,
|
|
575
|
+
headers: {
|
|
576
|
+
"Content-Type": upstream.json !== null ? "application/json" : response.headers.get("content-type") ?? "text/plain",
|
|
577
|
+
...NO_STORE
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
} catch (error) {
|
|
581
|
+
onError(error, {
|
|
582
|
+
method,
|
|
583
|
+
path
|
|
584
|
+
});
|
|
585
|
+
return jsonError(502, "upstream_unavailable", "The Shopwave API could not be reached");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function readJsonBody(request) {
|
|
589
|
+
const text = await request.text();
|
|
590
|
+
if (!text) throw new RequestBodyError("Request body is empty");
|
|
591
|
+
try {
|
|
592
|
+
return JSON.parse(text);
|
|
593
|
+
} catch {
|
|
594
|
+
throw new RequestBodyError("Request body is not valid JSON");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function notSupported(def, what) {
|
|
598
|
+
return jsonError(405, "method_not_allowed", `The Shopwave API doesn't support ${what} for ${def.collection}`);
|
|
599
|
+
}
|
|
600
|
+
async function write(request, def, forcedId) {
|
|
601
|
+
if (!def.writable) return notSupported(def, "writes");
|
|
602
|
+
let postBody;
|
|
603
|
+
try {
|
|
604
|
+
postBody = normalizeWriteBody(def, await readJsonBody(request), forcedId);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error instanceof RequestBodyError) return jsonError(400, "invalid_body", error.message);
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
return forward(request, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
path: def.upstream,
|
|
612
|
+
postBody
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
function badId() {
|
|
616
|
+
return jsonError(400, "invalid_id", "Missing or invalid id");
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
forward,
|
|
620
|
+
entity,
|
|
621
|
+
collection(kind) {
|
|
622
|
+
const def = entity(kind);
|
|
623
|
+
const save = (request) => write(request, def);
|
|
624
|
+
return {
|
|
625
|
+
GET: (request) => forward(request, { path: def.upstream }),
|
|
626
|
+
POST: save,
|
|
627
|
+
PUT: save
|
|
628
|
+
};
|
|
629
|
+
},
|
|
630
|
+
item(kind, options = {}) {
|
|
631
|
+
const def = entity(kind);
|
|
632
|
+
const param = options.param ?? "id";
|
|
633
|
+
return {
|
|
634
|
+
async GET(request, context) {
|
|
635
|
+
const id = await routeParam(context, param);
|
|
636
|
+
if (!id) return badId();
|
|
637
|
+
return forward(request, {
|
|
638
|
+
path: def.upstream,
|
|
639
|
+
headers: { [def.idsHeader]: id }
|
|
640
|
+
});
|
|
641
|
+
},
|
|
642
|
+
async PUT(request, context) {
|
|
643
|
+
const id = await routeParam(context, param);
|
|
644
|
+
if (!id) return badId();
|
|
645
|
+
return write(request, def, id);
|
|
646
|
+
},
|
|
647
|
+
async DELETE(request, context) {
|
|
648
|
+
const id = await routeParam(context, param);
|
|
649
|
+
if (!id) return badId();
|
|
650
|
+
if (def.deleteMode === "none") return notSupported(def, "deletes");
|
|
651
|
+
if (def.deleteMode === "retire") {
|
|
652
|
+
const field = def.retireField ?? "exitDate";
|
|
653
|
+
return forward(request, {
|
|
654
|
+
method: "POST",
|
|
655
|
+
path: def.upstream,
|
|
656
|
+
postBody: { [def.collection]: { 0: {
|
|
657
|
+
id: /^\d+$/.test(id) ? Number(id) : id,
|
|
658
|
+
[field]: shopwaveDateTime()
|
|
659
|
+
} } },
|
|
660
|
+
forwardExtras: false
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return forward(request, {
|
|
664
|
+
method: "DELETE",
|
|
665
|
+
path: def.upstream,
|
|
666
|
+
headers: { [def.idHeader]: id },
|
|
667
|
+
forwardExtras: false
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
},
|
|
672
|
+
passthrough(path) {
|
|
673
|
+
return { GET: (request) => forward(request, { path }) };
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/next/index.ts
|
|
680
|
+
function normalizePath(p) {
|
|
681
|
+
const withSlash = p.startsWith("/") ? p : `/${p}`;
|
|
682
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
|
683
|
+
}
|
|
684
|
+
function pathMatches(pathname, list) {
|
|
685
|
+
return list.some((raw) => {
|
|
686
|
+
const p = normalizePath(raw);
|
|
687
|
+
if (p === "/") return pathname === "/";
|
|
688
|
+
return pathname === p || pathname.startsWith(`${p}/`);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
function escapeHtml(s) {
|
|
692
|
+
return s.replace(/[&<>"']/g, (c) => ({
|
|
693
|
+
"&": "&",
|
|
694
|
+
"<": "<",
|
|
695
|
+
">": ">",
|
|
696
|
+
"\"": """,
|
|
697
|
+
"'": "'"
|
|
698
|
+
})[c]);
|
|
699
|
+
}
|
|
700
|
+
function readCookie(request, name) {
|
|
701
|
+
const fromNext = request.cookies?.get?.(name)?.value;
|
|
702
|
+
if (fromNext) return fromNext;
|
|
703
|
+
const header = request.headers.get("cookie");
|
|
704
|
+
if (!header) return void 0;
|
|
705
|
+
for (const part of header.split(";")) {
|
|
706
|
+
const idx = part.indexOf("=");
|
|
707
|
+
if (idx === -1) continue;
|
|
708
|
+
if (part.slice(0, idx).trim() === name) {
|
|
709
|
+
const value = part.slice(idx + 1).trim();
|
|
710
|
+
try {
|
|
711
|
+
return decodeURIComponent(value);
|
|
712
|
+
} catch {
|
|
713
|
+
return value;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
let warnedAboutReadOnlyCookies = false;
|
|
719
|
+
/**
|
|
720
|
+
* Creates the Shopwave auth instance for one Next.js app. Call once at module
|
|
721
|
+
* scope (e.g. `lib/auth.ts`) and reuse. Config is validated on first use, so
|
|
722
|
+
* a missing env var doesn't fail `next build`.
|
|
723
|
+
*/
|
|
724
|
+
function createShopwaveAuth(config) {
|
|
725
|
+
if (typeof window !== "undefined") throw new Error("react-shopwave-connect/next is server-only. Import it from route handlers, server code or proxy.ts.");
|
|
726
|
+
const oauth = createShopwaveOAuth(config);
|
|
727
|
+
const authPath = normalizePath(config.authPath ?? "/auth");
|
|
728
|
+
const logoutPath = normalizePath(config.logoutPath ?? `${authPath}/logout`);
|
|
729
|
+
const sessionPath = normalizePath(config.sessionPath ?? "/api/session");
|
|
730
|
+
const defaultReturnTo = sanitizeReturnTo(config.defaultReturnTo, "/");
|
|
731
|
+
const requireState = config.requireState ?? true;
|
|
732
|
+
const skewSeconds = config.refreshSkewSeconds ?? 0;
|
|
733
|
+
const loginTimeoutMs = (config.loginTimeoutSeconds ?? 600) * 1e3;
|
|
734
|
+
const cookieName = config.session?.cookieName ?? "shopwave_session";
|
|
735
|
+
/**
|
|
736
|
+
* Refresh de-duplication, per server instance. Requests that read the same
|
|
737
|
+
* (old) token share one refresh while it's in flight, and for a short while
|
|
738
|
+
* afterwards reuse its result instead of refreshing again.
|
|
739
|
+
*/
|
|
740
|
+
const inflightRefresh = /* @__PURE__ */ new Map();
|
|
741
|
+
const recentRefresh = /* @__PURE__ */ new Map();
|
|
742
|
+
const RECENT_REFRESH_MS = 3e4;
|
|
743
|
+
function sessionOptions() {
|
|
744
|
+
const password = config.session?.password;
|
|
745
|
+
const passwords = typeof password === "string" ? [password] : Object.values(password ?? {});
|
|
746
|
+
if (passwords.length === 0 || passwords.some((p) => typeof p !== "string" || p.length < 32)) throw new ShopwaveAuthError("config_invalid", "Shopwave auth: session.password must be at least 32 characters (set a long random secret in your env).");
|
|
747
|
+
const cookieOptions = {
|
|
748
|
+
httpOnly: true,
|
|
749
|
+
secure: config.session.secure ?? process.env.NODE_ENV === "production",
|
|
750
|
+
sameSite: config.session.sameSite ?? "lax",
|
|
751
|
+
path: "/"
|
|
752
|
+
};
|
|
753
|
+
if (config.session.domain) cookieOptions.domain = config.session.domain;
|
|
754
|
+
const options = {
|
|
755
|
+
cookieName,
|
|
756
|
+
password,
|
|
757
|
+
cookieOptions
|
|
758
|
+
};
|
|
759
|
+
if (typeof config.session.ttl === "number") options.ttl = config.session.ttl;
|
|
760
|
+
return options;
|
|
761
|
+
}
|
|
762
|
+
async function getSession() {
|
|
763
|
+
return (0, iron_session.getIronSession)(await (0, next_headers.cookies)(), sessionOptions());
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Cookies can only be written from route handlers, server actions and proxy.
|
|
767
|
+
* From a Server Component the token is still usable for this request; it will
|
|
768
|
+
* simply be refreshed again next time.
|
|
769
|
+
*/
|
|
770
|
+
async function trySave(session) {
|
|
771
|
+
try {
|
|
772
|
+
await session.save();
|
|
773
|
+
} catch (error) {
|
|
774
|
+
if (!warnedAboutReadOnlyCookies) {
|
|
775
|
+
warnedAboutReadOnlyCookies = true;
|
|
776
|
+
console.warn("[react-shopwave-connect] Could not update the session cookie (called outside a route handler / server action?).", error?.message);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function tryDestroy(session) {
|
|
781
|
+
try {
|
|
782
|
+
session.destroy();
|
|
783
|
+
} catch {}
|
|
784
|
+
}
|
|
785
|
+
function refreshOnce(token) {
|
|
786
|
+
const key = `${token.refreshToken}\u0000${token.accessToken}`;
|
|
787
|
+
const now = Date.now();
|
|
788
|
+
const recent = recentRefresh.get(key);
|
|
789
|
+
if (recent && now - recent.at < RECENT_REFRESH_MS) return Promise.resolve(recent.token);
|
|
790
|
+
let pending = inflightRefresh.get(key);
|
|
791
|
+
if (!pending) {
|
|
792
|
+
pending = oauth.refreshToken(token).then((fresh) => {
|
|
793
|
+
for (const [k, v] of recentRefresh) if (now - v.at >= RECENT_REFRESH_MS) recentRefresh.delete(k);
|
|
794
|
+
recentRefresh.set(key, {
|
|
795
|
+
token: fresh,
|
|
796
|
+
at: Date.now()
|
|
797
|
+
});
|
|
798
|
+
return fresh;
|
|
799
|
+
}).finally(() => inflightRefresh.delete(key));
|
|
800
|
+
inflightRefresh.set(key, pending);
|
|
801
|
+
}
|
|
802
|
+
return pending;
|
|
803
|
+
}
|
|
804
|
+
async function getToken({ forceRefresh = false } = {}) {
|
|
805
|
+
const session = await getSession();
|
|
806
|
+
const token = normalizeStoredToken(session.token);
|
|
807
|
+
if (!token) return null;
|
|
808
|
+
const expired = isTokenExpired(token, { skewSeconds });
|
|
809
|
+
if (!forceRefresh && !expired) return token;
|
|
810
|
+
if (!token.refreshToken) {
|
|
811
|
+
if (expired || forceRefresh) {
|
|
812
|
+
tryDestroy(session);
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
return token;
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
const refreshed = await refreshOnce(token);
|
|
819
|
+
session.token = refreshed;
|
|
820
|
+
await trySave(session);
|
|
821
|
+
return refreshed;
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (error instanceof ShopwaveAuthError && error.isInvalidGrant) {
|
|
824
|
+
tryDestroy(session);
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function loginPath(returnTo) {
|
|
831
|
+
if (!returnTo) return authPath;
|
|
832
|
+
const safe = sanitizeReturnTo(returnTo, defaultReturnTo);
|
|
833
|
+
return `${authPath}?returnTo=${encodeURIComponent(safe)}`;
|
|
834
|
+
}
|
|
835
|
+
function redirectTo(location, request) {
|
|
836
|
+
return next_server.NextResponse.redirect(new URL(location, request.url), 302);
|
|
837
|
+
}
|
|
838
|
+
function errorPage(status, message, retryHref) {
|
|
839
|
+
return new next_server.NextResponse(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in problem</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;line-height:1.5"><h1 style="font-size:1.25rem">Sign-in didn’t complete</h1><p>${escapeHtml(message)}</p><p><a href="${escapeHtml(retryHref)}">Try again</a></p></body></html>`, {
|
|
840
|
+
status,
|
|
841
|
+
headers: {
|
|
842
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
843
|
+
"Cache-Control": "no-store"
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
async function authHandler(request) {
|
|
848
|
+
const url = new URL(request.url);
|
|
849
|
+
const session = await getSession();
|
|
850
|
+
const pending = session.pendingLogin;
|
|
851
|
+
const oauthError = url.searchParams.get("error");
|
|
852
|
+
if (oauthError) {
|
|
853
|
+
delete session.pendingLogin;
|
|
854
|
+
await trySave(session);
|
|
855
|
+
return errorPage(400, `Shopwave sign-in reported: ${url.searchParams.get("error_description") || oauthError}.`, loginPath(pending?.returnTo));
|
|
856
|
+
}
|
|
857
|
+
const code = url.searchParams.get("code");
|
|
858
|
+
if (!code) {
|
|
859
|
+
const returnTo$1 = sanitizeReturnTo(url.searchParams.get("returnTo"), defaultReturnTo);
|
|
860
|
+
const state = createState();
|
|
861
|
+
session.pendingLogin = {
|
|
862
|
+
state,
|
|
863
|
+
returnTo: returnTo$1,
|
|
864
|
+
createdAt: Date.now()
|
|
865
|
+
};
|
|
866
|
+
await session.save();
|
|
867
|
+
return next_server.NextResponse.redirect(oauth.buildLoginUrl({ state }), 302);
|
|
868
|
+
}
|
|
869
|
+
const returnedState = url.searchParams.get("state");
|
|
870
|
+
const isFresh = !!pending && Date.now() - pending.createdAt <= loginTimeoutMs;
|
|
871
|
+
const stateMatches = !!pending && !!returnedState && safeEqual(returnedState, pending.state);
|
|
872
|
+
if (requireState ? !(stateMatches && isFresh) : !!pending && !!returnedState && !stateMatches) {
|
|
873
|
+
delete session.pendingLogin;
|
|
874
|
+
await trySave(session);
|
|
875
|
+
return errorPage(400, "This sign-in link has expired or didn’t start from this browser. Please sign in again.", loginPath(pending?.returnTo));
|
|
876
|
+
}
|
|
877
|
+
let token;
|
|
878
|
+
try {
|
|
879
|
+
token = await oauth.exchangeCode(code);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
console.error("[react-shopwave-connect] Code exchange failed:", error?.message);
|
|
882
|
+
delete session.pendingLogin;
|
|
883
|
+
await trySave(session);
|
|
884
|
+
return errorPage(error instanceof ShopwaveAuthError && error.isInvalidGrant ? 400 : 502, "We couldn’t complete sign-in with Shopwave.", loginPath(pending?.returnTo));
|
|
885
|
+
}
|
|
886
|
+
const returnTo = pending?.returnTo ?? defaultReturnTo;
|
|
887
|
+
delete session.pendingLogin;
|
|
888
|
+
session.token = token;
|
|
889
|
+
await session.save();
|
|
890
|
+
return redirectTo(returnTo, request);
|
|
891
|
+
}
|
|
892
|
+
async function logoutHandler(_request) {
|
|
893
|
+
(await getSession()).destroy();
|
|
894
|
+
return next_server.NextResponse.redirect(oauth.buildLogoutUrl(), 302);
|
|
895
|
+
}
|
|
896
|
+
async function getStatus() {
|
|
897
|
+
const token = normalizeStoredToken((await getSession()).token);
|
|
898
|
+
if (!token) return { loggedIn: false };
|
|
899
|
+
return !isTokenExpired(token) || !!token.refreshToken ? {
|
|
900
|
+
loggedIn: true,
|
|
901
|
+
expiresAt: token.expiresAt
|
|
902
|
+
} : { loggedIn: false };
|
|
903
|
+
}
|
|
904
|
+
async function isAuthenticated(request) {
|
|
905
|
+
const value = readCookie(request, cookieName);
|
|
906
|
+
if (!value) return false;
|
|
907
|
+
try {
|
|
908
|
+
const { password, ttl } = sessionOptions();
|
|
909
|
+
const token = normalizeStoredToken((await (0, iron_session.unsealData)(value, {
|
|
910
|
+
password,
|
|
911
|
+
ttl
|
|
912
|
+
}))?.token);
|
|
913
|
+
return !!token && (!isTokenExpired(token) || !!token.refreshToken);
|
|
914
|
+
} catch {
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const noStore = { "Cache-Control": "no-store" };
|
|
919
|
+
return {
|
|
920
|
+
oauth,
|
|
921
|
+
logoutPath,
|
|
922
|
+
loginPath,
|
|
923
|
+
getSession,
|
|
924
|
+
getStatus,
|
|
925
|
+
getToken,
|
|
926
|
+
async getAccessToken(options) {
|
|
927
|
+
return (await getToken(options))?.accessToken ?? null;
|
|
928
|
+
},
|
|
929
|
+
async getAuthorizationHeader(options) {
|
|
930
|
+
const token = await getToken(options);
|
|
931
|
+
return token ? authorizationHeader(token) : null;
|
|
932
|
+
},
|
|
933
|
+
withAuth(handler) {
|
|
934
|
+
return async (request, context) => {
|
|
935
|
+
const token = await getToken();
|
|
936
|
+
if (!token) return next_server.NextResponse.json({ error: "unauthorized" }, {
|
|
937
|
+
status: 401,
|
|
938
|
+
headers: noStore
|
|
939
|
+
});
|
|
940
|
+
return handler(request, context, {
|
|
941
|
+
accessToken: token.accessToken,
|
|
942
|
+
authorization: authorizationHeader(token),
|
|
943
|
+
token
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
},
|
|
947
|
+
isAuthenticated,
|
|
948
|
+
async protect(request, options = {}) {
|
|
949
|
+
const { pathname, search } = new URL(request.url);
|
|
950
|
+
if (pathMatches(pathname, [
|
|
951
|
+
authPath,
|
|
952
|
+
logoutPath,
|
|
953
|
+
sessionPath,
|
|
954
|
+
...options.publicPaths ?? []
|
|
955
|
+
])) return;
|
|
956
|
+
const isApi = pathMatches(pathname, options.apiPaths ?? ["/api"]);
|
|
957
|
+
if (isApi && options.allowRequestToken && readRequestToken(request)) return void 0;
|
|
958
|
+
if (await isAuthenticated(request)) return void 0;
|
|
959
|
+
if (isApi) return next_server.NextResponse.json({ error: "unauthorized" }, {
|
|
960
|
+
status: 401,
|
|
961
|
+
headers: noStore
|
|
962
|
+
});
|
|
963
|
+
return redirectTo(loginPath(pathname + search), request);
|
|
964
|
+
},
|
|
965
|
+
handlers: {
|
|
966
|
+
auth: authHandler,
|
|
967
|
+
logout: logoutHandler,
|
|
968
|
+
session: {
|
|
969
|
+
async GET() {
|
|
970
|
+
return next_server.NextResponse.json(await getStatus(), { headers: noStore });
|
|
971
|
+
},
|
|
972
|
+
async DELETE() {
|
|
973
|
+
(await getSession()).destroy();
|
|
974
|
+
return new next_server.NextResponse(null, {
|
|
975
|
+
status: 204,
|
|
976
|
+
headers: noStore
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Ready-made Next.js route handlers for the SDK's `/api/*` contract, using the
|
|
985
|
+
* session from `createShopwaveAuth`. Create once (e.g. `lib/shopwave.ts`):
|
|
986
|
+
*
|
|
987
|
+
* ```ts
|
|
988
|
+
* export const shopwave = createShopwaveApi({ auth, apiUrl: process.env.SHOPWAVE_API_SERVER_URL! });
|
|
989
|
+
* ```
|
|
990
|
+
*
|
|
991
|
+
* then each route file is one line:
|
|
992
|
+
*
|
|
993
|
+
* ```ts
|
|
994
|
+
* // app/api/products/route.ts
|
|
995
|
+
* export const { GET, POST, PUT } = shopwave.collection("product");
|
|
996
|
+
* // app/api/products/[id]/route.ts
|
|
997
|
+
* export const { GET, PUT, DELETE } = shopwave.item("product");
|
|
998
|
+
* // app/api/report/route.ts
|
|
999
|
+
* export const { GET } = shopwave.passthrough("report");
|
|
1000
|
+
* ```
|
|
1001
|
+
*/
|
|
1002
|
+
function createShopwaveApi(config) {
|
|
1003
|
+
if (typeof window !== "undefined") throw new Error("react-shopwave-connect/next is server-only. Import it from route handlers or server code.");
|
|
1004
|
+
const { auth,...rest } = config;
|
|
1005
|
+
return createShopwaveApiHandlers({
|
|
1006
|
+
...rest,
|
|
1007
|
+
getAuthorization: (options) => auth.getAuthorizationHeader(options)
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
//#endregion
|
|
1012
|
+
exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
|
|
1013
|
+
exports.ShopwaveAuthError = ShopwaveAuthError;
|
|
1014
|
+
exports.authorizationHeader = authorizationHeader;
|
|
1015
|
+
exports.createShopwaveApi = createShopwaveApi;
|
|
1016
|
+
exports.createShopwaveAuth = createShopwaveAuth;
|
|
1017
|
+
exports.isExpiredTokenResponse = isExpiredTokenResponse;
|
|
1018
|
+
exports.readRequestToken = readRequestToken;
|
|
1019
|
+
//# sourceMappingURL=index.cjs.map
|