@wocart/storefront 1.16.0 → 1.18.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/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ follows [semantic versioning](https://semver.org/): breaking changes ship only
5
5
  in a new major version, and an export is marked `@deprecated` for at least one
6
6
  minor release before a major removes it.
7
7
 
8
+ ## 1.18.0 — 2026-10-21
9
+
10
+ The basket carries the offer a shopper chose for a line, so a quantity tier
11
+ that two offers share is priced as the shopper picked it.
12
+
13
+ ### Added
14
+
15
+ - `BasketLine.offerId` (optional): the offer chosen for the line. `addLine`
16
+ keeps it; a plain add to the line keeps the chosen offer, and a later explicit
17
+ choice replaces it. A stored offer survives a reload; a value that is not a
18
+ string is dropped.
19
+ - `toOrderItems` and `BasketStore.toOrderItems()` send `offerId` with the line,
20
+ so checkout and cart validation receive the choice. A line that chose no offer
21
+ sends none.
22
+
23
+ ## 1.17.0 — 2026-10-18
24
+
25
+ ### Added
26
+
27
+ - **Revalidation** (`@wocart/storefront/server`): `readRevalidateRequest`
28
+ and `REVALIDATE_PATH` (`/wocart-revalidate`), with `RevalidateClaims`. The
29
+ platform tells a storefront its pages changed — a publish — with a `POST`
30
+ carrying a bearer token signed by the same deployment key as previews but
31
+ made for this use only (`use: "revalidate"`, five minutes at most, for the
32
+ storefront's origin and theme); the theme then drops its cached pages, so a
33
+ publish shows at once. Without the key, revalidation is off.
34
+
35
+ ### Changed
36
+
37
+ - `verifyPreviewToken` refuses a token made for another use (`use` other
38
+ than `"preview"`). Tokens without `use` are accepted as before.
39
+
8
40
  ## 1.16.0 — 2026-10-18
9
41
 
10
42
  ### Changed
package/README.md CHANGED
@@ -68,7 +68,7 @@ if (result.success) {
68
68
  | Import | Runs in | What it holds |
69
69
  | --- | --- | --- |
70
70
  | `@wocart/storefront` | browser and server | read-model types and schemas, reference data (58 wilayas, 1,551 communes), money and image helpers, the basket and OTP state machines, language routing, Meta pixel payloads |
71
- | `@wocart/storefront/server` | server only | `createStoreClient`, `StoreClient`, `ClientResult`, `StorefrontError` — the API key lives here — and the preview checks (`readPreviewRequest`, `verifyPreviewToken`, `previewResponseHeaders`, `previewConfig`) |
71
+ | `@wocart/storefront/server` | server only | `createStoreClient`, `StoreClient`, `ClientResult`, `StorefrontError` — the API key lives here — the preview checks (`readPreviewRequest`, `verifyPreviewToken`, `previewResponseHeaders`, `previewConfig`) and revalidation (`readRevalidateRequest`) |
72
72
  | `@wocart/storefront/astro` | server only | `createStorefrontRoutes`, `createProxyEndpoint`, `placeOrderInput`, `executePlaceOrder`, `collectForwardedHeaders`, `runFormAction` |
73
73
 
74
74
  Browser code never receives the key. It calls your own same-origin routes
package/USAGE.md CHANGED
@@ -1639,6 +1639,32 @@ A storefront can verify tokens but never make one: the private key stays with
1639
1639
  the editor's server. The page side — marking sections and talking to the
1640
1640
  editor — is `@wocart/theme`'s (its README, "Preview").
1641
1641
 
1642
+ ## Showing a publish at once (revalidation)
1643
+
1644
+ A theme that caches its pages (route rules with `maxAge` and `tags`) shows a
1645
+ merchant's publish when the cache expires. To show it at once, the theme
1646
+ answers the platform's revalidation request and drops its pages:
1647
+
1648
+ ```ts
1649
+ // src/pages/wocart-revalidate.ts — REVALIDATE_PATH
1650
+ import type { APIRoute } from "astro";
1651
+ import { readRevalidateRequest } from "@wocart/storefront/server";
1652
+ export const prerender = false;
1653
+ export const POST: APIRoute = async (context) => {
1654
+ const revalidation = await readRevalidateRequest(context.request, { publicKey: WOCART_PREVIEW_KEY, theme: THEME_KEY });
1655
+ if (!revalidation.ok) return new Response(null, { status: revalidation.status });
1656
+ await context.cache.invalidate({ tags: ["pages"] }); // the tag every cached page carries
1657
+ return new Response(null, { status: 204 });
1658
+ };
1659
+ ```
1660
+
1661
+ The request is a `POST` from the platform's server with
1662
+ `Authorization: Bearer <token>`: signed with the same key as previews, but
1663
+ made only for revalidation (`use: "revalidate"`), for this storefront's
1664
+ origin and this theme, living five minutes at most. A preview token can
1665
+ never purge a cache, and a revalidation token can never open a preview.
1666
+ Astro's `cache.invalidate` purges the tag on Cloudflare and on Vercel alike.
1667
+
1642
1668
  ## A recommended theme structure
1643
1669
 
1644
1670
  Keep the theme visually expressive while making the platform boundary obvious:
package/dist/basket.js CHANGED
@@ -107,6 +107,7 @@ function sanitizeLine(raw, maxQuantity) {
107
107
  unitPrice: Number.isFinite(unitPrice) && unitPrice >= 0 ? unitPrice : 0,
108
108
  href: typeof line.href === "string" ? line.href : undefined,
109
109
  image: typeof line.image === "string" ? line.image : undefined,
110
+ offerId: typeof line.offerId === "string" && line.offerId ? line.offerId : undefined,
110
111
  };
111
112
  }
112
113
  function normalizeLines(lines, maxLines, maxQuantity) {
@@ -119,6 +120,8 @@ function normalizeLines(lines, maxLines, maxQuantity) {
119
120
  existing.variantLabel ??= line.variantLabel;
120
121
  existing.image ??= line.image;
121
122
  existing.href ??= line.href;
123
+ // The newest explicit choice of offer wins; a plain add keeps the one chosen.
124
+ existing.offerId = line.offerId ?? existing.offerId;
122
125
  }
123
126
  else {
124
127
  merged.set(key, { ...line });
@@ -143,6 +146,7 @@ export function toOrderItems(lines) {
143
146
  variantLabel: line.variantLabel ?? undefined,
144
147
  quantity: line.quantity,
145
148
  pricePerUnit: line.unitPrice,
149
+ ...(line.offerId !== undefined ? { offerId: line.offerId } : {}),
146
150
  }));
147
151
  }
148
152
  /**
package/dist/domain.d.ts CHANGED
@@ -75,6 +75,7 @@ export declare const BasketLineSchema: z.ZodObject<{
75
75
  unitPrice: z.ZodNumber;
76
76
  href: z.ZodOptional<z.ZodString>;
77
77
  image: z.ZodOptional<z.ZodString>;
78
+ offerId: z.ZodOptional<z.ZodString>;
78
79
  }, z.core.$strip>;
79
80
  export type BasketLine = z.infer<typeof BasketLineSchema>;
80
81
  /**
@@ -106,6 +107,7 @@ export declare const BasketSchema: z.ZodObject<{
106
107
  unitPrice: z.ZodNumber;
107
108
  href: z.ZodOptional<z.ZodString>;
108
109
  image: z.ZodOptional<z.ZodString>;
110
+ offerId: z.ZodOptional<z.ZodString>;
109
111
  }, z.core.$strip>>;
110
112
  }, z.core.$strip>;
111
113
  export type Basket = z.infer<typeof BasketSchema>;
package/dist/domain.js CHANGED
@@ -31,6 +31,11 @@ export const BasketLineSchema = z.object({
31
31
  unitPrice: z.number().finite().nonnegative(),
32
32
  href: z.string().min(1).optional(),
33
33
  image: z.string().min(1).optional(),
34
+ /**
35
+ * The offer the shopper chose for this line, when they picked a tier. The
36
+ * server still applies it only if it holds for the quantity.
37
+ */
38
+ offerId: z.string().min(1).max(200).optional(),
34
39
  });
