@asstio-dev/storefront 0.27.0 → 0.31.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 +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
package/README.md
CHANGED
|
@@ -260,6 +260,36 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
260
260
|
|
|
261
261
|
Titles come out as `{ absolute }` on purpose, so a layout's `title.template` does not apply a second time.
|
|
262
262
|
|
|
263
|
+
**Sitemap.** `sitemapEntries` reads every sitemap URL of one market: `GET /v1/site` once, then for each type
|
|
264
|
+
(`product`, `collection`, `brand`; default all three) a cursor walk of `GET /v1/sitemap` from `cursor=start` along
|
|
265
|
+
`nextCursor`, with no 10 000-row ceiling. `toNextSitemap` turns the result into Next.js's `app/sitemap.ts` shape:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import type { MetadataRoute } from "next";
|
|
269
|
+
|
|
270
|
+
export const dynamic = "force-dynamic";
|
|
271
|
+
|
|
272
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
273
|
+
const market = process.env.STOREFRONT_MARKET!; // the same market the client sends as X-Market
|
|
274
|
+
const api = createStorefrontClient({ baseUrl: API_URL, siteKey: SITE_KEY, market });
|
|
275
|
+
const result = await sitemapEntries(api, { market });
|
|
276
|
+
if (!result.complete) console.warn("Sitemap: Storefront API unavailable; serving a partial sitemap.");
|
|
277
|
+
return toNextSitemap(result);
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- `market` is required and must be the client's market. It is matched case-insensitively against the site's markets;
|
|
282
|
+
an unknown one throws `SeoError` before any page is read. Never pass the site's default instead of the request's.
|
|
283
|
+
- hreflang tags are `language-COUNTRY` (`sv-SE`, never the market code `se`), each entry lists itself, and `x-default`
|
|
284
|
+
appears only when the site's default market has the page. Two markets with the same tag and different URLs drop that
|
|
285
|
+
entry's alternates; the URL stays listed.
|
|
286
|
+
- `lastModified` is left out when the API reports the Unix epoch (collections carry no timestamp of their own).
|
|
287
|
+
- `complete: false`: a transport failure, a 5xx, `INDEX_UNAVAILABLE` or `SITE_REGISTRY_WARMING` stopped the walk; the
|
|
288
|
+
entries read so far are returned. Any other problem (a 400 such as `INVALID_CURSOR`) throws.
|
|
289
|
+
- An API older than cursor paging throws "the Storefront API does not support sitemap cursors; deploy the API first"
|
|
290
|
+
rather than ending the walk after one page.
|
|
291
|
+
- A walk is an eventually consistent crawl, not a snapshot: a product added mid-walk may wait for the next render.
|
|
292
|
+
|
|
263
293
|
**Errors.** `SeoError` means a caller or configuration error: `redirectTo` is set (answer 301 instead), an unknown
|
|
264
294
|
market, a bad `variesBy` value, a variant URL (`v.url` or the optional `variantUrl` hook) that is missing, not an absolute http(s) URL or repeated for two variants, or
|
|
265
295
|
two markets with the same `language-COUNTRY` and different URLs. Catch it at your SEO boundary and fix the market
|
|
@@ -602,6 +632,84 @@ mountCheckout(el, widget, {
|
|
|
602
632
|
visible.
|
|
603
633
|
- The SDK owns the provider's shipping events while `onShipping` is set; do not bind them yourself.
|
|
604
634
|
|
|
635
|
+
### Accounts: sign-in, sessions and saved carts
|
|
636
|
+
|
|
637
|
+
Shopper accounts are a **server-only** module, `@asstio-dev/storefront/account`, with a Next.js (15+) adapter,
|
|
638
|
+
`@asstio-dev/storefront/account/next`, and one browser helper, `@asstio-dev/storefront/account/browser`. Every account call
|
|
639
|
+
needs the site's **server key** (`sfk_srv_…`) and runs on your server; the session token travels in `X-Shopper-Session`, which
|
|
640
|
+
the API refuses from a browser (`SESSION_FROM_BROWSER`) and which no CORS preflight allows. A site without accounts answers
|
|
641
|
+
every account route `404 ACCOUNTS_DISABLED`, and `GET /v1/site` has `accounts: null` — render no sign-in then.
|
|
642
|
+
|
|
643
|
+
**Sign-in.** `startSignInAction(config, email, returnPath?)` mails an 8-digit code and a link (the `202` is the same whether
|
|
644
|
+
or not the address may sign in) and keeps the start's binding in the HttpOnly `__Host-sf_signin` cookie for 10 minutes.
|
|
645
|
+
`verifySignInAction(config, { code })` or `{ link }` mints the session into the `__Host-sf_session` cookie. On the link's
|
|
646
|
+
page, read the token with `readLinkFragment()` in the browser (it removes it from the URL) and hand it to a server action.
|
|
647
|
+
A link opened in another browser has no binding: the answer is `409 SIGN_IN_CONFIRM_REQUIRED` with `maskedEmail` and
|
|
648
|
+
`siteName` — ask "Sign in on this device?" and call again with `confirm: true`. Any other failure is one code,
|
|
649
|
+
`SIGN_IN_INVALID`: start a new sign-in. Redirect to the view's `returnPath` (or your default) yourself.
|
|
650
|
+
|
|
651
|
+
**Sessions.** Build a client per request, never a module singleton: `shopperClient(config)` reads the session cookie
|
|
652
|
+
(`createShopperClient({ baseUrl, serverKey, market, session, shopperIp })` outside Next.js). `getSession`, `getMe` and
|
|
653
|
+
`getMyCompany` read who is signed in; `switchPrincipalAction(config, principal)` acts as another principal (`"consumer"` or a
|
|
654
|
+
membership id), rotates the session and points your cart cookie at the new principal's saved cart, or clears it when that
|
|
655
|
+
principal has none; `signOutAction(config, { everywhere? })` clears the session, sign-in, cart and
|
|
656
|
+
browser-cart cookies whatever the API answers. A session that no longer validates is `401 SESSION_INVALID` on **every**
|
|
657
|
+
route, never a silent signed-out answer: render signed-out, and let the next action clear the cookie.
|
|
658
|
+
|
|
659
|
+
**Saved carts.** With a session, `POST /v1/cart` returns the principal's saved cart (or creates one) and `GET /v1/me/cart`
|
|
660
|
+
reads it; the cart routes work on it with its token **and** the session. A saved cart's token without its owner's session —
|
|
661
|
+
another device signed out, another principal, another account — is `404 NOT_FOUND`, exactly an unknown token; a principal
|
|
662
|
+
that may not change the cart (a company viewer) is `403 PERMISSION_DENIED` with `permission: "cart.edit"`. Sign-in never
|
|
663
|
+
merges carts: when the shopper had a browser cart A and a saved cart B, verify points your cart cookie
|
|
664
|
+
(`config.cartCookie`) at B and keeps A in `__Host-sf_browser_cart` for 30 minutes; with no saved cart, the cookie stays on A
|
|
665
|
+
(adopted, or still anonymous) and is cleared when the API names neither cart (the cookie held no usable cart). Offer "Add to saved cart" and call
|
|
666
|
+
`copyBrowserCartAction(config)`: each line of A is an ordinary `POST /v1/cart/lines` on B with
|
|
667
|
+
`Idempotency-Key: copy:{A}:{lineId}`, so a retry never doubles a line. The result lists line ids as `copied`, `skipped`
|
|
668
|
+
(the product is gone or cannot be sold) and `failed` (retry); the browser-cart cookie is cleared once nothing failed, at
|
|
669
|
+
sign-out and at its expiry.
|
|
670
|
+
|
|
671
|
+
**Checkout with a session.** Pass the shopper client as `{ shopper }` to `startCheckout`, `syncCheckout`,
|
|
672
|
+
`completeCheckout`, `getCheckoutStatus`, `liveShippingFor` and `getOrder`: the call goes through it, so a saved cart checks
|
|
673
|
+
out under its owner and the order records the consumer. Signed out, leave it out — the call is exactly today's. A company
|
|
674
|
+
principal cannot check out yet (`409 COMPANY_CHECKOUT_UNAVAILABLE`). The confirmation and order pages of a consumer's
|
|
675
|
+
checkout still open by the cart token and the order token alone, so a payment that returns to a signed-out tab completes.
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
import { getMe } from "@asstio-dev/storefront/account";
|
|
679
|
+
import { copyBrowserCartAction, shopperClient, signOutAction, startSignInAction, verifySignInAction, type AccountActionConfig } from "@asstio-dev/storefront/account/next";
|
|
680
|
+
|
|
681
|
+
// process.env.STOREFRONT_SERVER_KEY: the server key never reaches a browser bundle.
|
|
682
|
+
declare const STOREFRONT_SERVER_KEY: string;
|
|
683
|
+
|
|
684
|
+
const accounts: AccountActionConfig = {
|
|
685
|
+
baseUrl: "https://api.storefront.asstio.com",
|
|
686
|
+
serverKey: STOREFRONT_SERVER_KEY,
|
|
687
|
+
market: "se",
|
|
688
|
+
origin: "https://shop.example", // decides Secure and the __Host- prefix (dropped on http://localhost)
|
|
689
|
+
trustedHops: 1, // proxies in front of this server, for the shopper IP the sign-in limits use
|
|
690
|
+
cartCookie: "cart", // your cart cookie: sent at verify, pointed at the saved cart, read by the copy
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// Your "use server" actions.
|
|
694
|
+
export async function startSignInForm(email: string) { return startSignInAction(accounts, email, "/account"); }
|
|
695
|
+
export async function verifyCode(code: string) { return verifySignInAction(accounts, { code }); }
|
|
696
|
+
export async function verifyLink(link: string, confirm = false) { return verifySignInAction(accounts, { link, confirm }); }
|
|
697
|
+
export async function addToSavedCart() { return copyBrowserCartAction(accounts); }
|
|
698
|
+
export async function signOutEverywhere() { return signOutAction(accounts, { everywhere: true }); }
|
|
699
|
+
|
|
700
|
+
// A server component: who is signed in. Signed out, or a session that no longer validates, renders signed-out.
|
|
701
|
+
export async function accountHeader() {
|
|
702
|
+
const me = await getMe(await shopperClient(accounts));
|
|
703
|
+
return me.ok ? me.data.email : null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Checkout under the session: the saved cart checks out under its owner, and the order records the principal.
|
|
707
|
+
export async function beginSignedInCheckout(savedCart: string) {
|
|
708
|
+
const shopper = await shopperClient(accounts);
|
|
709
|
+
return startCheckout(storefront, savedCart, { returnOrigin: "https://shop.example", shopper });
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
605
713
|
## Headers
|
|
606
714
|
|
|
607
715
|
Every call the client makes carries these; you never set them by hand.
|
|
@@ -641,7 +749,7 @@ Every operation documents `400`, `401`, `403`, `404`, `429` and `503`; the entit
|
|
|
641
749
|
|
|
642
750
|
Codes you will meet: `SITE_KEY_MISSING`, `SITE_KEY_INVALID`, `SITE_INACTIVE`, `SECRET_KEY_FROM_BROWSER`,
|
|
643
751
|
`SITE_REGISTRY_WARMING`, `MARKET_UNKNOWN`, `RATE_LIMITED` (with `retryAfterSeconds`, plus a `Retry-After`
|
|
644
|
-
header), `PRODUCT_NOT_FOUND`, `COLLECTION_NOT_FOUND`, `INVALID_PAGE`, `INVALID_SINCE`, `INVALID_TYPE`,
|
|
752
|
+
header), `PRODUCT_NOT_FOUND`, `COLLECTION_NOT_FOUND`, `INVALID_PAGE`, `INVALID_SINCE`, `INVALID_TYPE`, `INVALID_CURSOR`,
|
|
645
753
|
`INDEX_UNAVAILABLE` (retry — a preview index is being rebuilt), `NOT_FOUND`, `INVALID_QUERY`,
|
|
646
754
|
`FACET_NOT_FOUND`, `FACET_NOT_SEARCHABLE`,
|
|
647
755
|
and, from the checkout ([above](#checkout)): `CART_EMPTY`, `CHECKOUT_CART_CHANGED`, `CHECKOUT_PREVIEW_KEY`,
|
|
@@ -677,8 +785,10 @@ A range is `min-max`: either bound may be left out and either may be negative
|
|
|
677
785
|
−10), `--2` (at most −2) — and a leading dash with nothing before it keeps its meaning, `-500` is "at most 500". A
|
|
678
786
|
range whose lower bound is above its upper bound is ignored with a warning.
|
|
679
787
|
|
|
680
|
-
`/v1/sitemap` takes `type` (`product` | `collection`), `since` (ISO-8601, inclusive), `
|
|
681
|
-
(
|
|
788
|
+
`/v1/sitemap` takes `type` (`product` | `collection` | `brand`), `since` (ISO-8601, inclusive), `pageSize` (max 1000,
|
|
789
|
+
default 500) and either `page` (page mode, `page × pageSize` at most 10 000) or `cursor` (cursor mode: `start`, then
|
|
790
|
+
each response's `nextCursor` until it is `null`; no ceiling, not combined with a `page` past 1). `since` is for
|
|
791
|
+
revalidation, cursor mode for a full crawl; `sitemapEntries` ([SEO](#seo)) does the walk for you.
|
|
682
792
|
|
|
683
793
|
`/v1/search/suggest` takes `q` only, and `/v1/products/facets/{key}` takes `q` plus the same `filter` object as a
|
|
684
794
|
listing — both are described under [Search-as-you-type and search-in-facet](#search-as-you-type-and-search-in-facet).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asstio-dev/storefront/account/browser` — the one browser-side piece of sign-in (accounts spec §6.2): the link's secret
|
|
3
|
+
* travels in the URL fragment, so no access log or mail scanner sees it. The landing page's client component reads it
|
|
4
|
+
* once, strips it from the address bar, and posts it through a server action.
|
|
5
|
+
*/
|
|
6
|
+
/** The parts of `window` this module touches (a fake in tests). */
|
|
7
|
+
export interface LinkWindow {
|
|
8
|
+
location: {
|
|
9
|
+
readonly hash: string;
|
|
10
|
+
readonly pathname: string;
|
|
11
|
+
readonly search: string;
|
|
12
|
+
};
|
|
13
|
+
history: {
|
|
14
|
+
readonly state: unknown;
|
|
15
|
+
replaceState(data: unknown, unused: string, url?: string | URL | null): void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The link token `sfl_{challengeId}.{secret}` from `location.hash`, or null; pass it as `link` (it carries its challenge id). The fragment is removed with `history.replaceState` before returning, so
|
|
20
|
+
* a reload, a shared screenshot or a second call never sees it again. Any other fragment is left alone.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readLinkFragment(win?: LinkWindow | undefined): string | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asstio-dev/storefront/account/browser` — the one browser-side piece of sign-in (accounts spec §6.2): the link's secret
|
|
3
|
+
* travels in the URL fragment, so no access log or mail scanner sees it. The landing page's client component reads it
|
|
4
|
+
* once, strips it from the address bar, and posts it through a server action.
|
|
5
|
+
*/
|
|
6
|
+
const PREFIX = "#sfl_";
|
|
7
|
+
/**
|
|
8
|
+
* The link token `sfl_{challengeId}.{secret}` from `location.hash`, or null; pass it as `link` (it carries its challenge id). The fragment is removed with `history.replaceState` before returning, so
|
|
9
|
+
* a reload, a shared screenshot or a second call never sees it again. Any other fragment is left alone.
|
|
10
|
+
*/
|
|
11
|
+
export function readLinkFragment(win = globalThis.window) {
|
|
12
|
+
if (!win)
|
|
13
|
+
return null;
|
|
14
|
+
const hash = win.location.hash;
|
|
15
|
+
if (!hash.startsWith(PREFIX))
|
|
16
|
+
return null;
|
|
17
|
+
win.history.replaceState(win.history.state, "", win.location.pathname + win.location.search);
|
|
18
|
+
return hash.slice(1);
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AccountProblem, ShopperClient } from "./index.js";
|
|
2
|
+
/** Line ids of A: `copied` (added to B, or already added by an earlier run), `skipped` (the product is gone or cannot be sold:
|
|
3
|
+
* `409`), `failed` (anything else — retry; the keys make copied lines no-ops). */
|
|
4
|
+
export interface CopyBrowserCartResult {
|
|
5
|
+
copied: string[];
|
|
6
|
+
skipped: string[];
|
|
7
|
+
failed: string[];
|
|
8
|
+
}
|
|
9
|
+
/** A failed read of the browser cart that is not "the cart is gone": nothing was copied, keep the token and retry. */
|
|
10
|
+
export declare class CopyBrowserCartError extends Error {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly problem: AccountProblem | null;
|
|
13
|
+
constructor(status: number, problem: AccountProblem | null);
|
|
14
|
+
}
|
|
15
|
+
/** The idempotency key of one copied line (spec §9.2). */
|
|
16
|
+
export declare const copyKey: (browserCartToken: string, lineId: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Copies the lines of the browser cart A into the saved cart B. A is read through the session-bearing client (`GET /v1/cart`
|
|
19
|
+
* with A's token): A is an anonymous cart, which any shopper may read, and this is the only read of it. Each line is posted to
|
|
20
|
+
* B as `{ sku, quantity }` exactly as A holds it — never a price or any other field; B prices the line itself. Quantities add
|
|
21
|
+
* up as any line add does. A that is gone (`404`, `410`) has nothing to copy: every list is empty. Any other failed read
|
|
22
|
+
* throws `CopyBrowserCartError`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function copyBrowserCart(client: ShopperClient, browserCartToken: string, savedCartToken: string): Promise<CopyBrowserCartResult>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** A failed read of the browser cart that is not "the cart is gone": nothing was copied, keep the token and retry. */
|
|
2
|
+
export class CopyBrowserCartError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
problem;
|
|
5
|
+
constructor(status, problem) {
|
|
6
|
+
super(`The browser cart could not be read (${status}${problem?.code ? " " + problem.code : ""}).`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.problem = problem;
|
|
9
|
+
this.name = "CopyBrowserCartError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** The idempotency key of one copied line (spec §9.2). */
|
|
13
|
+
export const copyKey = (browserCartToken, lineId) => `copy:${browserCartToken}:${lineId}`;
|
|
14
|
+
/**
|
|
15
|
+
* Copies the lines of the browser cart A into the saved cart B. A is read through the session-bearing client (`GET /v1/cart`
|
|
16
|
+
* with A's token): A is an anonymous cart, which any shopper may read, and this is the only read of it. Each line is posted to
|
|
17
|
+
* B as `{ sku, quantity }` exactly as A holds it — never a price or any other field; B prices the line itself. Quantities add
|
|
18
|
+
* up as any line add does. A that is gone (`404`, `410`) has nothing to copy: every list is empty. Any other failed read
|
|
19
|
+
* throws `CopyBrowserCartError`.
|
|
20
|
+
*/
|
|
21
|
+
export async function copyBrowserCart(client, browserCartToken, savedCartToken) {
|
|
22
|
+
const read = await client.GET("/v1/cart", { headers: { "X-Cart-Token": browserCartToken } });
|
|
23
|
+
const result = { copied: [], skipped: [], failed: [] };
|
|
24
|
+
if (!read.response.ok || !read.data) {
|
|
25
|
+
if (read.response.status === 404 || read.response.status === 410)
|
|
26
|
+
return result;
|
|
27
|
+
throw new CopyBrowserCartError(read.response.status, problemOf(read.error));
|
|
28
|
+
}
|
|
29
|
+
for (const line of read.data.lines) {
|
|
30
|
+
try {
|
|
31
|
+
const { response, error } = await client.POST("/v1/cart/lines", {
|
|
32
|
+
headers: { "X-Cart-Token": savedCartToken, "Idempotency-Key": copyKey(browserCartToken, line.id) },
|
|
33
|
+
body: { sku: line.sku, quantity: Number(line.quantity) },
|
|
34
|
+
});
|
|
35
|
+
if (response.ok)
|
|
36
|
+
result.copied.push(line.id);
|
|
37
|
+
// 409 is the line's own product (PRODUCT_GONE, STOCK_INSUFFICIENT); a lost race (CART_CONFLICT) is worth a retry.
|
|
38
|
+
else if (response.status === 409 && problemOf(error)?.code !== "CART_CONFLICT")
|
|
39
|
+
result.skipped.push(line.id);
|
|
40
|
+
else
|
|
41
|
+
result.failed.push(line.id);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
result.failed.push(line.id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
function problemOf(error) {
|
|
50
|
+
return typeof error === "object" && error !== null && typeof error.code === "string" ? error : null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie descriptors for the account flow (accounts spec §6.2, §7.3). Framework-neutral: hand a spec to Next.js's
|
|
3
|
+
* `cookies().set(spec)`, or `serializeCookie(spec)` into a `Set-Cookie` header. Every cookie is `HttpOnly; SameSite=Lax;
|
|
4
|
+
* Path=/` with no `Domain`; `Secure` and the `__Host-` prefix are dropped only on `http://localhost` (`cookieSecurity`).
|
|
5
|
+
*/
|
|
6
|
+
export interface CookieSecurity {
|
|
7
|
+
/** false only for a development origin on http://localhost; drops `Secure` and the `__Host-` prefix. */
|
|
8
|
+
secure: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface CookieSpec {
|
|
11
|
+
name: string;
|
|
12
|
+
value: string;
|
|
13
|
+
httpOnly: true;
|
|
14
|
+
secure: boolean;
|
|
15
|
+
sameSite: "lax";
|
|
16
|
+
path: "/";
|
|
17
|
+
/** Seconds; 0 deletes the cookie. */
|
|
18
|
+
maxAge: number;
|
|
19
|
+
}
|
|
20
|
+
export interface SignInCookieValue {
|
|
21
|
+
binding: string;
|
|
22
|
+
challengeId: string | null;
|
|
23
|
+
}
|
|
24
|
+
/** The cookie security of the frontend's own origin: `{ secure: false }` only for `http://localhost` (and its loopback forms). */
|
|
25
|
+
export declare function cookieSecurity(origin: string | URL): CookieSecurity;
|
|
26
|
+
interface SessionCookie {
|
|
27
|
+
/** The session cookie: `Max-Age` runs to the session's absolute expiry. */
|
|
28
|
+
(token: string, expiresAt: string | Date, opts: CookieSecurity & {
|
|
29
|
+
now?: number;
|
|
30
|
+
}): CookieSpec;
|
|
31
|
+
clear(opts: CookieSecurity): CookieSpec;
|
|
32
|
+
cookieName(opts: CookieSecurity): string;
|
|
33
|
+
}
|
|
34
|
+
export declare const sessionCookie: SessionCookie;
|
|
35
|
+
interface SignInCookie {
|
|
36
|
+
/** The sign-in binding cookie (10 minutes). With `challengeId`, the cookie also remembers which challenge it binds, so a
|
|
37
|
+
* link opened in this browser can be verified without the start page's state. */
|
|
38
|
+
(binding: string, opts: CookieSecurity & {
|
|
39
|
+
challengeId?: string | null;
|
|
40
|
+
}): CookieSpec;
|
|
41
|
+
clear(opts: CookieSecurity): CookieSpec;
|
|
42
|
+
cookieName(opts: CookieSecurity): string;
|
|
43
|
+
}
|
|
44
|
+
export declare const signInCookie: SignInCookie;
|
|
45
|
+
/** The sign-in cookie's value back: the binding and, when it was stored, the challenge id. Null when absent or empty. */
|
|
46
|
+
export declare function readSignInCookie(value: string | null | undefined): SignInCookieValue | null;
|
|
47
|
+
/**
|
|
48
|
+
* The frontend's own cart cookie, by the name the frontend gave it (no prefix is added). The SDK sets it only when a sign-in or a
|
|
49
|
+
* switch hands back another cart (spec §7.8; for as long as a cart lives untouched, 30 days) and clears it at sign-out (§9.2).
|
|
50
|
+
*/
|
|
51
|
+
export declare function cartCookie(name: string): {
|
|
52
|
+
set: (token: string, opts: CookieSecurity) => CookieSpec;
|
|
53
|
+
clear: (opts: CookieSecurity) => CookieSpec;
|
|
54
|
+
};
|
|
55
|
+
interface BrowserCartCookie {
|
|
56
|
+
/** The browser cart A's token beside a saved cart B (spec §9.2), for "Lägg till i sparad varukorg": 30 minutes. */
|
|
57
|
+
(token: string, opts: CookieSecurity): CookieSpec;
|
|
58
|
+
clear(opts: CookieSecurity): CookieSpec;
|
|
59
|
+
cookieName(opts: CookieSecurity): string;
|
|
60
|
+
}
|
|
61
|
+
export declare const browserCartCookie: BrowserCartCookie;
|
|
62
|
+
/** A `Set-Cookie` header value for a route handler or middleware. */
|
|
63
|
+
export declare function serializeCookie(c: CookieSpec): string;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie descriptors for the account flow (accounts spec §6.2, §7.3). Framework-neutral: hand a spec to Next.js's
|
|
3
|
+
* `cookies().set(spec)`, or `serializeCookie(spec)` into a `Set-Cookie` header. Every cookie is `HttpOnly; SameSite=Lax;
|
|
4
|
+
* Path=/` with no `Domain`; `Secure` and the `__Host-` prefix are dropped only on `http://localhost` (`cookieSecurity`).
|
|
5
|
+
*/
|
|
6
|
+
const SESSION = "sf_session";
|
|
7
|
+
const SIGN_IN = "sf_signin";
|
|
8
|
+
/** Spec §6.2: the binding cookie lives as long as the challenge, 10 minutes. */
|
|
9
|
+
const SIGN_IN_SECONDS = 600;
|
|
10
|
+
const BROWSER_CART = "sf_browser_cart";
|
|
11
|
+
/** Spec §9.2: the browser-cart cookie lives 30 minutes. */
|
|
12
|
+
const BROWSER_CART_SECONDS = 1800;
|
|
13
|
+
/** A cart's sliding expiry on the API: 30 days after its last change. */
|
|
14
|
+
const CART_SECONDS = 30 * 24 * 3600;
|
|
15
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
16
|
+
/** The cookie security of the frontend's own origin: `{ secure: false }` only for `http://localhost` (and its loopback forms). */
|
|
17
|
+
export function cookieSecurity(origin) {
|
|
18
|
+
const url = new URL(origin);
|
|
19
|
+
return { secure: !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname)) };
|
|
20
|
+
}
|
|
21
|
+
const named = (base, { secure }) => (secure ? "__Host-" + base : base);
|
|
22
|
+
const spec = (name, value, maxAge, { secure }) => ({ name, value, httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: Math.max(0, Math.floor(maxAge)) });
|
|
23
|
+
export const sessionCookie = Object.assign((token, expiresAt, opts) => spec(named(SESSION, opts), token, (new Date(expiresAt).getTime() - (opts.now ?? Date.now())) / 1000, opts), {
|
|
24
|
+
clear: (opts) => spec(named(SESSION, opts), "", 0, opts),
|
|
25
|
+
cookieName: (opts) => named(SESSION, opts),
|
|
26
|
+
});
|
|
27
|
+
export const signInCookie = Object.assign((binding, opts) => spec(named(SIGN_IN, opts), opts.challengeId ? `${opts.challengeId}.${binding}` : binding, SIGN_IN_SECONDS, opts), {
|
|
28
|
+
clear: (opts) => spec(named(SIGN_IN, opts), "", 0, opts),
|
|
29
|
+
cookieName: (opts) => named(SIGN_IN, opts),
|
|
30
|
+
});
|
|
31
|
+
/** The sign-in cookie's value back: the binding and, when it was stored, the challenge id. Null when absent or empty. */
|
|
32
|
+
export function readSignInCookie(value) {
|
|
33
|
+
if (!value)
|
|
34
|
+
return null;
|
|
35
|
+
// A binding is base64url and a challenge id a uuid: neither holds a '.'.
|
|
36
|
+
const dot = value.indexOf(".");
|
|
37
|
+
return dot < 0 ? { binding: value, challengeId: null } : { binding: value.slice(dot + 1), challengeId: value.slice(0, dot) || null };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The frontend's own cart cookie, by the name the frontend gave it (no prefix is added). The SDK sets it only when a sign-in or a
|
|
41
|
+
* switch hands back another cart (spec §7.8; for as long as a cart lives untouched, 30 days) and clears it at sign-out (§9.2).
|
|
42
|
+
*/
|
|
43
|
+
export function cartCookie(name) {
|
|
44
|
+
return {
|
|
45
|
+
set: (token, opts) => spec(name, token, CART_SECONDS, opts),
|
|
46
|
+
clear: (opts) => spec(name, "", 0, opts),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export const browserCartCookie = Object.assign((token, opts) => spec(named(BROWSER_CART, opts), token, BROWSER_CART_SECONDS, opts), {
|
|
50
|
+
clear: (opts) => spec(named(BROWSER_CART, opts), "", 0, opts),
|
|
51
|
+
cookieName: (opts) => named(BROWSER_CART, opts),
|
|
52
|
+
});
|
|
53
|
+
/** A `Set-Cookie` header value for a route handler or middleware. */
|
|
54
|
+
export function serializeCookie(c) {
|
|
55
|
+
return `${c.name}=${encodeURIComponent(c.value)}; Path=${c.path}; Max-Age=${c.maxAge}; HttpOnly${c.secure ? "; Secure" : ""}; SameSite=Lax`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asstio-dev/storefront/account` — shopper accounts (sign-in and sessions), **server-only** (accounts spec §7.1, §11.4).
|
|
3
|
+
*
|
|
4
|
+
* Every call needs the site's server key (`sfk_srv_…`), which never reaches a browser, and the session token travels in
|
|
5
|
+
* `X-Shopper-Session`, which the API refuses from a browser. Create one client per request with that request's session:
|
|
6
|
+
* a client is never a module-level singleton, so one shopper's token can never serve another shopper's request.
|
|
7
|
+
*/
|
|
8
|
+
import { type Client } from "openapi-fetch";
|
|
9
|
+
import type { components } from "../schema";
|
|
10
|
+
import type { StorefrontPaths } from "../index.js";
|
|
11
|
+
export type SignInStartedDto = components["schemas"]["SignInStartedDto"];
|
|
12
|
+
export type SignedInDto = components["schemas"]["SignedInDto"];
|
|
13
|
+
export type SessionDto = components["schemas"]["SessionDto"];
|
|
14
|
+
export type AccountDto = components["schemas"]["AccountDto"];
|
|
15
|
+
export type MyCompanyDto = components["schemas"]["MyCompanyDto"];
|
|
16
|
+
export type PrincipalDto = components["schemas"]["PrincipalDto"];
|
|
17
|
+
export type SiteAccountsDto = components["schemas"]["SiteAccountsDto"];
|
|
18
|
+
/** The problem body; `maskedEmail` and `siteName` come with `409 SIGN_IN_CONFIRM_REQUIRED`. */
|
|
19
|
+
export type AccountProblem = components["schemas"]["StorefrontProblem"] & {
|
|
20
|
+
maskedEmail?: string;
|
|
21
|
+
siteName?: string;
|
|
22
|
+
};
|
|
23
|
+
/** The repo's result shape: the data, or the HTTP status and the problem (branch on `problem.code`). */
|
|
24
|
+
export type AccountResult<T> = {
|
|
25
|
+
ok: true;
|
|
26
|
+
data: T;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
status: number;
|
|
30
|
+
problem: AccountProblem;
|
|
31
|
+
retryAfter?: number;
|
|
32
|
+
};
|
|
33
|
+
export interface ShopperClientOptions {
|
|
34
|
+
/** e.g. https://api.storefront.asstio.com */
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
/** The site's server key (`sfk_srv_…`). Server-side only. */
|
|
37
|
+
serverKey: string;
|
|
38
|
+
/** Market code, e.g. "se". */
|
|
39
|
+
market: string;
|
|
40
|
+
/** The request's session token (`sfs_…`) from the session cookie; omit when the shopper is signed out. */
|
|
41
|
+
session?: string | null;
|
|
42
|
+
/** The shopper's IP (`shopperIpFrom`); the sign-in rate limits are per shopper IP. `null` when it cannot be known. */
|
|
43
|
+
shopperIp: string | null;
|
|
44
|
+
/** BCP-47 tag; omit to use the market's language. */
|
|
45
|
+
acceptLanguage?: string;
|
|
46
|
+
fetch?: typeof fetch;
|
|
47
|
+
}
|
|
48
|
+
/** A client for one request. Construct it per request; never cache it beyond that request. */
|
|
49
|
+
export type ShopperClient = Client<StorefrontPaths>;
|
|
50
|
+
/**
|
|
51
|
+
* A request-scoped client: every call carries the server key, the market, the shopper IP and (when given) the session, and
|
|
52
|
+
* every fetch is `cache: "no-store"` — an account answer is never shared or cached (spec §10.1).
|
|
53
|
+
*/
|
|
54
|
+
export declare function createShopperClient(opts: ShopperClientOptions): ShopperClient;
|
|
55
|
+
/** `POST /v1/auth/sign-in`. The 202 is the same whether or not the address may sign in; keep `binding` in the sign-in cookie
|
|
56
|
+
* (`signInCookie`) and never show it. An unsafe `returnPath` is dropped by the API. */
|
|
57
|
+
export declare function startSignIn(client: ShopperClient, email: string, returnPath?: string | null): Promise<AccountResult<SignInStartedDto>>;
|
|
58
|
+
export interface VerifySignInInput {
|
|
59
|
+
/** The start answer's challenge id: required with `code`. Ignored with `link`, which carries its own (ruling R12). */
|
|
60
|
+
challengeId?: string | null;
|
|
61
|
+
/** The 8-digit code; spaces allowed. Exactly one of `code` and `link`. */
|
|
62
|
+
code?: string | null;
|
|
63
|
+
/** The `sfl_{challengeId}.{secret}` token from the link's fragment (`readLinkFragment`), or the whole sign-in URL. */
|
|
64
|
+
link?: string | null;
|
|
65
|
+
/** The start answer's binding, from the sign-in cookie, when this browser has it. */
|
|
66
|
+
binding?: string | null;
|
|
67
|
+
/** The shopper's answer to the `409 SIGN_IN_CONFIRM_REQUIRED` question. */
|
|
68
|
+
confirm?: boolean | null;
|
|
69
|
+
}
|
|
70
|
+
/** The link token of a whole sign-in URL or a bare token: the part after the last '#', as the API reads it. */
|
|
71
|
+
export declare function linkToken(link: string): string;
|
|
72
|
+
/** The challenge id a link token (or a whole sign-in URL) carries (`sfl_{challengeId:N}.{secret}`, ruling R12), or null. */
|
|
73
|
+
export declare function linkChallengeId(link: string): string | null;
|
|
74
|
+
/** A failure that never reached the API (`status: 0`). */
|
|
75
|
+
export declare function localFailure(code: string, detail: string): {
|
|
76
|
+
ok: false;
|
|
77
|
+
status: number;
|
|
78
|
+
problem: AccountProblem;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* `POST /v1/auth/sign-in/verify` → the session (`409 SIGN_IN_CONFIRM_REQUIRED` without the binding until `confirm: true`).
|
|
82
|
+
* With a link the challenge id is the link's own; a code without a challenge id fails locally (`SIGN_IN_CHALLENGE_MISSING`,
|
|
83
|
+
* no request, no rate-limit slot spent).
|
|
84
|
+
*/
|
|
85
|
+
export declare function verifySignIn(client: ShopperClient, input: VerifySignInInput, cartToken?: string | null): Promise<AccountResult<SignedInDto>>;
|
|
86
|
+
/** `POST /v1/auth/sign-out` (the client's session); `everywhere` ends every session of the account. */
|
|
87
|
+
export declare function signOut(client: ShopperClient, opts?: {
|
|
88
|
+
everywhere?: boolean;
|
|
89
|
+
}): Promise<AccountResult<void>>;
|
|
90
|
+
/** `PUT /v1/session/principal`: `"consumer"` or a membership id. The answer carries a rotated token: store it at once. */
|
|
91
|
+
export declare function switchPrincipal(client: ShopperClient, principal: string): Promise<AccountResult<SignedInDto>>;
|
|
92
|
+
/** `GET /v1/session`. `401 SESSION_INVALID` means signed out: render so, and clear the cookie in the next action. */
|
|
93
|
+
export declare function getSession(client: ShopperClient): Promise<AccountResult<SessionDto>>;
|
|
94
|
+
/** `GET /v1/me`. */
|
|
95
|
+
export declare function getMe(client: ShopperClient): Promise<AccountResult<AccountDto>>;
|
|
96
|
+
/** `GET /v1/me/company`: `403 PRINCIPAL_NOT_AVAILABLE` while the principal is the consumer. */
|
|
97
|
+
export declare function getMyCompany(client: ShopperClient): Promise<AccountResult<MyCompanyDto>>;
|
|
98
|
+
export { copyBrowserCart, copyKey, CopyBrowserCartError, type CopyBrowserCartResult } from "./cart.js";
|
|
99
|
+
export { browserCartCookie, cartCookie, cookieSecurity, readSignInCookie, serializeCookie, sessionCookie, signInCookie, type CookieSecurity, type CookieSpec, type SignInCookieValue, } from "./cookies.js";
|
|
100
|
+
export { assertSameOrigin, CrossOriginError, safeReturnPath, shopperIpFrom, type HeadersLike } from "./security.js";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asstio-dev/storefront/account` — shopper accounts (sign-in and sessions), **server-only** (accounts spec §7.1, §11.4).
|
|
3
|
+
*
|
|
4
|
+
* Every call needs the site's server key (`sfk_srv_…`), which never reaches a browser, and the session token travels in
|
|
5
|
+
* `X-Shopper-Session`, which the API refuses from a browser. Create one client per request with that request's session:
|
|
6
|
+
* a client is never a module-level singleton, so one shopper's token can never serve another shopper's request.
|
|
7
|
+
*/
|
|
8
|
+
import createClient from "openapi-fetch";
|
|
9
|
+
/**
|
|
10
|
+
* A request-scoped client: every call carries the server key, the market, the shopper IP and (when given) the session, and
|
|
11
|
+
* every fetch is `cache: "no-store"` — an account answer is never shared or cached (spec §10.1).
|
|
12
|
+
*/
|
|
13
|
+
export function createShopperClient(opts) {
|
|
14
|
+
if (typeof globalThis.window !== "undefined") {
|
|
15
|
+
throw new Error("@asstio-dev/storefront/account is server-only: the server key and the session token never reach a browser.");
|
|
16
|
+
}
|
|
17
|
+
const baseFetch = opts.fetch ?? globalThis.fetch;
|
|
18
|
+
// The init's cache wins in Next.js's patched fetch; the Request's carries it everywhere else.
|
|
19
|
+
const noStore = ((input, init) => baseFetch(input, { ...init, cache: "no-store" }));
|
|
20
|
+
const client = createClient({ baseUrl: opts.baseUrl.replace(/\/$/, ""), fetch: noStore, cache: "no-store" });
|
|
21
|
+
const session = opts.session || null;
|
|
22
|
+
const headers = {
|
|
23
|
+
onRequest({ request }) {
|
|
24
|
+
request.headers.set("X-Site-Key", opts.serverKey);
|
|
25
|
+
request.headers.set("X-Market", opts.market);
|
|
26
|
+
if (opts.acceptLanguage)
|
|
27
|
+
request.headers.set("Accept-Language", opts.acceptLanguage);
|
|
28
|
+
if (opts.shopperIp)
|
|
29
|
+
request.headers.set("X-Shopper-Ip", opts.shopperIp);
|
|
30
|
+
if (session)
|
|
31
|
+
request.headers.set("X-Shopper-Session", session);
|
|
32
|
+
return request;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
client.use(headers);
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
/** `POST /v1/auth/sign-in`. The 202 is the same whether or not the address may sign in; keep `binding` in the sign-in cookie
|
|
39
|
+
* (`signInCookie`) and never show it. An unsafe `returnPath` is dropped by the API. */
|
|
40
|
+
export async function startSignIn(client, email, returnPath) {
|
|
41
|
+
return answer(await client.POST("/v1/auth/sign-in", { body: { email, returnPath: returnPath ?? null } }));
|
|
42
|
+
}
|
|
43
|
+
/** The link token of a whole sign-in URL or a bare token: the part after the last '#', as the API reads it. */
|
|
44
|
+
export function linkToken(link) {
|
|
45
|
+
const hash = link.lastIndexOf("#");
|
|
46
|
+
return hash >= 0 ? link.slice(hash + 1) : link;
|
|
47
|
+
}
|
|
48
|
+
const LINK_TOKEN = /^sfl_([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})\.[A-Za-z0-9_-]{43}$/;
|
|
49
|
+
/** The challenge id a link token (or a whole sign-in URL) carries (`sfl_{challengeId:N}.{secret}`, ruling R12), or null. */
|
|
50
|
+
export function linkChallengeId(link) {
|
|
51
|
+
const m = LINK_TOKEN.exec(linkToken(link));
|
|
52
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` : null;
|
|
53
|
+
}
|
|
54
|
+
/** A failure that never reached the API (`status: 0`). */
|
|
55
|
+
export function localFailure(code, detail) {
|
|
56
|
+
return { ok: false, status: 0, problem: { type: "https://docs.asstio.com/storefront/errors#" + code, title: code, status: 0, detail, code } };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `POST /v1/auth/sign-in/verify` → the session (`409 SIGN_IN_CONFIRM_REQUIRED` without the binding until `confirm: true`).
|
|
60
|
+
* With a link the challenge id is the link's own; a code without a challenge id fails locally (`SIGN_IN_CHALLENGE_MISSING`,
|
|
61
|
+
* no request, no rate-limit slot spent).
|
|
62
|
+
*/
|
|
63
|
+
export async function verifySignIn(client, input, cartToken) {
|
|
64
|
+
const challengeId = input.link ? linkChallengeId(input.link) : input.challengeId || null;
|
|
65
|
+
if (!input.link && !challengeId)
|
|
66
|
+
return localFailure("SIGN_IN_CHALLENGE_MISSING", "A code needs the challenge id of its sign-in start.");
|
|
67
|
+
return answer(await client.POST("/v1/auth/sign-in/verify", {
|
|
68
|
+
body: { challengeId, code: input.code ?? null, link: input.link ? linkToken(input.link) : null, binding: input.binding ?? null, confirm: input.confirm ?? null },
|
|
69
|
+
headers: cartToken ? { "X-Cart-Token": cartToken } : {},
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** `POST /v1/auth/sign-out` (the client's session); `everywhere` ends every session of the account. */
|
|
73
|
+
export async function signOut(client, opts = {}) {
|
|
74
|
+
const { error, response } = await client.POST("/v1/auth/sign-out", { body: { everywhere: opts.everywhere ?? false } });
|
|
75
|
+
if (response.ok)
|
|
76
|
+
return { ok: true, data: undefined };
|
|
77
|
+
return failure(response, error);
|
|
78
|
+
}
|
|
79
|
+
/** `PUT /v1/session/principal`: `"consumer"` or a membership id. The answer carries a rotated token: store it at once. */
|
|
80
|
+
export async function switchPrincipal(client, principal) {
|
|
81
|
+
return answer(await client.PUT("/v1/session/principal", { body: { principal } }));
|
|
82
|
+
}
|
|
83
|
+
/** `GET /v1/session`. `401 SESSION_INVALID` means signed out: render so, and clear the cookie in the next action. */
|
|
84
|
+
export async function getSession(client) {
|
|
85
|
+
return answer(await client.GET("/v1/session"));
|
|
86
|
+
}
|
|
87
|
+
/** `GET /v1/me`. */
|
|
88
|
+
export async function getMe(client) {
|
|
89
|
+
return answer(await client.GET("/v1/me"));
|
|
90
|
+
}
|
|
91
|
+
/** `GET /v1/me/company`: `403 PRINCIPAL_NOT_AVAILABLE` while the principal is the consumer. */
|
|
92
|
+
export async function getMyCompany(client) {
|
|
93
|
+
return answer(await client.GET("/v1/me/company"));
|
|
94
|
+
}
|
|
95
|
+
function answer(r) {
|
|
96
|
+
if (r.response.ok && r.data !== undefined && r.data !== null && r.error === undefined)
|
|
97
|
+
return { ok: true, data: r.data };
|
|
98
|
+
return failure(r.response, r.error);
|
|
99
|
+
}
|
|
100
|
+
function failure(response, error) {
|
|
101
|
+
const problem = isProblem(error)
|
|
102
|
+
? error
|
|
103
|
+
: { type: "https://docs.asstio.com/storefront/errors#SDK_EMPTY_ANSWER", title: "SDK_EMPTY_ANSWER", status: response.status, detail: "no problem body", code: "SDK_EMPTY_ANSWER" };
|
|
104
|
+
const retry = Number(response.headers.get("Retry-After"));
|
|
105
|
+
return Number.isFinite(retry) && retry > 0 && response.headers.has("Retry-After")
|
|
106
|
+
? { ok: false, status: response.status, problem, retryAfter: Math.ceil(retry) }
|
|
107
|
+
: { ok: false, status: response.status, problem };
|
|
108
|
+
}
|
|
109
|
+
function isProblem(value) {
|
|
110
|
+
return typeof value === "object" && value !== null && typeof value.code === "string";
|
|
111
|
+
}
|
|
112
|
+
export { copyBrowserCart, copyKey, CopyBrowserCartError } from "./cart.js";
|
|
113
|
+
export { browserCartCookie, cartCookie, cookieSecurity, readSignInCookie, serializeCookie, sessionCookie, signInCookie, } from "./cookies.js";
|
|
114
|
+
export { assertSameOrigin, CrossOriginError, safeReturnPath, shopperIpFrom } from "./security.js";
|