@wocart/storefront 1.15.0 → 1.17.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,34 @@ 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.17.0 — 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - **Revalidation** (`@wocart/storefront/server`): `readRevalidateRequest`
13
+ and `REVALIDATE_PATH` (`/wocart-revalidate`), with `RevalidateClaims`. The
14
+ platform tells a storefront its pages changed — a publish — with a `POST`
15
+ carrying a bearer token signed by the same deployment key as previews but
16
+ made for this use only (`use: "revalidate"`, five minutes at most, for the
17
+ storefront's origin and theme); the theme then drops its cached pages, so a
18
+ publish shows at once. Without the key, revalidation is off.
19
+
20
+ ### Changed
21
+
22
+ - `verifyPreviewToken` refuses a token made for another use (`use` other
23
+ than `"preview"`). Tokens without `use` are accepted as before.
24
+
25
+ ## 1.16.0 — 2026-10-18
26
+
27
+ ### Changed
28
+
29
+ - `pageData.get(surface)` reads the document the store composed for the
30
+ client's theme: a client created with `theme` sends it
31
+ (`/store/page-documents/{surface}?theme=`), so a store keeps one document
32
+ per theme and switching themes never shows another theme's page. Without
33
+ `theme`, the server answers the most recently saved document for the
34
+ surface, as before.
35
+
8
36
  ## 1.15.0 — 2026-10-18
9
37
 
10
38
  ### Added
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
@@ -884,8 +884,9 @@ Do not invent tracking events from a landing-page slug.
884
884
  const saved = await store.pageData.get("home"); // null = never saved / failed
885
885
  ~~~
886
886
 
887
- One document per surface ("home" today), owned by the store and written by
888
- the platform's page editor — a storefront never writes it. `null` means the store has never saved one (or the read
887
+ One document per theme and surface ("home" today), owned by the store and
888
+ written by the platform's page editor — a storefront never writes it. A
889
+ client created with `theme` reads the document composed for that theme. `null` means the store has never saved one (or the read
889
890
  failed): render the theme's shipped default document. The payload is
890
891
  **transport-checked only** (`version`, `surface`, `sections`) and fetched
891
892
  fresh on every call, so a save reaches the next render.
@@ -1638,6 +1639,32 @@ A storefront can verify tokens but never make one: the private key stays with
1638
1639
  the editor's server. The page side — marking sections and talking to the
1639
1640
  editor — is `@wocart/theme`'s (its README, "Preview").
1640
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
+
1641
1668
  ## A recommended theme structure
1642
1669
 
1643
1670
  Keep the theme visually expressive while making the platform boundary obvious:
@@ -2,8 +2,10 @@
2
2
  * Page Data Namespace — the store's saved composition document.
3
3
  *
4
4
  * Deep module: hides URL construction and transport behind one call. The
5
- * document belongs to the store (one per surface — "home" today) and is
6
- * written by the platform's editor; this namespace is the theme's read side.
5
+ * document belongs to the store (one per theme and surface — "home" today)
6
+ * and is written by the platform's editor; this namespace is the theme's
7
+ * read side. A client created with `theme` reads that theme's document;
8
+ * without it, the server answers the most recently saved one for the surface.
7
9
  *
8
10
  * Transport semantics (NOT the document contract): the payload is validated
9
11
  * as `{ version, surface, sections }` and returns `null` for "no saved
@@ -2,8 +2,10 @@
2
2
  * Page Data Namespace — the store's saved composition document.
3
3
  *
4
4
  * Deep module: hides URL construction and transport behind one call. The
5
- * document belongs to the store (one per surface — "home" today) and is
6
- * written by the platform's editor; this namespace is the theme's read side.
5
+ * document belongs to the store (one per theme and surface — "home" today)
6
+ * and is written by the platform's editor; this namespace is the theme's
7
+ * read side. A client created with `theme` reads that theme's document;
8
+ * without it, the server answers the most recently saved one for the surface.
7
9
  *
8
10
  * Transport semantics (NOT the document contract): the payload is validated
9
11
  * as `{ version, surface, sections }` and returns `null` for "no saved
@@ -24,7 +26,9 @@ export function createPageDataNamespace(core) {
24
26
  return {
25
27
  async get(surface) {
26
28
  try {
27
- const document = await core.get(`/store/page-documents/${encodeURIComponent(surface)}`, PageDocumentPayloadSchema.nullable(), { operation: STOREFRONT_OPERATIONS.pageDataGet });
29
+ const path = `/store/page-documents/${encodeURIComponent(surface)}`;
30
+ const theme = core.config.theme;
31
+ const document = await core.get(theme === undefined ? path : `${path}?${new URLSearchParams({ theme })}`, PageDocumentPayloadSchema.nullable(), { operation: STOREFRONT_OPERATIONS.pageDataGet });
28
32
  return document ?? null;
29
33
  }
30
34
  catch (error) {
package/dist/domain.d.ts CHANGED
@@ -12,7 +12,8 @@ export interface StoreScope {
12
12
  * The key of the theme this client renders (`defineTheme({ key })` in
13
13
  * `@wocart/theme`). The store's edits to that theme's text arrive as
14
14
  * `StoreConfig.contentOverrides`; without it there are none, because edits
15
- * belong to the theme that declares their keys. The server owns the key
15
+ * belong to the theme that declares their keys. `pageData.get` reads the
16
+ * documents the store composed for that theme. The server owns the key
16
17
  * format and refuses a malformed one.
17
18
  */
18
19
  readonly theme?: string;
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.15.0",
3
+ "version": "1.17.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",