35
40
  /**
36
41
  * The server-facing projection of a basket line. UI-only fields are omitted,
package/dist/preview.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * editor's origin, `iat`/`exp` (at most an hour apart), optional `sub`/`jti`.
15
15
  */
16
16
  import type { StoreConfig } from "./types.js";
17
+ import { type SignedTokenRefusal } from "./signed-token.js";
17
18
  /** What a verified token says. */
18
19
  export interface PreviewClaims {
19
20
  /** The storefront origin the token was made for. */
@@ -27,7 +28,7 @@ export interface PreviewClaims {
27
28
  sub?: string;
28
29
  jti?: string;
29
30
  }
30
- export type PreviewTokenRefusal = "malformed" | "unsupported-algorithm" | "invalid-key" | "bad-signature" | "invalid-claims" | "expired" | "not-yet-valid" | "too-long-lived" | "wrong-audience" | "wrong-theme";
31
+ export type PreviewTokenRefusal = SignedTokenRefusal;
31
32
  export type PreviewTokenCheck = {
32
33
  ok: true;
33
34
  claims: PreviewClaims;
@@ -45,7 +46,11 @@ export interface PreviewTokenOptions {
45
46
  /** Seconds since the epoch; defaults to now. */
46
47
  now?: number;
47
48
  }
48
- /** Check a preview token's signature and claims against this storefront and theme. */
49
+ /**
50
+ * Check a preview token's signature and claims against this storefront and
51
+ * theme. A token made for another use (`use` other than `"preview"`) is
52
+ * refused, so no other signed request can open a preview.
53
+ */
49
54
  export declare function verifyPreviewToken(token: string, options: PreviewTokenOptions): Promise<PreviewTokenCheck>;
50
55
  /**
51
56
  * What an editor sends to be previewed. The storefront renders it as if it
package/dist/preview.js CHANGED
@@ -1,32 +1,7 @@
1
- /** Clock skew tolerated between the editor's server and the storefront. */
2
- const CLOCK_SKEW_SECONDS = 30;
1
+ import { timingRefusal, verifiedPayload } from "./signed-token.js";
3
2
  /** The longest a preview token may live. */
4
3
  const MAX_LIFETIME_SECONDS = 60 * 60;
5
- const decoder = new TextDecoder();
6
4
  const encoder = new TextEncoder();
7
- function base64UrlBytes(value) {
8
- if (!/^[A-Za-z0-9_-]*$/.test(value))
9
- return null;
10
- try {
11
- const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4));
12
- return Uint8Array.from(binary, (char) => char.charCodeAt(0));
13
- }
14
- catch {
15
- return null;
16
- }
17
- }
18
- function base64UrlJson(value) {
19
- const bytes = base64UrlBytes(value);
20
- if (bytes === null)
21
- return null;
22
- try {
23
- const parsed = JSON.parse(decoder.decode(bytes));
24
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
25
- }
26
- catch {
27
- return null;
28
- }
29
- }
30
5
  const isOrigin = (value) => {
31
6
  if (typeof value !== "string")
32
7
  return false;
@@ -38,44 +13,16 @@ const isOrigin = (value) => {
38
13
  return false;
39
14
  }
40
15
  };
