@asstio-dev/storefront 0.28.0 → 0.31.1
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 +113 -3
- package/dist/account/browser.d.ts +22 -0
- package/dist/account/browser.js +19 -0
- package/dist/account/cart.d.ts +24 -0
- package/dist/account/cart.js +51 -0
- package/dist/account/cookies.d.ts +64 -0
- package/dist/account/cookies.js +56 -0
- package/dist/account/index.d.ts +100 -0
- package/dist/account/index.js +114 -0
- package/dist/account/next.d.ts +82 -0
- package/dist/account/next.js +161 -0
- package/dist/account/security.d.ts +30 -0
- package/dist/account/security.js +85 -0
- package/dist/checkout/server.d.ts +16 -6
- package/dist/checkout/server.js +12 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/schema.d.ts +1232 -285
- package/dist/seo/sitemap.d.ts +44 -0
- package/dist/seo/sitemap.js +91 -0
- package/package.json +30 -2
- package/src/schema.d.ts +1232 -285
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type AccountResult, type PrincipalDto, type ShopperClient } from "./index.js";
|
|
2
|
+
import { type CopyBrowserCartResult } from "./cart.js";
|
|
3
|
+
export interface AccountActionConfig {
|
|
4
|
+
/** e.g. https://api.storefront.asstio.com */
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
/** The site's server key (`sfk_srv_…`), from the server's environment. */
|
|
7
|
+
serverKey: string;
|
|
8
|
+
/** Market code, e.g. "se". */
|
|
9
|
+
market: string;
|
|
10
|
+
acceptLanguage?: string;
|
|
11
|
+
/** The frontend's own origin, e.g. https://shop.example: decides `Secure` and the `__Host-` prefix (dropped on http://localhost). */
|
|
12
|
+
origin: string;
|
|
13
|
+
/** How many proxies append to `X-Forwarded-For` in front of this server (`shopperIpFrom`). */
|
|
14
|
+
trustedHops: number;
|
|
15
|
+
/**
|
|
16
|
+
* The name of the frontend's cart cookie, if any: sent as `X-Cart-Token` at verify, set to the cart a verify or a switch hands
|
|
17
|
+
* back when it is another one and cleared when a successful one hands back none it may keep (spec §7.8), read as the saved cart
|
|
18
|
+
* by `copyBrowserCartAction`, dropped at sign-out (spec §9.2).
|
|
19
|
+
*/
|
|
20
|
+
cartCookie?: string;
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
/** What a sign-in start shows the page: no binding (it is in the cookie). */
|
|
24
|
+
export interface SignInStartedView {
|
|
25
|
+
challengeId: string;
|
|
26
|
+
expiresAt: string;
|
|
27
|
+
codeLength: number;
|
|
28
|
+
}
|
|
29
|
+
/** What a sign-in or switch shows the page: who the shopper now is. No token. */
|
|
30
|
+
export interface SignedInView {
|
|
31
|
+
principal: PrincipalDto;
|
|
32
|
+
principals: PrincipalDto[];
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
/**
|
|
35
|
+
* Where to send the shopper now (spec §7.8): the safe path the sign-in started from, also when the mailed link is opened in
|
|
36
|
+
* another browser. Null when the start gave none, and always null after a switch. Redirect to it (or your default) yourself.
|
|
37
|
+
*/
|
|
38
|
+
returnPath: string | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Starts a sign-in: `POST /v1/auth/sign-in`, then the binding (with the challenge id) goes into the sign-in cookie for 10
|
|
42
|
+
* minutes. An unsafe `returnPath` is dropped here as the API would. The result carries the challenge id for the code form.
|
|
43
|
+
*/
|
|
44
|
+
export declare function startSignInAction(config: AccountActionConfig, email: string, returnPath?: string | null): Promise<AccountResult<SignInStartedView>>;
|
|
45
|
+
export interface VerifySignInActionInput {
|
|
46
|
+
/** For a code: defaults to the challenge the sign-in cookie remembers. Never used with a link, which carries its own (R12). */
|
|
47
|
+
challengeId?: string | null;
|
|
48
|
+
code?: string | null;
|
|
49
|
+
link?: string | null;
|
|
50
|
+
confirm?: boolean | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Verifies a code or link with the binding from the sign-in cookie: on success the session cookie is set (to the session's
|
|
54
|
+
* absolute expiry) and the sign-in cookie cleared. `409 SIGN_IN_CONFIRM_REQUIRED` comes back as a problem with
|
|
55
|
+
* `maskedEmail` and `siteName`; ask the shopper, then call again with `confirm: true`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function verifySignInAction(config: AccountActionConfig, input: VerifySignInActionInput): Promise<AccountResult<SignedInView>>;
|
|
58
|
+
/**
|
|
59
|
+
* Switches the principal: the rotated token replaces the session cookie at once (the old one serves GETs for 30 s only).
|
|
60
|
+
* A failure (also `SESSION_INVALID`) is returned and leaves the cookie alone: a switch that lost to a concurrent one from
|
|
61
|
+
* another tab would otherwise delete the winner's fresh cookie. Refresh the session state; only `signOutAction` clears.
|
|
62
|
+
*/
|
|
63
|
+
export declare function switchPrincipalAction(config: AccountActionConfig, principal: string): Promise<AccountResult<SignedInView>>;
|
|
64
|
+
/**
|
|
65
|
+
* Signs out (`everywhere` ends every session of the account). The session cookie, the sign-in cookie, the cart cookie and the
|
|
66
|
+
* browser-cart cookie are cleared whatever the API answered, and also when it cannot be reached (`{ ok: false, status: 0, problem.code: "NETWORK" }`)
|
|
67
|
+
* — a session already gone is still a sign-out for this browser.
|
|
68
|
+
*/
|
|
69
|
+
export declare function signOutAction(config: AccountActionConfig, opts?: {
|
|
70
|
+
everywhere?: boolean;
|
|
71
|
+
}): Promise<AccountResult<void>>;
|
|
72
|
+
/**
|
|
73
|
+
* "Lägg till i sparad varukorg" (spec §9.2): copies the browser cart (the browser-cart cookie) into the saved cart (the cart
|
|
74
|
+
* cookie, `config.cartCookie`) with `copyBrowserCart`, under the session. The browser-cart cookie is cleared only when no line
|
|
75
|
+
* failed: a retry reuses the same idempotency keys, so lines already copied are no-ops. The result lists line ids, never a token.
|
|
76
|
+
*/
|
|
77
|
+
export declare function copyBrowserCartAction(config: AccountActionConfig): Promise<AccountResult<CopyBrowserCartResult>>;
|
|
78
|
+
/**
|
|
79
|
+
* A client for a server component or route handler of this request, with the session from the cookie (or none). It never
|
|
80
|
+
* sets a cookie: a render cannot.
|
|
81
|
+
*/
|
|
82
|
+
export declare function shopperClient(config: AccountActionConfig): Promise<ShopperClient>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asstio-dev/storefront/account/next` — Next.js (15+) server-action wrappers for sign-in and sessions (accounts spec §7.1,
|
|
3
|
+
* §7.3, §11.4): the correct default a frontend copies. Call them from your own `"use server"` actions. Each one builds a
|
|
4
|
+
* client for this request only, reads the session and binding from the cookies, sets and clears the cookies through
|
|
5
|
+
* `next/headers`, and returns a result that never carries the session token, the binding or a cart token — those live in
|
|
6
|
+
* HttpOnly cookies only, so an action result rendered or logged in the browser holds no secret.
|
|
7
|
+
*
|
|
8
|
+
* Next.js sets cookies only in server actions, route handlers and middleware. A server-component render that meets
|
|
9
|
+
* `SESSION_INVALID` renders signed-out and leaves clearing the cookie to the next action (`signOutAction` clears it).
|
|
10
|
+
*/
|
|
11
|
+
// "next/headers.js", not "next/headers": next has no exports map, so plain Node ESM needs the file name; bundlers resolve both.
|
|
12
|
+
import { cookies, headers } from "next/headers.js";
|
|
13
|
+
import { createShopperClient, localFailure, signOut, startSignIn, switchPrincipal, verifySignIn, } from "./index.js";
|
|
14
|
+
import { copyBrowserCart, CopyBrowserCartError } from "./cart.js";
|
|
15
|
+
import { browserCartCookie, cartCookie, cookieSecurity, readSignInCookie, sessionCookie, signInCookie, } from "./cookies.js";
|
|
16
|
+
import { safeReturnPath, shopperIpFrom } from "./security.js";
|
|
17
|
+
async function context(config) {
|
|
18
|
+
const jar = await cookies();
|
|
19
|
+
const security = cookieSecurity(config.origin);
|
|
20
|
+
const shopperIp = shopperIpFrom(await headers(), { trustedHops: config.trustedHops });
|
|
21
|
+
const client = (session) => createShopperClient({
|
|
22
|
+
baseUrl: config.baseUrl, serverKey: config.serverKey, market: config.market, acceptLanguage: config.acceptLanguage,
|
|
23
|
+
session, shopperIp, fetch: config.fetch,
|
|
24
|
+
});
|
|
25
|
+
return { jar, security, client };
|
|
26
|
+
}
|
|
27
|
+
const set = (jar, spec) => jar.set(spec);
|
|
28
|
+
const read = (jar, name) => jar.get(name)?.value || null;
|
|
29
|
+
const session = (jar, security) => read(jar, sessionCookie.cookieName(security));
|
|
30
|
+
function signedInView(data) {
|
|
31
|
+
// The API only stores a safe path; checked again so a view never carries anything else.
|
|
32
|
+
return { principal: data.principal, principals: data.principals, expiresAt: data.session.expiresAt, returnPath: safeReturnPath(data.returnPath, "") || null };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Spec §7.8, §9.2 (the truth table), only after a successful verify or switch: the answer's `cart` becomes the cart cookie (the
|
|
36
|
+
* saved cart B, the adopted cart A, or the new principal's cart). With a saved cart, the browser cart A beside it goes to the
|
|
37
|
+
* browser-cart cookie for "Lägg till i sparad varukorg". Without `cart`, a verify keeps the anonymous cart only when
|
|
38
|
+
* `browserCart` names it (A has had an attempt and stays anonymous); otherwise the cookie held no usable cart for this principal
|
|
39
|
+
* — no browser cart at verify, or the previous principal's cart at a switch — and is cleared.
|
|
40
|
+
*/
|
|
41
|
+
function keepCarts(jar, config, security, data, current) {
|
|
42
|
+
const keep = data.cart?.token ?? data.browserCart?.token ?? null;
|
|
43
|
+
if (config.cartCookie && keep && keep !== current)
|
|
44
|
+
set(jar, cartCookie(config.cartCookie).set(keep, security));
|
|
45
|
+
if (config.cartCookie && !keep && current)
|
|
46
|
+
set(jar, cartCookie(config.cartCookie).clear(security));
|
|
47
|
+
if (data.cart && data.browserCart)
|
|
48
|
+
set(jar, browserCartCookie(data.browserCart.token, security));
|
|
49
|
+
}
|
|
50
|
+
function withoutData(r, view) {
|
|
51
|
+
return r.ok ? { ok: true, data: view(r.data) } : r;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Starts a sign-in: `POST /v1/auth/sign-in`, then the binding (with the challenge id) goes into the sign-in cookie for 10
|
|
55
|
+
* minutes. An unsafe `returnPath` is dropped here as the API would. The result carries the challenge id for the code form.
|
|
56
|
+
*/
|
|
57
|
+
export async function startSignInAction(config, email, returnPath) {
|
|
58
|
+
const { jar, security, client } = await context(config);
|
|
59
|
+
const path = returnPath == null ? null : safeReturnPath(returnPath, "") || null;
|
|
60
|
+
const r = await startSignIn(client(null), email, path);
|
|
61
|
+
if (r.ok)
|
|
62
|
+
set(jar, signInCookie(r.data.binding, { ...security, challengeId: r.data.challengeId })); // the challenge id for the code path
|
|
63
|
+
return withoutData(r, (d) => ({ challengeId: d.challengeId, expiresAt: d.expiresAt, codeLength: Number(d.codeLength) }));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Verifies a code or link with the binding from the sign-in cookie: on success the session cookie is set (to the session's
|
|
67
|
+
* absolute expiry) and the sign-in cookie cleared. `409 SIGN_IN_CONFIRM_REQUIRED` comes back as a problem with
|
|
68
|
+
* `maskedEmail` and `siteName`; ask the shopper, then call again with `confirm: true`.
|
|
69
|
+
*/
|
|
70
|
+
export async function verifySignInAction(config, input) {
|
|
71
|
+
const { jar, security, client } = await context(config);
|
|
72
|
+
const pending = readSignInCookie(read(jar, signInCookie.cookieName(security)));
|
|
73
|
+
// A link names its own challenge (ruling R12): never the cookie's, which may belong to a later start in this browser.
|
|
74
|
+
const challengeId = input.link ? null : input.challengeId || pending?.challengeId || null;
|
|
75
|
+
if (!input.link && !challengeId)
|
|
76
|
+
return localFailure("SIGN_IN_CHALLENGE_MISSING", "No sign-in is in progress in this browser: start a new one.");
|
|
77
|
+
const cartToken = config.cartCookie ? read(jar, config.cartCookie) : null;
|
|
78
|
+
const r = await verifySignIn(client(null), {
|
|
79
|
+
challengeId, code: input.code ?? null, link: input.link ?? null, binding: pending?.binding ?? null, confirm: input.confirm ?? null,
|
|
80
|
+
}, cartToken);
|
|
81
|
+
if (r.ok) {
|
|
82
|
+
set(jar, sessionCookie(r.data.session.token, r.data.session.expiresAt, security));
|
|
83
|
+
set(jar, signInCookie.clear(security));
|
|
84
|
+
keepCarts(jar, config, security, r.data, cartToken);
|
|
85
|
+
}
|
|
86
|
+
return withoutData(r, signedInView);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Switches the principal: the rotated token replaces the session cookie at once (the old one serves GETs for 30 s only).
|
|
90
|
+
* A failure (also `SESSION_INVALID`) is returned and leaves the cookie alone: a switch that lost to a concurrent one from
|
|
91
|
+
* another tab would otherwise delete the winner's fresh cookie. Refresh the session state; only `signOutAction` clears.
|
|
92
|
+
*/
|
|
93
|
+
export async function switchPrincipalAction(config, principal) {
|
|
94
|
+
const { jar, security, client } = await context(config);
|
|
95
|
+
const token = session(jar, security);
|
|
96
|
+
const r = await switchPrincipal(client(token), principal);
|
|
97
|
+
if (r.ok) {
|
|
98
|
+
set(jar, sessionCookie(r.data.session.token, r.data.session.expiresAt, security));
|
|
99
|
+
// The old principal keeps its cart; the new principal's cart becomes the cart cookie, or the cookie is cleared when it has none.
|
|
100
|
+
keepCarts(jar, config, security, r.data, config.cartCookie ? read(jar, config.cartCookie) : null);
|
|
101
|
+
}
|
|
102
|
+
return withoutData(r, signedInView);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Signs out (`everywhere` ends every session of the account). The session cookie, the sign-in cookie, the cart cookie and the
|
|
106
|
+
* browser-cart cookie are cleared whatever the API answered, and also when it cannot be reached (`{ ok: false, status: 0, problem.code: "NETWORK" }`)
|
|
107
|
+
* — a session already gone is still a sign-out for this browser.
|
|
108
|
+
*/
|
|
109
|
+
export async function signOutAction(config, opts = {}) {
|
|
110
|
+
const { jar, security, client } = await context(config);
|
|
111
|
+
const token = session(jar, security);
|
|
112
|
+
try {
|
|
113
|
+
return token ? await signOut(client(token), opts) : { ok: true, data: undefined };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Spec §7.8: an unreachable API still signs this browser out; the session ends server-side at its idle expiry.
|
|
117
|
+
return localFailure("NETWORK", "The API could not be reached; this browser is signed out.");
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
set(jar, sessionCookie.clear(security));
|
|
121
|
+
set(jar, signInCookie.clear(security));
|
|
122
|
+
if (config.cartCookie && read(jar, config.cartCookie))
|
|
123
|
+
set(jar, cartCookie(config.cartCookie).clear(security));
|
|
124
|
+
if (read(jar, browserCartCookie.cookieName(security)))
|
|
125
|
+
set(jar, browserCartCookie.clear(security));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* "Lägg till i sparad varukorg" (spec §9.2): copies the browser cart (the browser-cart cookie) into the saved cart (the cart
|
|
130
|
+
* cookie, `config.cartCookie`) with `copyBrowserCart`, under the session. The browser-cart cookie is cleared only when no line
|
|
131
|
+
* failed: a retry reuses the same idempotency keys, so lines already copied are no-ops. The result lists line ids, never a token.
|
|
132
|
+
*/
|
|
133
|
+
export async function copyBrowserCartAction(config) {
|
|
134
|
+
const { jar, security, client } = await context(config);
|
|
135
|
+
const browser = read(jar, browserCartCookie.cookieName(security));
|
|
136
|
+
if (!browser)
|
|
137
|
+
return localFailure("BROWSER_CART_MISSING", "There is no browser cart to copy.");
|
|
138
|
+
const saved = config.cartCookie ? read(jar, config.cartCookie) : null;
|
|
139
|
+
if (!saved)
|
|
140
|
+
return localFailure("CART_MISSING", "There is no saved cart to copy into.");
|
|
141
|
+
let result;
|
|
142
|
+
try {
|
|
143
|
+
result = await copyBrowserCart(client(session(jar, security)), browser, saved);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e instanceof CopyBrowserCartError && e.problem)
|
|
147
|
+
return { ok: false, status: e.status, problem: e.problem };
|
|
148
|
+
return localFailure("NETWORK", "The browser cart could not be read; nothing was copied.");
|
|
149
|
+
}
|
|
150
|
+
if (result.failed.length === 0)
|
|
151
|
+
set(jar, browserCartCookie.clear(security));
|
|
152
|
+
return { ok: true, data: result };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A client for a server component or route handler of this request, with the session from the cookie (or none). It never
|
|
156
|
+
* sets a cookie: a render cannot.
|
|
157
|
+
*/
|
|
158
|
+
export async function shopperClient(config) {
|
|
159
|
+
const { jar, security, client } = await context(config);
|
|
160
|
+
return client(session(jar, security));
|
|
161
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Request-hop guards for the account flow (accounts spec §6.2, §7.4, §13.1). */
|
|
2
|
+
/** `Headers`, Next.js's `headers()`, or a plain object of header values. */
|
|
3
|
+
export type HeadersLike = {
|
|
4
|
+
get(name: string): string | null;
|
|
5
|
+
} | Record<string, string | string[] | undefined>;
|
|
6
|
+
/**
|
|
7
|
+
* The shopper's IP from `X-Forwarded-For` for `X-Shopper-Ip`. `trustedHops` is how many proxies you run in front of the
|
|
8
|
+
* server, each appending the address it saw: the shopper is the entry the outermost of them appended, `trustedHops` from
|
|
9
|
+
* the right. Anything left of it is the client's own word and is skipped. Null when the header has fewer entries than
|
|
10
|
+
* trusted hops, `trustedHops` is 0, or the entry is not an IP address.
|
|
11
|
+
*/
|
|
12
|
+
export declare function shopperIpFrom(headers: HeadersLike, opts: {
|
|
13
|
+
trustedHops: number;
|
|
14
|
+
}): string | null;
|
|
15
|
+
export declare class CrossOriginError extends Error {
|
|
16
|
+
readonly origin: string | null;
|
|
17
|
+
constructor(origin: string | null);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* For a route handler that changes state (spec §7.4): throws `CrossOriginError` unless the `Origin` header is one of
|
|
21
|
+
* `origins`. Server actions get the same check from Next.js; route handlers do not.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertSameOrigin(request: {
|
|
24
|
+
headers: HeadersLike;
|
|
25
|
+
}, origins: readonly string[]): void;
|
|
26
|
+
/**
|
|
27
|
+
* The API's return-path rule (`ReturnPaths.IsSafe`): a same-origin path — `/` not followed by `/` or `\`, at most 512
|
|
28
|
+
* characters, no control character. Returns the path when it is one, else `fallback` (also for null or empty).
|
|
29
|
+
*/
|
|
30
|
+
export declare function safeReturnPath(path: string | null | undefined, fallback?: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Request-hop guards for the account flow (accounts spec §6.2, §7.4, §13.1). */
|
|
2
|
+
function header(headers, name) {
|
|
3
|
+
if (typeof headers.get === "function")
|
|
4
|
+
return headers.get(name);
|
|
5
|
+
const record = headers;
|
|
6
|
+
const key = Object.keys(record).find((k) => k.toLowerCase() === name);
|
|
7
|
+
const value = key === undefined ? undefined : record[key];
|
|
8
|
+
return Array.isArray(value) ? value.join(",") : value ?? null;
|
|
9
|
+
}
|
|
10
|
+
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
11
|
+
const IPV6 = /^[0-9a-f:.]+$/i;
|
|
12
|
+
function isIp(value) {
|
|
13
|
+
if (IPV4.test(value))
|
|
14
|
+
return true;
|
|
15
|
+
if (!value.includes(":") || !IPV6.test(value))
|
|
16
|
+
return false;
|
|
17
|
+
try {
|
|
18
|
+
new URL(`http://[${value}]/`);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The shopper's IP from `X-Forwarded-For` for `X-Shopper-Ip`. `trustedHops` is how many proxies you run in front of the
|
|
27
|
+
* server, each appending the address it saw: the shopper is the entry the outermost of them appended, `trustedHops` from
|
|
28
|
+
* the right. Anything left of it is the client's own word and is skipped. Null when the header has fewer entries than
|
|
29
|
+
* trusted hops, `trustedHops` is 0, or the entry is not an IP address.
|
|
30
|
+
*/
|
|
31
|
+
export function shopperIpFrom(headers, opts) {
|
|
32
|
+
const hops = Math.floor(opts.trustedHops);
|
|
33
|
+
if (!(hops > 0))
|
|
34
|
+
return null;
|
|
35
|
+
const raw = header(headers, "x-forwarded-for");
|
|
36
|
+
if (!raw)
|
|
37
|
+
return null;
|
|
38
|
+
const entries = raw.split(",").map((e) => e.trim()).filter((e) => e.length > 0);
|
|
39
|
+
if (entries.length < hops)
|
|
40
|
+
return null;
|
|
41
|
+
const ip = entries[entries.length - hops];
|
|
42
|
+
return isIp(ip) ? ip : null;
|
|
43
|
+
}
|
|
44
|
+
export class CrossOriginError extends Error {
|
|
45
|
+
origin;
|
|
46
|
+
constructor(origin) {
|
|
47
|
+
super(origin === null ? "The request has no Origin header." : `The origin ${origin} is not allowed.`);
|
|
48
|
+
this.origin = origin;
|
|
49
|
+
this.name = "CrossOriginError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* For a route handler that changes state (spec §7.4): throws `CrossOriginError` unless the `Origin` header is one of
|
|
54
|
+
* `origins`. Server actions get the same check from Next.js; route handlers do not.
|
|
55
|
+
*/
|
|
56
|
+
export function assertSameOrigin(request, origins) {
|
|
57
|
+
const origin = header(request.headers, "origin");
|
|
58
|
+
if (!origin || origin === "null")
|
|
59
|
+
throw new CrossOriginError(origin);
|
|
60
|
+
let actual;
|
|
61
|
+
try {
|
|
62
|
+
actual = new URL(origin).origin;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new CrossOriginError(origin);
|
|
66
|
+
}
|
|
67
|
+
if (!origins.some((o) => new URL(o).origin === actual))
|
|
68
|
+
throw new CrossOriginError(origin);
|
|
69
|
+
}
|
|
70
|
+
/** The API's `ReturnPaths.MaxLength`. */
|
|
71
|
+
const MAX_RETURN_PATH = 512;
|
|
72
|
+
/** char.IsControl: U+0000–U+001F and U+007F–U+009F. */
|
|
73
|
+
// eslint-disable-next-line no-control-regex
|
|
74
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
75
|
+
/**
|
|
76
|
+
* The API's return-path rule (`ReturnPaths.IsSafe`): a same-origin path — `/` not followed by `/` or `\`, at most 512
|
|
77
|
+
* characters, no control character. Returns the path when it is one, else `fallback` (also for null or empty).
|
|
78
|
+
*/
|
|
79
|
+
export function safeReturnPath(path, fallback = "/") {
|
|
80
|
+
if (!path || path.length > MAX_RETURN_PATH || path[0] !== "/")
|
|
81
|
+
return fallback;
|
|
82
|
+
if (path.length > 1 && (path[1] === "/" || path[1] === "\\"))
|
|
83
|
+
return fallback;
|
|
84
|
+
return CONTROL.test(path) ? fallback : path;
|
|
85
|
+
}
|
|
@@ -2,7 +2,16 @@ import type { Client } from "openapi-fetch";
|
|
|
2
2
|
import type { StorefrontPaths } from "../index.js";
|
|
3
3
|
import { type CheckoutResult, type LiveShippingResult, type OrderResult, type StatusResult } from "./types.js";
|
|
4
4
|
export type CheckoutClient = Client<StorefrontPaths>;
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Accounts (spec §9.1, §9.2): a signed-in frontend passes its request-scoped shopper client — `createShopperClient` from
|
|
7
|
+
* `@asstio-dev/storefront/account`, with the server key and the session — and the call goes through it instead of `client`, so
|
|
8
|
+
* `X-Shopper-Session` travels: a saved (owned) cart checks out only under its owner's session, and the order records the
|
|
9
|
+
* principal. Signed out, omit it (or pass `null`): the call is exactly today's. Server-side only, like the shopper client itself.
|
|
10
|
+
*/
|
|
11
|
+
export interface ShopperOption {
|
|
12
|
+
shopper?: CheckoutClient | null;
|
|
13
|
+
}
|
|
14
|
+
export interface StartCheckoutOptions extends ShopperOption {
|
|
6
15
|
/** One of `GET /v1/site` → `markets[].checkout.options[].key`; omit for the market's default. */
|
|
7
16
|
option?: string;
|
|
8
17
|
/** Replaces the market's origin in the URLs handed to the provider; must be one of the site's allowed origins. */
|
|
@@ -22,14 +31,15 @@ export declare function startCheckout(client: CheckoutClient, cartToken: string,
|
|
|
22
31
|
* Never repeats a cart mutation: it only syncs. */
|
|
23
32
|
export declare function syncCheckout(client: CheckoutClient, cartToken: string, opts?: StartCheckoutOptions, retry?: SyncRetryOptions): Promise<CheckoutResult>;
|
|
24
33
|
/** `POST /v1/checkout/{attemptId}/complete`: the confirmation page's call; with the cart token the answer carries the order's access token (spec §8.3). */
|
|
25
|
-
export declare function completeCheckout(client: CheckoutClient, attemptId: string, cartToken?: string): Promise<StatusResult>;
|
|
34
|
+
export declare function completeCheckout(client: CheckoutClient, attemptId: string, cartToken?: string, opts?: ShopperOption): Promise<StatusResult>;
|
|
26
35
|
/** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
|
|
27
|
-
export declare function getCheckoutStatus(client: CheckoutClient, attemptId: string, cartToken: string): Promise<StatusResult>;
|
|
36
|
+
export declare function getCheckoutStatus(client: CheckoutClient, attemptId: string, cartToken: string, opts?: ShopperOption): Promise<StatusResult>;
|
|
28
37
|
/** `GET /v1/checkout/{attemptId}/shipping` with the cart token: the live shipping view behind `mountCheckout`'s `liveShipping`.
|
|
29
38
|
* Forward `retryAfter` (the `Retry-After` header) from a proxy; a 200 `unavailable` view may carry it too. */
|
|
30
|
-
export declare function liveShippingFor(client: CheckoutClient, cartToken: string, attemptId: string): Promise<LiveShippingResult>;
|
|
31
|
-
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3).
|
|
32
|
-
|
|
39
|
+
export declare function liveShippingFor(client: CheckoutClient, cartToken: string, attemptId: string, opts?: ShopperOption): Promise<LiveShippingResult>;
|
|
40
|
+
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). A company order opens to its
|
|
41
|
+
* own membership's session alone (`{ shopper }`, token `null`); its order token never opens it. */
|
|
42
|
+
export declare function getOrder(client: CheckoutClient, orderId: string, token: string | null, opts?: ShopperOption): Promise<OrderResult>;
|
|
33
43
|
export interface AwaitCompletionOptions {
|
|
34
44
|
firstDelayMs?: number;
|
|
35
45
|
maxDelayMs?: number;
|
package/dist/checkout/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { localProblem, retryAfterSeconds } from "./types.js";
|
|
2
|
+
const via = (client, opts) => opts.shopper ?? client;
|
|
2
3
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
4
|
/** `POST /v1/checkout`: creates the cart's attempt or converges on the live one (spec §5.3). Call it from a Server Action or route handler, never during render. */
|
|
4
5
|
export async function startCheckout(client, cartToken, opts = {}) {
|
|
5
|
-
const { data, error, response } = await client.POST("/v1/checkout", {
|
|
6
|
+
const { data, error, response } = await via(client, opts).POST("/v1/checkout", {
|
|
6
7
|
body: { option: opts.option ?? null, returnOrigin: opts.returnOrigin ?? null },
|
|
7
8
|
headers: { "X-Cart-Token": cartToken },
|
|
8
9
|
});
|
|
@@ -25,8 +26,8 @@ export async function syncCheckout(client, cartToken, opts = {}, retry = {}) {
|
|
|
25
26
|
return result;
|
|
26
27
|
}
|
|
27
28
|
/** `POST /v1/checkout/{attemptId}/complete`: the confirmation page's call; with the cart token the answer carries the order's access token (spec §8.3). */
|
|
28
|
-
export async function completeCheckout(client, attemptId, cartToken) {
|
|
29
|
-
const { data, error, response } = await client.POST("/v1/checkout/{attemptId}/complete", {
|
|
29
|
+
export async function completeCheckout(client, attemptId, cartToken, opts = {}) {
|
|
30
|
+
const { data, error, response } = await via(client, opts).POST("/v1/checkout/{attemptId}/complete", {
|
|
30
31
|
params: { path: { attemptId } },
|
|
31
32
|
headers: cartToken ? { "X-Cart-Token": cartToken } : {},
|
|
32
33
|
});
|
|
@@ -35,24 +36,25 @@ export async function completeCheckout(client, attemptId, cartToken) {
|
|
|
35
36
|
return { ok: true, data };
|
|
36
37
|
}
|
|
37
38
|
/** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
|
|
38
|
-
export async function getCheckoutStatus(client, attemptId, cartToken) {
|
|
39
|
-
const { data, error, response } = await client.GET("/v1/checkout/{attemptId}", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
|
|
39
|
+
export async function getCheckoutStatus(client, attemptId, cartToken, opts = {}) {
|
|
40
|
+
const { data, error, response } = await via(client, opts).GET("/v1/checkout/{attemptId}", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
|
|
40
41
|
if (error || !data)
|
|
41
42
|
return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter: retryAfterSeconds(response) };
|
|
42
43
|
return { ok: true, data };
|
|
43
44
|
}
|
|
44
45
|
/** `GET /v1/checkout/{attemptId}/shipping` with the cart token: the live shipping view behind `mountCheckout`'s `liveShipping`.
|
|
45
46
|
* Forward `retryAfter` (the `Retry-After` header) from a proxy; a 200 `unavailable` view may carry it too. */
|
|
46
|
-
export async function liveShippingFor(client, cartToken, attemptId) {
|
|
47
|
-
const { data, error, response } = await client.GET("/v1/checkout/{attemptId}/shipping", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
|
|
47
|
+
export async function liveShippingFor(client, cartToken, attemptId, opts = {}) {
|
|
48
|
+
const { data, error, response } = await via(client, opts).GET("/v1/checkout/{attemptId}/shipping", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
|
|
48
49
|
const retryAfter = retryAfterSeconds(response);
|
|
49
50
|
if (error || !data)
|
|
50
51
|
return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter };
|
|
51
52
|
return { ok: true, data, retryAfter };
|
|
52
53
|
}
|
|
53
|
-
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3).
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). A company order opens to its
|
|
55
|
+
* own membership's session alone (`{ shopper }`, token `null`); its order token never opens it. */
|
|
56
|
+
export async function getOrder(client, orderId, token, opts = {}) {
|
|
57
|
+
const { data, error, response } = await via(client, opts).GET("/v1/orders/{orderId}", { params: { path: { orderId } }, headers: token ? { "X-Order-Token": token } : {} });
|
|
56
58
|
if (error || !data)
|
|
57
59
|
return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")) };
|
|
58
60
|
return { ok: true, data };
|
package/dist/index.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get
|
|
|
47
47
|
export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
|
|
48
48
|
export { decodeFilterValues, encodeFilterValues } from "./filters.js";
|
|
49
49
|
export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, type FormatFit, type FormattedImage, type MediaConfig, type MediaDescriptor, type MediaFormat, type PictureSources, type SlotSize, type VideoSource, } from "./media.js";
|
|
50
|
-
export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, liveShippingFor, startCheckout, syncCheckout, type CheckoutClient, type StartCheckoutOptions, type SyncRetryOptions, type AwaitCompletionOptions } from "./checkout/server.js";
|
|
50
|
+
export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, liveShippingFor, startCheckout, syncCheckout, type CheckoutClient, type ShopperOption, type StartCheckoutOptions, type SyncRetryOptions, type AwaitCompletionOptions } from "./checkout/server.js";
|
|
51
51
|
export { retryAfterSeconds, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutShippingViewDto, type LiveShippingResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
|
|
52
52
|
export { mountCheckout, type CheckoutHandle, type MountOptions, type MinimalDocument, type MinimalElement, type MinimalScript } from "./checkout/browser.js";
|
|
53
53
|
export { controllers, type CheckoutController, type ProviderWindow } from "./checkout/controllers.js";
|
|
@@ -55,6 +55,7 @@ export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
|
|
|
55
55
|
export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/head.js";
|
|
56
56
|
export { gtinJsonLd } from "./gtin.js";
|
|
57
57
|
export { productJsonLd } from "./seo/product.js";
|
|
58
|
+
export { sitemapEntries, toNextSitemap, type NextSitemapEntry, type SitemapOptions, type SitemapResult, type SitemapType, type SitemapUrl } from "./seo/sitemap.js";
|
|
58
59
|
export { jsonLdScript } from "./seo/serialize.js";
|
|
59
60
|
export { productFacts } from "./analytics/facts.js";
|
|
60
61
|
export { longestCategory, toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
|
|
|
44
44
|
export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/head.js";
|
|
45
45
|
export { gtinJsonLd } from "./gtin.js";
|
|
46
46
|
export { productJsonLd } from "./seo/product.js";
|
|
47
|
+
export { sitemapEntries, toNextSitemap } from "./seo/sitemap.js";
|
|
47
48
|
export { jsonLdScript } from "./seo/serialize.js";
|
|
48
49
|
export { productFacts } from "./analytics/facts.js";
|
|
49
50
|
export { longestCategory, toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
|