react-shopwave-connect 0.1.1 → 0.2.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,260 @@
1
+
2
+ //#region src/server/errors.ts
3
+ var ShopwaveAuthError = class extends Error {
4
+ code;
5
+ status;
6
+ /** Raw response body from the auth server, if any. Never contains our secret. */
7
+ body;
8
+ constructor(code, message, details = {}) {
9
+ super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
10
+ this.name = "ShopwaveAuthError";
11
+ this.code = code;
12
+ this.status = details.status;
13
+ this.body = details.body;
14
+ }
15
+ /**
16
+ * True when the auth server rejected the grant itself (bad/expired code or
17
+ * refresh token) rather than failing for a transient reason. Callers should
18
+ * treat the user as logged out.
19
+ */
20
+ get isInvalidGrant() {
21
+ return (this.code === "token_exchange_failed" || this.code === "token_refresh_failed") && this.status !== void 0 && this.status >= 400 && this.status < 500;
22
+ }
23
+ };
24
+
25
+ //#endregion
26
+ //#region src/server/token.ts
27
+ /** Shopwave API error id meaning "access token expired / invalid". */
28
+ const SHOPWAVE_TOKEN_EXPIRED_ERROR_ID = 908;
29
+ /**
30
+ * Converts a token-endpoint response into a {@link ShopwaveToken}.
31
+ * Returns `null` when the response has no access token.
32
+ *
33
+ * @param previous the token being refreshed; its refresh token is kept when
34
+ * the server doesn't send a new one.
35
+ */
36
+ function tokenFromResponse(response, previous, now = Date.now()) {
37
+ if (!response || typeof response.access_token !== "string" || !response.access_token) return null;
38
+ const expiresIn = Number(response.expires_in);
39
+ return {
40
+ accessToken: response.access_token,
41
+ refreshToken: typeof response.refresh_token === "string" && response.refresh_token || previous?.refreshToken,
42
+ tokenType: typeof response.token_type === "string" && response.token_type || "OAuth",
43
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? now + expiresIn * 1e3 : void 0
44
+ };
45
+ }
46
+ /**
47
+ * Reads a token from session storage. Accepts both the current shape and the
48
+ * legacy raw response that older apps (e.g. AdminUI ≤ 0.1) stored directly in
49
+ * the session, so existing sessions survive an upgrade.
50
+ */
51
+ function normalizeStoredToken(raw) {
52
+ if (!raw || typeof raw !== "object") return null;
53
+ const t = raw;
54
+ if (typeof t.accessToken === "string" && t.accessToken) return {
55
+ accessToken: t.accessToken,
56
+ refreshToken: typeof t.refreshToken === "string" ? t.refreshToken : void 0,
57
+ tokenType: typeof t.tokenType === "string" && t.tokenType ? t.tokenType : "OAuth",
58
+ expiresAt: typeof t.expiresAt === "number" ? t.expiresAt : void 0
59
+ };
60
+ if (typeof t.access_token === "string" && t.access_token) return {
61
+ accessToken: t.access_token,
62
+ refreshToken: typeof t.refresh_token === "string" ? t.refresh_token : void 0,
63
+ tokenType: typeof t.token_type === "string" && t.token_type ? t.token_type : "OAuth"
64
+ };
65
+ return null;
66
+ }
67
+ /**
68
+ * True when the token has passed its expiry (minus `skewSeconds`).
69
+ * Tokens without a known expiry are treated as valid.
70
+ *
71
+ * Shopwave only issues a new access token once the old one has actually
72
+ * expired, so the default skew is 0 — refreshing early just returns the same
73
+ * token.
74
+ */
75
+ function isTokenExpired(token, { skewSeconds = 0, now = Date.now() } = {}) {
76
+ if (token.expiresAt === void 0) return false;
77
+ return now >= token.expiresAt - skewSeconds * 1e3;
78
+ }
79
+ /** `Authorization` header value, e.g. `OAuth 111ad…`. */
80
+ function authorizationHeader(token) {
81
+ return `${token.tokenType || "OAuth"} ${token.accessToken}`;
82
+ }
83
+ /**
84
+ * True when a Shopwave API response means "your access token is no longer
85
+ * valid": HTTP 401, or the API error 908 in the response envelope.
86
+ * Use it to trigger a forced refresh + a single retry.
87
+ */
88
+ function isExpiredTokenResponse(status, body) {
89
+ if (status === 401) return true;
90
+ const errors = body?.api?.message?.errors;
91
+ if (!errors || typeof errors !== "object") return false;
92
+ return Object.entries(errors).some(([key, value]) => key === String(SHOPWAVE_TOKEN_EXPIRED_ERROR_ID) || Number(value?.id) === SHOPWAVE_TOKEN_EXPIRED_ERROR_ID);
93
+ }
94
+
95
+ //#endregion
96
+ //#region src/server/oauth.ts
97
+ const REQUIRED_KEYS = [
98
+ "authServerUrl",
99
+ "clientId",
100
+ "clientSecret",
101
+ "redirectUri"
102
+ ];
103
+ /** Throws a readable error when required settings are missing. */
104
+ function assertOAuthConfig(config) {
105
+ const missing = REQUIRED_KEYS.filter((k) => !config?.[k]);
106
+ if (missing.length > 0) throw new ShopwaveAuthError("config_invalid", `Shopwave auth is missing required settings: ${missing.join(", ")}`);
107
+ }
108
+ function trimSlash(url) {
109
+ return url.replace(/\/+$/, "");
110
+ }
111
+ /**
112
+ * Creates a framework-agnostic Shopwave OAuth client (authorization-code flow
113
+ * with a client secret — the Shopwave auth server does not support PKCE, so
114
+ * this must run on a server).
115
+ *
116
+ * Config is validated lazily on first use, so creating the client at module
117
+ * scope doesn't break builds where env vars aren't present.
118
+ */
119
+ function createShopwaveOAuth(config) {
120
+ const endpoints = {
121
+ login: config.endpoints?.login ?? "/login",
122
+ token: config.endpoints?.token ?? "/oauth/token",
123
+ logout: config.endpoints?.logout ?? "/logout"
124
+ };
125
+ const base = () => {
126
+ assertOAuthConfig(config);
127
+ return trimSlash(config.authServerUrl);
128
+ };
129
+ const commonParams = (redirectUri) => new URLSearchParams({
130
+ access_type: config.accessType ?? "online",
131
+ redirect_uri: redirectUri,
132
+ response_type: "code",
133
+ client_id: config.clientId,
134
+ scope: config.scope ?? "application"
135
+ });
136
+ async function postToken(fields, errorCode) {
137
+ const url = `${base()}${endpoints.token}`;
138
+ const doFetch = config.fetch ?? globalThis.fetch;
139
+ let body;
140
+ if (config.tokenRequestFormat === "multipart") {
141
+ const fd = new FormData();
142
+ for (const [k, v] of Object.entries(fields)) fd.append(k, v);
143
+ body = fd;
144
+ } else body = new URLSearchParams(fields);
145
+ let response;
146
+ try {
147
+ response = await doFetch(url, {
148
+ method: "POST",
149
+ body,
150
+ headers: { Accept: "application/json" },
151
+ cache: "no-store"
152
+ });
153
+ } catch (cause) {
154
+ throw new ShopwaveAuthError("network_error", `Could not reach the Shopwave auth server`, { cause });
155
+ }
156
+ const text = await response.text();
157
+ if (!response.ok) throw new ShopwaveAuthError(errorCode, `Shopwave auth server returned ${response.status} for ${fields.grant_type}`, {
158
+ status: response.status,
159
+ body: text.slice(0, 2e3)
160
+ });
161
+ try {
162
+ return JSON.parse(text);
163
+ } catch (cause) {
164
+ throw new ShopwaveAuthError("token_response_invalid", "Token response was not JSON", {
165
+ status: response.status,
166
+ body: text.slice(0, 2e3),
167
+ cause
168
+ });
169
+ }
170
+ }
171
+ return {
172
+ config,
173
+ buildLoginUrl({ state } = {}) {
174
+ const params = commonParams(config.redirectUri);
175
+ if (state) params.set("state", state);
176
+ return `${base()}${endpoints.login}?${params.toString()}`;
177
+ },
178
+ buildLogoutUrl({ redirectUri } = {}) {
179
+ const params = commonParams(redirectUri ?? config.postLogoutRedirectUri ?? config.redirectUri);
180
+ return `${base()}${endpoints.logout}?${params.toString()}`;
181
+ },
182
+ async exchangeCode(code) {
183
+ if (!code) throw new ShopwaveAuthError("token_exchange_failed", "Missing authorization code");
184
+ const token = tokenFromResponse(await postToken({
185
+ code,
186
+ redirect_uri: config.redirectUri,
187
+ client_id: config.clientId,
188
+ client_secret: config.clientSecret,
189
+ scope: config.scope ?? "application",
190
+ grant_type: "authorization_code"
191
+ }, "token_exchange_failed"));
192
+ if (!token) throw new ShopwaveAuthError("token_response_invalid", "Token response had no access_token");
193
+ return token;
194
+ },
195
+ async refreshToken(tokenOrRefreshToken) {
196
+ const previous = typeof tokenOrRefreshToken === "string" ? {
197
+ accessToken: "",
198
+ refreshToken: tokenOrRefreshToken,
199
+ tokenType: "OAuth"
200
+ } : tokenOrRefreshToken;
201
+ if (!previous.refreshToken) throw new ShopwaveAuthError("token_refresh_failed", "No refresh token available", { status: 400 });
202
+ const token = tokenFromResponse(await postToken({
203
+ refresh_token: previous.refreshToken,
204
+ redirect_uri: config.redirectUri,
205
+ client_id: config.clientId,
206
+ client_secret: config.clientSecret,
207
+ grant_type: "refresh_token"
208
+ }, "token_refresh_failed"), previous);
209
+ if (!token) throw new ShopwaveAuthError("token_response_invalid", "Refresh response had no access_token");
210
+ return token;
211
+ }
212
+ };
213
+ }
214
+
215
+ //#endregion
216
+ //#region src/server/returnTo.ts
217
+ /**
218
+ * Helpers for the round trip through the auth server.
219
+ */
220
+ /**
221
+ * Only allows same-origin relative paths ("/products?tab=1"). Anything that
222
+ * could send the user to another site after login — absolute URLs,
223
+ * protocol-relative "//evil.com", backslash tricks, control characters —
224
+ * falls back to `fallback`.
225
+ */
226
+ function sanitizeReturnTo(value, fallback = "/") {
227
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return fallback;
228
+ if (!/^\/(?![/\\])/.test(value)) return fallback;
229
+ if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
230
+ if (value.includes("\\")) return fallback;
231
+ return value;
232
+ }
233
+ /** Random, URL-safe value for the OAuth `state` parameter (128 bits, hex). */
234
+ function createState() {
235
+ const bytes = new Uint8Array(16);
236
+ globalThis.crypto.getRandomValues(bytes);
237
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
238
+ }
239
+ /** Constant-time string comparison for state values. */
240
+ function safeEqual(a, b) {
241
+ if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
242
+ let diff = 0;
243
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
244
+ return diff === 0;
245
+ }
246
+
247
+ //#endregion
248
+ exports.SHOPWAVE_TOKEN_EXPIRED_ERROR_ID = SHOPWAVE_TOKEN_EXPIRED_ERROR_ID;
249
+ exports.ShopwaveAuthError = ShopwaveAuthError;
250
+ exports.assertOAuthConfig = assertOAuthConfig;
251
+ exports.authorizationHeader = authorizationHeader;
252
+ exports.createShopwaveOAuth = createShopwaveOAuth;
253
+ exports.createState = createState;
254
+ exports.isExpiredTokenResponse = isExpiredTokenResponse;
255
+ exports.isTokenExpired = isTokenExpired;
256
+ exports.normalizeStoredToken = normalizeStoredToken;
257
+ exports.safeEqual = safeEqual;
258
+ exports.sanitizeReturnTo = sanitizeReturnTo;
259
+ exports.tokenFromResponse = tokenFromResponse;
260
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["body: URLSearchParams | FormData","response: Response"],"sources":["../../src/server/errors.ts","../../src/server/token.ts","../../src/server/oauth.ts","../../src/server/returnTo.ts"],"sourcesContent":["/**\n * Error thrown by the server-side auth helpers.\n *\n * `code` is stable and safe to branch on; `status` is the HTTP status returned\n * by the Shopwave auth server when there was one.\n */\nexport type ShopwaveAuthErrorCode =\n | \"config_invalid\"\n | \"token_exchange_failed\"\n | \"token_refresh_failed\"\n | \"token_response_invalid\"\n | \"network_error\";\n\nexport class ShopwaveAuthError extends Error {\n readonly code: ShopwaveAuthErrorCode;\n readonly status?: number;\n /** Raw response body from the auth server, if any. Never contains our secret. */\n readonly body?: string;\n\n constructor(\n code: ShopwaveAuthErrorCode,\n message: string,\n details: { status?: number; body?: string; cause?: unknown } = {}\n ) {\n super(message, details.cause !== undefined ? { cause: details.cause } : undefined);\n this.name = \"ShopwaveAuthError\";\n this.code = code;\n this.status = details.status;\n this.body = details.body;\n }\n\n /**\n * True when the auth server rejected the grant itself (bad/expired code or\n * refresh token) rather than failing for a transient reason. Callers should\n * treat the user as logged out.\n */\n get isInvalidGrant(): boolean {\n return (\n (this.code === \"token_exchange_failed\" || this.code === \"token_refresh_failed\") &&\n this.status !== undefined &&\n this.status >= 400 &&\n this.status < 500\n );\n }\n}\n","/**\n * Token model + helpers shared by every server-side integration.\n *\n * The Shopwave auth server returns (snake_case):\n * { access_token, refresh_token, token_type: \"OAuth\", expires_in: 43200 }\n *\n * We store a normalised, camelCase shape with an absolute expiry so any\n * request can decide whether the token is still usable without extra state.\n */\n\nexport interface ShopwaveToken {\n accessToken: string;\n /** Long-lived; the Shopwave server does not rotate it on refresh. */\n refreshToken?: string;\n /** Scheme used in the Authorization header. Shopwave uses \"OAuth\". */\n tokenType: string;\n /** Absolute expiry, epoch milliseconds. Undefined if the server didn't say. */\n expiresAt?: number;\n}\n\n/** Raw token response from `POST {authServer}/oauth/token`. */\nexport interface ShopwaveTokenResponse {\n access_token?: string;\n refresh_token?: string;\n token_type?: string;\n expires_in?: number | string;\n [key: string]: unknown;\n}\n\n/** Shopwave API error id meaning \"access token expired / invalid\". */\nexport const SHOPWAVE_TOKEN_EXPIRED_ERROR_ID = 908;\n\n/**\n * Converts a token-endpoint response into a {@link ShopwaveToken}.\n * Returns `null` when the response has no access token.\n *\n * @param previous the token being refreshed; its refresh token is kept when\n * the server doesn't send a new one.\n */\nexport function tokenFromResponse(\n response: ShopwaveTokenResponse,\n previous?: ShopwaveToken,\n now: number = Date.now()\n): ShopwaveToken | null {\n if (!response || typeof response.access_token !== \"string\" || !response.access_token) {\n return null;\n }\n\n const expiresIn = Number(response.expires_in);\n\n return {\n accessToken: response.access_token,\n refreshToken:\n (typeof response.refresh_token === \"string\" && response.refresh_token) ||\n previous?.refreshToken,\n tokenType: (typeof response.token_type === \"string\" && response.token_type) || \"OAuth\",\n expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? now + expiresIn * 1000 : undefined,\n };\n}\n\n/**\n * Reads a token from session storage. Accepts both the current shape and the\n * legacy raw response that older apps (e.g. AdminUI ≤ 0.1) stored directly in\n * the session, so existing sessions survive an upgrade.\n */\nexport function normalizeStoredToken(raw: unknown): ShopwaveToken | null {\n if (!raw || typeof raw !== \"object\") return null;\n const t = raw as Record<string, unknown>;\n\n if (typeof t.accessToken === \"string\" && t.accessToken) {\n return {\n accessToken: t.accessToken,\n refreshToken: typeof t.refreshToken === \"string\" ? t.refreshToken : undefined,\n tokenType: typeof t.tokenType === \"string\" && t.tokenType ? t.tokenType : \"OAuth\",\n expiresAt: typeof t.expiresAt === \"number\" ? t.expiresAt : undefined,\n };\n }\n\n if (typeof t.access_token === \"string\" && t.access_token) {\n // Legacy shape: we don't know when it was issued, so no expiresAt.\n // It will be refreshed reactively when the API reports it expired.\n return {\n accessToken: t.access_token,\n refreshToken: typeof t.refresh_token === \"string\" ? t.refresh_token : undefined,\n tokenType: typeof t.token_type === \"string\" && t.token_type ? t.token_type : \"OAuth\",\n };\n }\n\n return null;\n}\n\n/**\n * True when the token has passed its expiry (minus `skewSeconds`).\n * Tokens without a known expiry are treated as valid.\n *\n * Shopwave only issues a new access token once the old one has actually\n * expired, so the default skew is 0 — refreshing early just returns the same\n * token.\n */\nexport function isTokenExpired(\n token: ShopwaveToken,\n { skewSeconds = 0, now = Date.now() }: { skewSeconds?: number; now?: number } = {}\n): boolean {\n if (token.expiresAt === undefined) return false;\n return now >= token.expiresAt - skewSeconds * 1000;\n}\n\n/** `Authorization` header value, e.g. `OAuth 111ad…`. */\nexport function authorizationHeader(token: ShopwaveToken): string {\n return `${token.tokenType || \"OAuth\"} ${token.accessToken}`;\n}\n\n/**\n * True when a Shopwave API response means \"your access token is no longer\n * valid\": HTTP 401, or the API error 908 in the response envelope.\n * Use it to trigger a forced refresh + a single retry.\n */\nexport function isExpiredTokenResponse(status: number, body?: unknown): boolean {\n if (status === 401) return true;\n const errors = (body as { api?: { message?: { errors?: Record<string, { id?: number }> } } })\n ?.api?.message?.errors;\n if (!errors || typeof errors !== \"object\") return false;\n return Object.entries(errors).some(\n ([key, value]) =>\n key === String(SHOPWAVE_TOKEN_EXPIRED_ERROR_ID) ||\n Number(value?.id) === SHOPWAVE_TOKEN_EXPIRED_ERROR_ID\n );\n}\n","import { ShopwaveAuthError } from \"./errors\";\nimport { tokenFromResponse, type ShopwaveToken, type ShopwaveTokenResponse } from \"./token\";\n\n/**\n * Per-app OAuth settings. Everything that differs between Shopwave apps lives\n * here; the flow itself is identical for all of them.\n */\nexport interface ShopwaveOAuthConfig {\n /** e.g. `https://secure.merchantstack.com` (no trailing slash needed). */\n authServerUrl: string;\n clientId: string;\n /** Server-side only. Never expose it to the browser. */\n clientSecret: string;\n /**\n * The callback URL registered for this client on the auth server, e.g.\n * `https://admin.example.com/auth`. Must match exactly.\n */\n redirectUri: string;\n /** Where the auth server sends the user after logout. Defaults to `redirectUri`. */\n postLogoutRedirectUri?: string;\n /** Defaults to `\"application\"`. */\n scope?: string;\n /** Defaults to `\"online\"`. */\n accessType?: string;\n /**\n * Body encoding for `POST /oauth/token`. Defaults to standard\n * `application/x-www-form-urlencoded`; `\"multipart\"` sends `FormData`.\n */\n tokenRequestFormat?: \"urlencoded\" | \"multipart\";\n /** Auth-server paths. Defaults match Shopwave: `/login`, `/oauth/token`, `/logout`. */\n endpoints?: { login?: string; token?: string; logout?: string };\n /** Custom fetch (tests, proxies). Defaults to global `fetch`. */\n fetch?: typeof fetch;\n}\n\nexport interface ShopwaveOAuthClient {\n readonly config: Readonly<ShopwaveOAuthConfig>;\n /** URL to send the browser to so the user can sign in. */\n buildLoginUrl(params?: { state?: string }): string;\n /** URL to send the browser to so the auth server ends its own session. */\n buildLogoutUrl(params?: { redirectUri?: string }): string;\n /** Exchanges the `?code=` from the callback for tokens (server-to-server). */\n exchangeCode(code: string): Promise<ShopwaveToken>;\n /**\n * Gets a fresh access token. Shopwave keeps the same refresh token, so the\n * returned token carries the previous refresh token when none is sent back.\n */\n refreshToken(token: ShopwaveToken | string): Promise<ShopwaveToken>;\n}\n\nconst REQUIRED_KEYS = [\"authServerUrl\", \"clientId\", \"clientSecret\", \"redirectUri\"] as const;\n\n/** Throws a readable error when required settings are missing. */\nexport function assertOAuthConfig(config: Partial<ShopwaveOAuthConfig>): asserts config is ShopwaveOAuthConfig {\n const missing = REQUIRED_KEYS.filter((k) => !config?.[k]);\n if (missing.length > 0) {\n throw new ShopwaveAuthError(\n \"config_invalid\",\n `Shopwave auth is missing required settings: ${missing.join(\", \")}`\n );\n }\n}\n\nfunction trimSlash(url: string): string {\n return url.replace(/\\/+$/, \"\");\n}\n\n/**\n * Creates a framework-agnostic Shopwave OAuth client (authorization-code flow\n * with a client secret — the Shopwave auth server does not support PKCE, so\n * this must run on a server).\n *\n * Config is validated lazily on first use, so creating the client at module\n * scope doesn't break builds where env vars aren't present.\n */\nexport function createShopwaveOAuth(config: ShopwaveOAuthConfig): ShopwaveOAuthClient {\n const endpoints = {\n login: config.endpoints?.login ?? \"/login\",\n token: config.endpoints?.token ?? \"/oauth/token\",\n logout: config.endpoints?.logout ?? \"/logout\",\n };\n\n const base = () => {\n assertOAuthConfig(config);\n return trimSlash(config.authServerUrl);\n };\n\n const commonParams = (redirectUri: string) =>\n new URLSearchParams({\n access_type: config.accessType ?? \"online\",\n redirect_uri: redirectUri,\n response_type: \"code\",\n client_id: config.clientId,\n scope: config.scope ?? \"application\",\n });\n\n async function postToken(\n fields: Record<string, string>,\n errorCode: \"token_exchange_failed\" | \"token_refresh_failed\"\n ): Promise<ShopwaveTokenResponse> {\n const url = `${base()}${endpoints.token}`;\n const doFetch = config.fetch ?? globalThis.fetch;\n\n let body: URLSearchParams | FormData;\n if (config.tokenRequestFormat === \"multipart\") {\n const fd = new FormData();\n for (const [k, v] of Object.entries(fields)) fd.append(k, v);\n body = fd;\n } else {\n body = new URLSearchParams(fields);\n }\n\n let response: Response;\n try {\n response = await doFetch(url, {\n method: \"POST\",\n body,\n headers: { Accept: \"application/json\" },\n cache: \"no-store\",\n } as RequestInit);\n } catch (cause) {\n throw new ShopwaveAuthError(\"network_error\", `Could not reach the Shopwave auth server`, { cause });\n }\n\n const text = await response.text();\n if (!response.ok) {\n throw new ShopwaveAuthError(\n errorCode,\n `Shopwave auth server returned ${response.status} for ${fields.grant_type}`,\n { status: response.status, body: text.slice(0, 2000) }\n );\n }\n\n try {\n return JSON.parse(text) as ShopwaveTokenResponse;\n } catch (cause) {\n throw new ShopwaveAuthError(\"token_response_invalid\", \"Token response was not JSON\", {\n status: response.status,\n body: text.slice(0, 2000),\n cause,\n });\n }\n }\n\n return {\n config,\n\n buildLoginUrl({ state } = {}) {\n const params = commonParams(config.redirectUri);\n if (state) params.set(\"state\", state);\n return `${base()}${endpoints.login}?${params.toString()}`;\n },\n\n buildLogoutUrl({ redirectUri } = {}) {\n const params = commonParams(redirectUri ?? config.postLogoutRedirectUri ?? config.redirectUri);\n return `${base()}${endpoints.logout}?${params.toString()}`;\n },\n\n async exchangeCode(code) {\n if (!code) {\n throw new ShopwaveAuthError(\"token_exchange_failed\", \"Missing authorization code\");\n }\n const json = await postToken(\n {\n code,\n redirect_uri: config.redirectUri,\n client_id: config.clientId,\n client_secret: config.clientSecret,\n scope: config.scope ?? \"application\",\n grant_type: \"authorization_code\",\n },\n \"token_exchange_failed\"\n );\n const token = tokenFromResponse(json);\n if (!token) {\n throw new ShopwaveAuthError(\"token_response_invalid\", \"Token response had no access_token\");\n }\n return token;\n },\n\n async refreshToken(tokenOrRefreshToken) {\n const previous =\n typeof tokenOrRefreshToken === \"string\"\n ? ({ accessToken: \"\", refreshToken: tokenOrRefreshToken, tokenType: \"OAuth\" } as ShopwaveToken)\n : tokenOrRefreshToken;\n\n if (!previous.refreshToken) {\n throw new ShopwaveAuthError(\"token_refresh_failed\", \"No refresh token available\", { status: 400 });\n }\n\n const json = await postToken(\n {\n refresh_token: previous.refreshToken,\n redirect_uri: config.redirectUri,\n client_id: config.clientId,\n client_secret: config.clientSecret,\n grant_type: \"refresh_token\",\n },\n \"token_refresh_failed\"\n );\n const token = tokenFromResponse(json, previous);\n if (!token) {\n throw new ShopwaveAuthError(\"token_response_invalid\", \"Refresh response had no access_token\");\n }\n return token;\n },\n };\n}\n","/**\n * Helpers for the round trip through the auth server.\n */\n\n/**\n * Only allows same-origin relative paths (\"/products?tab=1\"). Anything that\n * could send the user to another site after login — absolute URLs,\n * protocol-relative \"//evil.com\", backslash tricks, control characters —\n * falls back to `fallback`.\n */\nexport function sanitizeReturnTo(value: unknown, fallback = \"/\"): string {\n if (typeof value !== \"string\" || value.length === 0 || value.length > 2048) {\n return fallback;\n }\n // Must start with a single \"/\" not followed by \"/\" or \"\\\".\n if (!/^\\/(?![/\\\\])/.test(value)) return fallback;\n // No control characters (CR/LF header injection, tabs, NUL, …).\n // eslint-disable-next-line no-control-regex\n if (/[\\u0000-\\u001f\\u007f]/.test(value)) return fallback;\n if (value.includes(\"\\\\\")) return fallback;\n return value;\n}\n\n/** Random, URL-safe value for the OAuth `state` parameter (128 bits, hex). */\nexport function createState(): string {\n const bytes = new Uint8Array(16);\n globalThis.crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Constant-time string comparison for state values. */\nexport function safeEqual(a: string, b: string): boolean {\n if (typeof a !== \"string\" || typeof b !== \"string\" || a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return diff === 0;\n}\n"],"mappings":"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"}
@@ -0,0 +1,177 @@
1
+ //#region src/server/token.d.ts
2
+ /**
3
+ * Token model + helpers shared by every server-side integration.
4
+ *
5
+ * The Shopwave auth server returns (snake_case):
6
+ * { access_token, refresh_token, token_type: "OAuth", expires_in: 43200 }
7
+ *
8
+ * We store a normalised, camelCase shape with an absolute expiry so any
9
+ * request can decide whether the token is still usable without extra state.
10
+ */
11
+ interface ShopwaveToken {
12
+ accessToken: string;
13
+ /** Long-lived; the Shopwave server does not rotate it on refresh. */
14
+ refreshToken?: string;
15
+ /** Scheme used in the Authorization header. Shopwave uses "OAuth". */
16
+ tokenType: string;
17
+ /** Absolute expiry, epoch milliseconds. Undefined if the server didn't say. */
18
+ expiresAt?: number;
19
+ }
20
+ /** Raw token response from `POST {authServer}/oauth/token`. */
21
+ interface ShopwaveTokenResponse {
22
+ access_token?: string;
23
+ refresh_token?: string;
24
+ token_type?: string;
25
+ expires_in?: number | string;
26
+ [key: string]: unknown;
27
+ }
28
+ /** Shopwave API error id meaning "access token expired / invalid". */
29
+ declare const SHOPWAVE_TOKEN_EXPIRED_ERROR_ID = 908;
30
+ /**
31
+ * Converts a token-endpoint response into a {@link ShopwaveToken}.
32
+ * Returns `null` when the response has no access token.
33
+ *
34
+ * @param previous the token being refreshed; its refresh token is kept when
35
+ * the server doesn't send a new one.
36
+ */
37
+ declare function tokenFromResponse(response: ShopwaveTokenResponse, previous?: ShopwaveToken, now?: number): ShopwaveToken | null;
38
+ /**
39
+ * Reads a token from session storage. Accepts both the current shape and the
40
+ * legacy raw response that older apps (e.g. AdminUI ≤ 0.1) stored directly in
41
+ * the session, so existing sessions survive an upgrade.
42
+ */
43
+ declare function normalizeStoredToken(raw: unknown): ShopwaveToken | null;
44
+ /**
45
+ * True when the token has passed its expiry (minus `skewSeconds`).
46
+ * Tokens without a known expiry are treated as valid.
47
+ *
48
+ * Shopwave only issues a new access token once the old one has actually
49
+ * expired, so the default skew is 0 — refreshing early just returns the same
50
+ * token.
51
+ */
52
+ declare function isTokenExpired(token: ShopwaveToken, {
53
+ skewSeconds,
54
+ now
55
+ }?: {
56
+ skewSeconds?: number;
57
+ now?: number;
58
+ }): boolean;
59
+ /** `Authorization` header value, e.g. `OAuth 111ad…`. */
60
+ declare function authorizationHeader(token: ShopwaveToken): string;
61
+ /**
62
+ * True when a Shopwave API response means "your access token is no longer
63
+ * valid": HTTP 401, or the API error 908 in the response envelope.
64
+ * Use it to trigger a forced refresh + a single retry.
65
+ */
66
+ declare function isExpiredTokenResponse(status: number, body?: unknown): boolean;
67
+ //#endregion
68
+ //#region src/server/oauth.d.ts
69
+ /**
70
+ * Per-app OAuth settings. Everything that differs between Shopwave apps lives
71
+ * here; the flow itself is identical for all of them.
72
+ */
73
+ interface ShopwaveOAuthConfig {
74
+ /** e.g. `https://secure.merchantstack.com` (no trailing slash needed). */
75
+ authServerUrl: string;
76
+ clientId: string;
77
+ /** Server-side only. Never expose it to the browser. */
78
+ clientSecret: string;
79
+ /**
80
+ * The callback URL registered for this client on the auth server, e.g.
81
+ * `https://admin.example.com/auth`. Must match exactly.
82
+ */
83
+ redirectUri: string;
84
+ /** Where the auth server sends the user after logout. Defaults to `redirectUri`. */
85
+ postLogoutRedirectUri?: string;
86
+ /** Defaults to `"application"`. */
87
+ scope?: string;
88
+ /** Defaults to `"online"`. */
89
+ accessType?: string;
90
+ /**
91
+ * Body encoding for `POST /oauth/token`. Defaults to standard
92
+ * `application/x-www-form-urlencoded`; `"multipart"` sends `FormData`.
93
+ */
94
+ tokenRequestFormat?: "urlencoded" | "multipart";
95
+ /** Auth-server paths. Defaults match Shopwave: `/login`, `/oauth/token`, `/logout`. */
96
+ endpoints?: {
97
+ login?: string;
98
+ token?: string;
99
+ logout?: string;
100
+ };
101
+ /** Custom fetch (tests, proxies). Defaults to global `fetch`. */
102
+ fetch?: typeof fetch;
103
+ }
104
+ interface ShopwaveOAuthClient {
105
+ readonly config: Readonly<ShopwaveOAuthConfig>;
106
+ /** URL to send the browser to so the user can sign in. */
107
+ buildLoginUrl(params?: {
108
+ state?: string;
109
+ }): string;
110
+ /** URL to send the browser to so the auth server ends its own session. */
111
+ buildLogoutUrl(params?: {
112
+ redirectUri?: string;
113
+ }): string;
114
+ /** Exchanges the `?code=` from the callback for tokens (server-to-server). */
115
+ exchangeCode(code: string): Promise<ShopwaveToken>;
116
+ /**
117
+ * Gets a fresh access token. Shopwave keeps the same refresh token, so the
118
+ * returned token carries the previous refresh token when none is sent back.
119
+ */
120
+ refreshToken(token: ShopwaveToken | string): Promise<ShopwaveToken>;
121
+ }
122
+ /** Throws a readable error when required settings are missing. */
123
+ declare function assertOAuthConfig(config: Partial<ShopwaveOAuthConfig>): asserts config is ShopwaveOAuthConfig;
124
+ /**
125
+ * Creates a framework-agnostic Shopwave OAuth client (authorization-code flow
126
+ * with a client secret — the Shopwave auth server does not support PKCE, so
127
+ * this must run on a server).
128
+ *
129
+ * Config is validated lazily on first use, so creating the client at module
130
+ * scope doesn't break builds where env vars aren't present.
131
+ */
132
+ declare function createShopwaveOAuth(config: ShopwaveOAuthConfig): ShopwaveOAuthClient;
133
+ //#endregion
134
+ //#region src/server/returnTo.d.ts
135
+ /**
136
+ * Helpers for the round trip through the auth server.
137
+ */
138
+ /**
139
+ * Only allows same-origin relative paths ("/products?tab=1"). Anything that
140
+ * could send the user to another site after login — absolute URLs,
141
+ * protocol-relative "//evil.com", backslash tricks, control characters —
142
+ * falls back to `fallback`.
143
+ */
144
+ declare function sanitizeReturnTo(value: unknown, fallback?: string): string;
145
+ /** Random, URL-safe value for the OAuth `state` parameter (128 bits, hex). */
146
+ declare function createState(): string;
147
+ /** Constant-time string comparison for state values. */
148
+ declare function safeEqual(a: string, b: string): boolean;
149
+ //#endregion
150
+ //#region src/server/errors.d.ts
151
+ /**
152
+ * Error thrown by the server-side auth helpers.
153
+ *
154
+ * `code` is stable and safe to branch on; `status` is the HTTP status returned
155
+ * by the Shopwave auth server when there was one.
156
+ */
157
+ type ShopwaveAuthErrorCode = "config_invalid" | "token_exchange_failed" | "token_refresh_failed" | "token_response_invalid" | "network_error";
158
+ declare class ShopwaveAuthError extends Error {
159
+ readonly code: ShopwaveAuthErrorCode;
160
+ readonly status?: number;
161
+ /** Raw response body from the auth server, if any. Never contains our secret. */
162
+ readonly body?: string;
163
+ constructor(code: ShopwaveAuthErrorCode, message: string, details?: {
164
+ status?: number;
165
+ body?: string;
166
+ cause?: unknown;
167
+ });
168
+ /**
169
+ * True when the auth server rejected the grant itself (bad/expired code or
170
+ * refresh token) rather than failing for a transient reason. Callers should
171
+ * treat the user as logged out.
172
+ */
173
+ get isInvalidGrant(): boolean;
174
+ }
175
+ //#endregion
176
+ export { SHOPWAVE_TOKEN_EXPIRED_ERROR_ID, ShopwaveAuthError, type ShopwaveAuthErrorCode, type ShopwaveOAuthClient, type ShopwaveOAuthConfig, type ShopwaveToken, type ShopwaveTokenResponse, assertOAuthConfig, authorizationHeader, createShopwaveOAuth, createState, isExpiredTokenResponse, isTokenExpired, normalizeStoredToken, safeEqual, sanitizeReturnTo, tokenFromResponse };
177
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1,177 @@
1
+ //#region src/server/token.d.ts
2
+ /**
3
+ * Token model + helpers shared by every server-side integration.
4
+ *
5
+ * The Shopwave auth server returns (snake_case):
6
+ * { access_token, refresh_token, token_type: "OAuth", expires_in: 43200 }
7
+ *
8
+ * We store a normalised, camelCase shape with an absolute expiry so any
9
+ * request can decide whether the token is still usable without extra state.
10
+ */
11
+ interface ShopwaveToken {
12
+ accessToken: string;
13
+ /** Long-lived; the Shopwave server does not rotate it on refresh. */
14
+ refreshToken?: string;
15
+ /** Scheme used in the Authorization header. Shopwave uses "OAuth". */
16
+ tokenType: string;
17
+ /** Absolute expiry, epoch milliseconds. Undefined if the server didn't say. */
18
+ expiresAt?: number;
19
+ }
20
+ /** Raw token response from `POST {authServer}/oauth/token`. */
21
+ interface ShopwaveTokenResponse {
22
+ access_token?: string;
23
+ refresh_token?: string;
24
+ token_type?: string;
25
+ expires_in?: number | string;
26
+ [key: string]: unknown;
27
+ }
28
+ /** Shopwave API error id meaning "access token expired / invalid". */
29
+ declare const SHOPWAVE_TOKEN_EXPIRED_ERROR_ID = 908;
30
+ /**
31
+ * Converts a token-endpoint response into a {@link ShopwaveToken}.
32
+ * Returns `null` when the response has no access token.
33
+ *
34
+ * @param previous the token being refreshed; its refresh token is kept when
35
+ * the server doesn't send a new one.
36
+ */
37
+ declare function tokenFromResponse(response: ShopwaveTokenResponse, previous?: ShopwaveToken, now?: number): ShopwaveToken | null;
38
+ /**
39
+ * Reads a token from session storage. Accepts both the current shape and the
40
+ * legacy raw response that older apps (e.g. AdminUI ≤ 0.1) stored directly in
41
+ * the session, so existing sessions survive an upgrade.
42
+ */
43
+ declare function normalizeStoredToken(raw: unknown): ShopwaveToken | null;
44
+ /**
45
+ * True when the token has passed its expiry (minus `skewSeconds`).
46
+ * Tokens without a known expiry are treated as valid.
47
+ *
48
+ * Shopwave only issues a new access token once the old one has actually
49
+ * expired, so the default skew is 0 — refreshing early just returns the same
50
+ * token.
51
+ */
52
+ declare function isTokenExpired(token: ShopwaveToken, {
53
+ skewSeconds,
54
+ now
55
+ }?: {
56
+ skewSeconds?: number;
57
+ now?: number;
58
+ }): boolean;
59
+ /** `Authorization` header value, e.g. `OAuth 111ad…`. */
60
+ declare function authorizationHeader(token: ShopwaveToken): string;
61
+ /**
62
+ * True when a Shopwave API response means "your access token is no longer
63
+ * valid": HTTP 401, or the API error 908 in the response envelope.
64
+ * Use it to trigger a forced refresh + a single retry.
65
+ */
66
+ declare function isExpiredTokenResponse(status: number, body?: unknown): boolean;
67
+ //#endregion
68
+ //#region src/server/oauth.d.ts
69
+ /**
70
+ * Per-app OAuth settings. Everything that differs between Shopwave apps lives
71
+ * here; the flow itself is identical for all of them.
72
+ */
73
+ interface ShopwaveOAuthConfig {
74
+ /** e.g. `https://secure.merchantstack.com` (no trailing slash needed). */
75
+ authServerUrl: string;
76
+ clientId: string;
77
+ /** Server-side only. Never expose it to the browser. */
78
+ clientSecret: string;
79
+ /**
80
+ * The callback URL registered for this client on the auth server, e.g.
81
+ * `https://admin.example.com/auth`. Must match exactly.
82
+ */
83
+ redirectUri: string;
84
+ /** Where the auth server sends the user after logout. Defaults to `redirectUri`. */
85
+ postLogoutRedirectUri?: string;
86
+ /** Defaults to `"application"`. */
87
+ scope?: string;
88
+ /** Defaults to `"online"`. */
89
+ accessType?: string;
90
+ /**
91
+ * Body encoding for `POST /oauth/token`. Defaults to standard
92
+ * `application/x-www-form-urlencoded`; `"multipart"` sends `FormData`.
93
+ */
94
+ tokenRequestFormat?: "urlencoded" | "multipart";
95
+ /** Auth-server paths. Defaults match Shopwave: `/login`, `/oauth/token`, `/logout`. */
96
+ endpoints?: {
97
+ login?: string;
98
+ token?: string;
99
+ logout?: string;
100
+ };
101
+ /** Custom fetch (tests, proxies). Defaults to global `fetch`. */
102
+ fetch?: typeof fetch;
103
+ }
104
+ interface ShopwaveOAuthClient {
105
+ readonly config: Readonly<ShopwaveOAuthConfig>;
106
+ /** URL to send the browser to so the user can sign in. */
107
+ buildLoginUrl(params?: {
108
+ state?: string;
109
+ }): string;
110
+ /** URL to send the browser to so the auth server ends its own session. */
111
+ buildLogoutUrl(params?: {
112
+ redirectUri?: string;
113
+ }): string;
114
+ /** Exchanges the `?code=` from the callback for tokens (server-to-server). */
115
+ exchangeCode(code: string): Promise<ShopwaveToken>;
116
+ /**
117
+ * Gets a fresh access token. Shopwave keeps the same refresh token, so the
118
+ * returned token carries the previous refresh token when none is sent back.
119
+ */
120
+ refreshToken(token: ShopwaveToken | string): Promise<ShopwaveToken>;
121
+ }
122
+ /** Throws a readable error when required settings are missing. */
123
+ declare function assertOAuthConfig(config: Partial<ShopwaveOAuthConfig>): asserts config is ShopwaveOAuthConfig;
124
+ /**
125
+ * Creates a framework-agnostic Shopwave OAuth client (authorization-code flow
126
+ * with a client secret — the Shopwave auth server does not support PKCE, so
127
+ * this must run on a server).
128
+ *
129
+ * Config is validated lazily on first use, so creating the client at module
130
+ * scope doesn't break builds where env vars aren't present.
131
+ */
132
+ declare function createShopwaveOAuth(config: ShopwaveOAuthConfig): ShopwaveOAuthClient;
133
+ //#endregion
134
+ //#region src/server/returnTo.d.ts
135
+ /**
136
+ * Helpers for the round trip through the auth server.
137
+ */
138
+ /**
139
+ * Only allows same-origin relative paths ("/products?tab=1"). Anything that
140
+ * could send the user to another site after login — absolute URLs,
141
+ * protocol-relative "//evil.com", backslash tricks, control characters —
142
+ * falls back to `fallback`.
143
+ */
144
+ declare function sanitizeReturnTo(value: unknown, fallback?: string): string;
145
+ /** Random, URL-safe value for the OAuth `state` parameter (128 bits, hex). */
146
+ declare function createState(): string;
147
+ /** Constant-time string comparison for state values. */
148
+ declare function safeEqual(a: string, b: string): boolean;
149
+ //#endregion
150
+ //#region src/server/errors.d.ts
151
+ /**
152
+ * Error thrown by the server-side auth helpers.
153
+ *
154
+ * `code` is stable and safe to branch on; `status` is the HTTP status returned
155
+ * by the Shopwave auth server when there was one.
156
+ */
157
+ type ShopwaveAuthErrorCode = "config_invalid" | "token_exchange_failed" | "token_refresh_failed" | "token_response_invalid" | "network_error";
158
+ declare class ShopwaveAuthError extends Error {
159
+ readonly code: ShopwaveAuthErrorCode;
160
+ readonly status?: number;
161
+ /** Raw response body from the auth server, if any. Never contains our secret. */
162
+ readonly body?: string;
163
+ constructor(code: ShopwaveAuthErrorCode, message: string, details?: {
164
+ status?: number;
165
+ body?: string;
166
+ cause?: unknown;
167
+ });
168
+ /**
169
+ * True when the auth server rejected the grant itself (bad/expired code or
170
+ * refresh token) rather than failing for a transient reason. Callers should
171
+ * treat the user as logged out.
172
+ */
173
+ get isInvalidGrant(): boolean;
174
+ }
175
+ //#endregion
176
+ export { SHOPWAVE_TOKEN_EXPIRED_ERROR_ID, ShopwaveAuthError, type ShopwaveAuthErrorCode, type ShopwaveOAuthClient, type ShopwaveOAuthConfig, type ShopwaveToken, type ShopwaveTokenResponse, assertOAuthConfig, authorizationHeader, createShopwaveOAuth, createState, isExpiredTokenResponse, isTokenExpired, normalizeStoredToken, safeEqual, sanitizeReturnTo, tokenFromResponse };
177
+ //# sourceMappingURL=index.d.ts.map