41
- async function importKey(publicKey) {
42
- try {
43
- const jwk = typeof publicKey === "string" ? JSON.parse(publicKey) : publicKey;
44
- if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || jwk.d !== undefined)
45
- return null;
46
- return await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
47
- }
48
- catch {
49
- return null;
50
- }
51
- }
52
- /** Check a preview token's signature and claims against this storefront and theme. */
16
+ /**
17
+ * Check a preview token's signature and claims against this storefront and
18
+ * theme. A token made for another use (`use` other than `"preview"`) is
19
+ * refused, so no other signed request can open a preview.
20
+ */
53
21
  export async function verifyPreviewToken(token, options) {
54
- const parts = typeof token === "string" ? token.split(".") : [];
55
- if (parts.length !== 3)
56
- return { ok: false, reason: "malformed" };
57
- const [headerPart, payloadPart, signaturePart] = parts;
58
- const header = base64UrlJson(headerPart);
59
- const payload = base64UrlJson(payloadPart);
60
- const signature = base64UrlBytes(signaturePart);
61
- if (header === null || payload === null || signature === null)
62
- return { ok: false, reason: "malformed" };
63
- if (header.alg !== "EdDSA" || header.crit !== undefined)
64
- return { ok: false, reason: "unsupported-algorithm" };
65
- const key = await importKey(options.publicKey);
66
- if (key === null)
67
- return { ok: false, reason: "invalid-key" };
68
- const signed = encoder.encode(`${headerPart}.${payloadPart}`);
69
- let valid = false;
70
- try {
71
- valid = await crypto.subtle.verify({ name: "Ed25519" }, key, signature, signed);
72
- }
73
- catch {
74
- valid = false;
75
- }
76
- if (!valid)
77
- return { ok: false, reason: "bad-signature" };
78
- const { aud, theme, editor, iat, exp, sub, jti } = payload;
22
+ const verified = await verifiedPayload(token, options.publicKey);
23
+ if (!verified.ok)
24
+ return verified;
25
+ const { aud, theme, editor, iat, exp, sub, jti, use } = verified.payload;
79
26
  if (typeof aud !== "string" ||
80
27
  typeof theme !== "string" ||
81
28
  !isOrigin(editor) ||
@@ -84,16 +31,13 @@ export async function verifyPreviewToken(token, options) {
84
31
  !Number.isFinite(iat) ||
85
32
  !Number.isFinite(exp) ||
86
33
  (sub !== undefined && typeof sub !== "string") ||
87
- (jti !== undefined && typeof jti !== "string")) {
34
+ (jti !== undefined && typeof jti !== "string") ||
35
+ (use !== undefined && use !== "preview")) {
88
36
  return { ok: false, reason: "invalid-claims" };
89
37
  }
90
- const now = options.now ?? Math.floor(Date.now() / 1000);
91
- if (exp <= now - CLOCK_SKEW_SECONDS)
92
- return { ok: false, reason: "expired" };
93
- if (iat > now + CLOCK_SKEW_SECONDS)
94
- return { ok: false, reason: "not-yet-valid" };
95
- if (exp - iat > MAX_LIFETIME_SECONDS)
96
- return { ok: false, reason: "too-long-lived" };
38
+ const timing = timingRefusal(iat, exp, options.now, MAX_LIFETIME_SECONDS);
39
+ if (timing !== null)
40
+ return { ok: false, reason: timing };
97
41
  if (aud !== options.audience)
98
42
  return { ok: false, reason: "wrong-audience" };
99
43
  if (theme !== options.theme)
@@ -0,0 +1,37 @@
1
+ /** Where a theme that can drop its cached pages answers a revalidation. */
2
+ export declare const REVALIDATE_PATH = "/wocart-revalidate";
3
+ /** What a verified revalidation token says. */
4
+ export interface RevalidateClaims {
5
+ /** The storefront origin the token was made for. */
6
+ aud: string;
7
+ /** The key of the theme whose pages changed. */
8
+ theme: string;
9
+ use: "revalidate";
10
+ iat: number;
11
+ exp: number;
12
+ sub?: string;
13
+ jti?: string;
14
+ }
15
+ export type RevalidateRequest = {
16
+ ok: true;
17
+ claims: RevalidateClaims;
18
+ } | {
19
+ ok: false;
20
+ status: 401 | 403 | 404 | 405;
21
+ reason: string;
22
+ };
23
+ export interface RevalidateRequestOptions {
24
+ /** The deployment's public key (a JWK) — the preview key; absent: revalidation is off and every request answers 404. */
25
+ publicKey?: string | JsonWebKey | undefined;
26
+ /** This theme's key. */
27
+ theme: string;
28
+ /** Seconds since the epoch; defaults to now. */
29
+ now?: number;
30
+ }
31
+ /**
32
+ * Read a revalidation request. `{ ok: true }` only for a `POST` carrying a
33
+ * token made for revalidation, for this storefront (its origin is the
34
+ * token's `aud`) and this theme; otherwise a status to answer, and nothing
35
+ * is dropped.
36
+ */
37
+ export declare function readRevalidateRequest(request: Request, options: RevalidateRequestOptions): Promise<RevalidateRequest>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Revalidation: the platform tells a storefront that what its pages show has
3
+ * changed — an editor published a theme's pages, settings or text — so the
4
+ * storefront drops the pages it cached and the next visit renders fresh.
5
+ *
6
+ * The request is a `POST` to the theme's revalidation route
7
+ * (`REVALIDATE_PATH`) from the platform's server, with a short-lived token in
8
+ * `Authorization: Bearer`. The token is signed like a preview token, with the
9
+ * same deployment key, but made for this use only (`use: "revalidate"`): a
10
+ * preview token, which a framed page can see, can never purge a cache, and a
11
+ * revalidation token can never open a preview.
12
+ *
13
+ * Claims: `aud` the storefront origin, `theme` the key of the theme whose
14
+ * pages changed, `use: "revalidate"`, `iat`/`exp` at most five minutes apart,
15
+ * optional `sub`/`jti`.
16
+ */
17
+ import { timingRefusal, verifiedPayload } from "./signed-token.js";
18
+ /** Where a theme that can drop its cached pages answers a revalidation. */
19
+ export const REVALIDATE_PATH = "/wocart-revalidate";
20
+ /** The longest a revalidation token may live. */
21
+ const MAX_LIFETIME_SECONDS = 5 * 60;
22
+ const refuse = (status, reason) => ({ ok: false, status, reason });
23
+ const refused = (reason) => refuse(403, `the token is refused: ${reason}`);
24
+ /**
25
+ * Read a revalidation request. `{ ok: true }` only for a `POST` carrying a
26
+ * token made for revalidation, for this storefront (its origin is the
27
+ * token's `aud`) and this theme; otherwise a status to answer, and nothing
28
+ * is dropped.
29
+ */
30
+ export async function readRevalidateRequest(request, options) {
31
+ if (options.publicKey === undefined || options.publicKey === "")
32
+ return refuse(404, "revalidation is not enabled on this storefront");
33
+ if (request.method !== "POST")
34
+ return refuse(405, "a revalidation is posted");
35
+ const bearer = /^Bearer ([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/.exec(request.headers.get("Authorization") ?? "");
36
+ if (bearer === null)
37
+ return refuse(401, "a revalidation carries its token as a bearer");
38
+ const verified = await verifiedPayload(bearer[1], options.publicKey);
39
+ if (!verified.ok)
40
+ return refused(verified.reason);
41
+ const { aud, theme, use, iat, exp, sub, jti } = verified.payload;
42
+ if (use !== "revalidate" ||
43
+ typeof aud !== "string" ||
44
+ typeof theme !== "string" ||
45
+ typeof iat !== "number" ||
46
+ typeof exp !== "number" ||
47
+ !Number.isFinite(iat) ||
48
+ !Number.isFinite(exp) ||
49
+ (sub !== undefined && typeof sub !== "string") ||
50
+ (jti !== undefined && typeof jti !== "string")) {
51
+ return refused("invalid-claims");
52
+ }
53
+ const timing = timingRefusal(iat, exp, options.now, MAX_LIFETIME_SECONDS);
54
+ if (timing !== null)
55
+ return refused(timing);
56
+ if (aud !== new URL(request.url).origin)
57
+ return refused("wrong-audience");
58
+ if (theme !== options.theme)
59
+ return refused("wrong-theme");
60
+ const claims = { aud, theme, use, iat, exp };
61
+ if (sub !== undefined)
62
+ claims.sub = sub;
63
+ if (jti !== undefined)
64
+ claims.jti = jti;
65
+ return { ok: true, claims };
66
+ }
package/dist/server.d.ts CHANGED
@@ -3,4 +3,6 @@ export type { StoreClient } from "./client/index.js";
3
3
  export type { ClientResult } from "./client/core.js";
4
4
  export { StorefrontError, isStorefrontError } from "./client/errors.js";
5
5
  export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPreviewToken } from "./preview.js";
6
+ export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
7
+ export type { RevalidateClaims, RevalidateRequest, RevalidateRequestOptions } from "./revalidate.js";
6
8
  export type { PreviewClaims, PreviewDraft, PreviewRequest, PreviewRequestOptions, PreviewTokenCheck, PreviewTokenOptions, PreviewTokenRefusal, } from "./preview.js";
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { createStoreClient } from "./client/index.js";
2
2
  export { StorefrontError, isStorefrontError } from "./client/errors.js";
3
3
  export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPreviewToken } from "./preview.js";
4
+ export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The signed requests a deployment's key checks: a compact JWS signed with
3
+ * Ed25519 (`alg: "EdDSA"`), verified with the public key the storefront was
4
+ * deployed with. A storefront can check such a token, never make one. Each
5
+ * kind of request — a preview, a revalidation — checks its own claims on
6
+ * top of this.
7
+ */
8
+ /** Why a signed token was refused. */
9
+ export type SignedTokenRefusal = "malformed" | "unsupported-algorithm" | "invalid-key" | "bad-signature" | "invalid-claims" | "expired" | "not-yet-valid" | "too-long-lived" | "wrong-audience" | "wrong-theme";
10
+ /** The token's payload once its signature checks out with `publicKey`. */
11
+ export declare function verifiedPayload(token: string, publicKey: string | JsonWebKey): Promise<{
12
+ ok: true;
13
+ payload: Record<string, unknown>;
14
+ } | {
15
+ ok: false;
16
+ reason: SignedTokenRefusal;
17
+ }>;
18
+ /** Why a token's times are refused at `now` (seconds; now by default), or null. */
19
+ export declare function timingRefusal(iat: number, exp: number, now: number | undefined, maxLifetimeSeconds: number): SignedTokenRefusal | null;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The signed requests a deployment's key checks: a compact JWS signed with
3
+ * Ed25519 (`alg: "EdDSA"`), verified with the public key the storefront was
4
+ * deployed with. A storefront can check such a token, never make one. Each
5
+ * kind of request — a preview, a revalidation — checks its own claims on
6
+ * top of this.
7
+ */
8
+ /** Clock skew tolerated between the signer and the storefront. */
9
+ const CLOCK_SKEW_SECONDS = 30;
10
+ const decoder = new TextDecoder();
11
+ const encoder = new TextEncoder();
12
+ function base64UrlBytes(value) {
13
+ if (!/^[A-Za-z0-9_-]*$/.test(value))
14
+ return null;
15
+ try {
16
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4));
17
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function base64UrlJson(value) {
24
+ const bytes = base64UrlBytes(value);
25
+ if (bytes === null)
26
+ return null;
27
+ try {
28
+ const parsed = JSON.parse(decoder.decode(bytes));
29
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ async function importKey(publicKey) {
36
+ try {
37
+ const jwk = typeof publicKey === "string" ? JSON.parse(publicKey) : publicKey;
38
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || jwk.d !== undefined)
39
+ return null;
40
+ return await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** The token's payload once its signature checks out with `publicKey`. */
47
+ export async function verifiedPayload(token, publicKey) {
48
+ const parts = typeof token === "string" ? token.split(".") : [];
49
+ if (parts.length !== 3)
50
+ return { ok: false, reason: "malformed" };
51
+ const [headerPart, payloadPart, signaturePart] = parts;
52
+ const header = base64UrlJson(headerPart);
53
+ const payload = base64UrlJson(payloadPart);
54
+ const signature = base64UrlBytes(signaturePart);
55
+ if (header === null || payload === null || signature === null)
56
+ return { ok: false, reason: "malformed" };
57
+ if (header.alg !== "EdDSA" || header.crit !== undefined)
58
+ return { ok: false, reason: "unsupported-algorithm" };
59
+ const key = await importKey(publicKey);
60
+ if (key === null)
61
+ return { ok: false, reason: "invalid-key" };
62
+ let valid = false;
63
+ try {
64
+ valid = await crypto.subtle.verify({ name: "Ed25519" }, key, signature, encoder.encode(`${headerPart}.${payloadPart}`));
65
+ }
66
+ catch {
67
+ valid = false;
68
+ }
69
+ return valid ? { ok: true, payload } : { ok: false, reason: "bad-signature" };
70
+ }
71
+ /** Why a token's times are refused at `now` (seconds; now by default), or null. */
72
+ export function timingRefusal(iat, exp, now, maxLifetimeSeconds) {
73
+ const at = now ?? Math.floor(Date.now() / 1000);
74
+ if (exp <= at - CLOCK_SKEW_SECONDS)
75
+ return "expired";
76
+ if (iat > at + CLOCK_SKEW_SECONDS)
77
+ return "not-yet-valid";
78
+ if (exp - iat > maxLifetimeSeconds)
79
+ return "too-long-lived";
80
+ return null;
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",