@wocart/storefront 1.14.0 → 1.16.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 +28 -0
- package/README.md +1 -1
- package/USAGE.md +35 -2
- package/dist/client/page-data.d.ts +4 -2
- package/dist/client/page-data.js +7 -3
- package/dist/domain.d.ts +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/preview.d.ts +111 -0
- package/dist/preview.js +249 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/dist/utils/image.d.ts +12 -0
- package/dist/utils/image.js +33 -3
- package/package.json +1 -1
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.16.0 — 2026-10-18
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- `pageData.get(surface)` reads the document the store composed for the
|
|
13
|
+
client's theme: a client created with `theme` sends it
|
|
14
|
+
(`/store/page-documents/{surface}?theme=`), so a store keeps one document
|
|
15
|
+
per theme and switching themes never shows another theme's page. Without
|
|
16
|
+
`theme`, the server answers the most recently saved document for the
|
|
17
|
+
surface, as before.
|
|
18
|
+
|
|
19
|
+
## 1.15.0 — 2026-10-18
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `mediaUrl(key, mediaDomain)`: the public URL of a stored media key — what a
|
|
24
|
+
theme's `image` field holds (`{ key: "products/…" }`) — on the store's media
|
|
25
|
+
domain; `null` with no media domain or a key that cannot name an object. The
|
|
26
|
+
key is the image's identity; the URL is derived at render, never stored.
|
|
27
|
+
- **Preview** (`@wocart/storefront/server`): `readPreviewRequest`,
|
|
28
|
+
`verifyPreviewToken`, `previewResponseHeaders` and `previewConfig`, with
|
|
29
|
+
`PreviewClaims` and `PreviewDraft`. A storefront opens a preview (`GET`
|
|
30
|
+
with the token in the query) and renders a draft (`POST` JSON from its own
|
|
31
|
+
origin) only for an Ed25519-signed token (`alg: "EdDSA"`) made for its
|
|
32
|
+
origin and theme, living at most an hour; the answer can be framed by the
|
|
33
|
+
token's editor only and is never stored or indexed. Without a public key,
|
|
34
|
+
preview is off.
|
|
35
|
+
|
|
8
36
|
## 1.14.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 |
|
|
71
|
+
| `@wocart/storefront/server` | server only | `createStoreClient`, `StoreClient`, `ClientResult`, `StorefrontError` — the API key lives here — and the preview checks (`readPreviewRequest`, `verifyPreviewToken`, `previewResponseHeaders`, `previewConfig`) |
|
|
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
|
@@ -215,6 +215,7 @@ server directly.
|
|
|
215
215
|
| --- | --- |
|
|
216
216
|
| `formatMoney`, `optimizeImage`, `generateSrcSet` | Money text and image URLs |
|
|
217
217
|
| `mediaImageUrl` | The Cloudflare resizing URL of a media-domain image, served by the media domain itself (any storefront host); `null` for any other image — the builder behind every image helper and `@wocart/theme/image-service` |
|
|
218
|
+
| `mediaUrl(key, mediaDomain)` | The public URL of a stored media key — what a theme's `image` field holds (`{ key: "products/…" }`): `mediaUrl(image.key, MEDIA_DOMAIN)`; `null` with no media domain or a key that cannot name an object. The key is the identity; the URL is derived at render |
|
|
218
219
|
| `createImageHelpers(mediaDomain)`, `getCardImage`, `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`, `getLogoImage` | The bound image toolkit (`IMAGE_SIZES` holds the presets) |
|
|
219
220
|
| `WILAYAS`, `ALGERIA_COMMUNES`, `communesForWilaya` | Reference data: 58 wilayas, 1,551 communes, one wilaya's rows |
|
|
220
221
|
| `normalizeAlgerianPhone`, `toLocalAlgerianMobile` | The one Algerian phone rule (accept/format) |
|
|
@@ -883,8 +884,9 @@ Do not invent tracking events from a landing-page slug.
|
|
|
883
884
|
const saved = await store.pageData.get("home"); // null = never saved / failed
|
|
884
885
|
~~~
|
|
885
886
|
|
|
886
|
-
One document per surface ("home" today), owned by the store and
|
|
887
|
-
the platform's page editor — a storefront never writes it.
|
|
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
|
|
888
890
|
failed): render the theme's shipped default document. The payload is
|
|
889
891
|
**transport-checked only** (`version`, `surface`, `sections`) and fetched
|
|
890
892
|
fresh on every call, so a save reaches the next render.
|
|
@@ -1606,6 +1608,37 @@ Three rules come with it, and a theme must not re-implement them:
|
|
|
1606
1608
|
visible rather than silently swallowed. The cap and the token list are the
|
|
1607
1609
|
same ones the dashboard enforces.
|
|
1608
1610
|
|
|
1611
|
+
## Previewing a draft for an editor
|
|
1612
|
+
|
|
1613
|
+
A visual editor shows a merchant's unsaved changes on the store's own
|
|
1614
|
+
storefront. It frames the theme's preview route with a short-lived token its
|
|
1615
|
+
server signed — `GET /wocart-preview?token=…&surface=home&locale=fr` — and the
|
|
1616
|
+
framed page then re-renders each draft the editor sends it by posting the
|
|
1617
|
+
draft back to the same route as JSON. The storefront checks the token with a
|
|
1618
|
+
public key it was deployed with, renders what it was sent, and stores nothing.
|
|
1619
|
+
A draft is never a form post: Astro's `checkOrigin` (on by default) refuses
|
|
1620
|
+
cross-site forms, and the preview needs no exception to it. Everything lives in
|
|
1621
|
+
`@wocart/storefront/server`:
|
|
1622
|
+
|
|
1623
|
+
| Export | Use it for |
|
|
1624
|
+
| --- | --- |
|
|
1625
|
+
| `readPreviewRequest(request, { publicKey, theme })` | Read a preview request: the `GET` that opens it (`token`, `surface`, optional `locale` and `path` in the query; the draft names the page and nothing else, so it renders as saved) or the `POST` of a draft (JSON `{ token, draft }`, from this storefront's own origin only, 1 MB at most). `{ ok: true, claims, draft, token }` only when the token verifies for this storefront (its origin is the token's `aud`) and this theme; otherwise a status to answer — 404 without a key, 405, 403, 413, 415, 400 |
|
|
1626
|
+
| `verifyPreviewToken(token, { publicKey, audience, theme })` | The token check alone: a compact JWS signed with Ed25519 (`alg: "EdDSA"`), claims `aud`, `theme`, `editor` (an origin), `iat`/`exp` at most an hour apart |
|
|
1627
|
+
| `previewResponseHeaders(claims)` | The headers a preview answers with: `frame-ancestors` the token's editor only, `Cache-Control: private, no-store`, `noindex`, and a referrer kept on the storefront (the address holds the token) |
|
|
1628
|
+
| `previewConfig(config, draft)` | The store config the preview renders: the draft's theme settings and text edits over the saved ones, and no Meta pixel — a preview is not a visit |
|
|
1629
|
+
|
|
1630
|
+
```ts
|
|
1631
|
+
// src/middleware.ts — the one place a request's store context is built
|
|
1632
|
+
const preview = await readPreviewRequest(context.request, { publicKey: WOCART_PREVIEW_KEY, theme: THEME_KEY });
|
|
1633
|
+
if (!preview.ok) return new Response(null, { status: preview.status });
|
|
1634
|
+
context.locals.store = buildStoreContext(previewConfig(await client.config.get(), preview.draft), preview.draft.locale);
|
|
1635
|
+
```
|
|
1636
|
+
|
|
1637
|
+
The public key is a deployment setting (a JWK); without it preview is off.
|
|
1638
|
+
A storefront can verify tokens but never make one: the private key stays with
|
|
1639
|
+
the editor's server. The page side — marking sections and talking to the
|
|
1640
|
+
editor — is `@wocart/theme`'s (its README, "Preview").
|
|
1641
|
+
|
|
1609
1642
|
## A recommended theme structure
|
|
1610
1643
|
|
|
1611
1644
|
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)
|
|
6
|
-
* written by the platform's editor; this namespace is the theme's
|
|
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
|
package/dist/client/page-data.js
CHANGED
|
@@ -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)
|
|
6
|
-
* written by the platform's editor; this namespace is the theme's
|
|
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
|
|
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.
|
|
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/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { formatMoney } from "./utils/money.js";
|
|
13
13
|
export type { MoneyFormatOptions } from "./utils/money.js";
|
|
14
|
-
export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, } from "./utils/image.js";
|
|
14
|
+
export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaUrl, } from "./utils/image.js";
|
|
15
15
|
export type { ImageHelpers, ImageResizeOptions, MediaImageParams } from "./utils/image.js";
|
|
16
16
|
export { productImageSource } from "./utils/product-image.js";
|
|
17
17
|
export type { ProductImageSourceInput } from "./utils/product-image.js";
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Internal modules stay reachable only through their intentional entry points.
|
|
11
11
|
*/
|
|
12
12
|
export { formatMoney } from "./utils/money.js";
|
|
13
|
-
export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, } from "./utils/image.js";
|
|
13
|
+
export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaUrl, } from "./utils/image.js";
|
|
14
14
|
export { productImageSource } from "./utils/product-image.js";
|
|
15
15
|
export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, } from "./whatsapp.js";
|
|
16
16
|
export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview: rendering a draft on the store's own storefront, for an editor.
|
|
3
|
+
*
|
|
4
|
+
* An editor (a page on another origin) frames the storefront's preview route
|
|
5
|
+
* with a short-lived token its server signed; the framed page then re-renders
|
|
6
|
+
* each draft the editor sends it by posting the draft to the same route. The
|
|
7
|
+
* storefront verifies the token with a public key it was deployed with — it
|
|
8
|
+
* can check tokens, never make them — renders what it was sent, and answers so
|
|
9
|
+
* the response is framed only by that editor and never cached or indexed. The
|
|
10
|
+
* draft is never stored by the storefront.
|
|
11
|
+
*
|
|
12
|
+
* The token is a compact JWS signed with Ed25519 (`alg: "EdDSA"`), claims:
|
|
13
|
+
* `aud` the storefront origin, `theme` the previewed theme's key, `editor` the
|
|
14
|
+
* editor's origin, `iat`/`exp` (at most an hour apart), optional `sub`/`jti`.
|
|
15
|
+
*/
|
|
16
|
+
import type { StoreConfig } from "./types.js";
|
|
17
|
+
/** What a verified token says. */
|
|
18
|
+
export interface PreviewClaims {
|
|
19
|
+
/** The storefront origin the token was made for. */
|
|
20
|
+
aud: string;
|
|
21
|
+
/** The key of the theme it previews. */
|
|
22
|
+
theme: string;
|
|
23
|
+
/** The editor's origin: the only page that may frame the preview and send it drafts. */
|
|
24
|
+
editor: string;
|
|
25
|
+
iat: number;
|
|
26
|
+
exp: number;
|
|
27
|
+
sub?: string;
|
|
28
|
+
jti?: string;
|
|
29
|
+
}
|
|
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 PreviewTokenCheck = {
|
|
32
|
+
ok: true;
|
|
33
|
+
claims: PreviewClaims;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: PreviewTokenRefusal;
|
|
37
|
+
};
|
|
38
|
+
export interface PreviewTokenOptions {
|
|
39
|
+
/** The Ed25519 public key, as a JWK (`{"kty":"OKP","crv":"Ed25519","x":"…"}`, a JSON string or object). */
|
|
40
|
+
publicKey: string | JsonWebKey;
|
|
41
|
+
/** This storefront's origin (`https://store.example`). */
|
|
42
|
+
audience: string;
|
|
43
|
+
/** This theme's key. */
|
|
44
|
+
theme: string;
|
|
45
|
+
/** Seconds since the epoch; defaults to now. */
|
|
46
|
+
now?: number;
|
|
47
|
+
}
|
|
48
|
+
/** Check a preview token's signature and claims against this storefront and theme. */
|
|
49
|
+
export declare function verifyPreviewToken(token: string, options: PreviewTokenOptions): Promise<PreviewTokenCheck>;
|
|
50
|
+
/**
|
|
51
|
+
* What an editor sends to be previewed. The storefront renders it as if it
|
|
52
|
+
* were saved — `document` for `surface`, `settings` for the theme settings,
|
|
53
|
+
* `content` for the text edits of the languages it names — and stores none of
|
|
54
|
+
* it. Each part is optional: what is absent renders as saved.
|
|
55
|
+
*/
|
|
56
|
+
export interface PreviewDraft {
|
|
57
|
+
/** The surface to render (`home`, `product`). */
|
|
58
|
+
surface: string;
|
|
59
|
+
/** The language to render in; absent: the store's primary. */
|
|
60
|
+
locale?: string;
|
|
61
|
+
/** For a template, the resource route it is previewed for (`/products/robe-kabyle`). */
|
|
62
|
+
path?: string;
|
|
63
|
+
/** The draft page document; the theme reads it with `@wocart/theme`'s `parsePageData`. */
|
|
64
|
+
document?: Record<string, unknown>;
|
|
65
|
+
/** The draft theme settings, replacing the saved ones. */
|
|
66
|
+
settings?: Record<string, unknown>;
|
|
67
|
+
/** The draft text edits, per language, replacing the saved edits of those languages. */
|
|
68
|
+
content?: Record<string, Record<string, string>>;
|
|
69
|
+
}
|
|
70
|
+
export type PreviewRequest = {
|
|
71
|
+
ok: true;
|
|
72
|
+
claims: PreviewClaims;
|
|
73
|
+
draft: PreviewDraft;
|
|
74
|
+
token: string;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
status: 400 | 403 | 404 | 405 | 413 | 415;
|
|
78
|
+
reason: string;
|
|
79
|
+
};
|
|
80
|
+
export interface PreviewRequestOptions {
|
|
81
|
+
/** The deployment's preview public key (a JWK); absent: preview is off and every request answers 404. */
|
|
82
|
+
publicKey?: string | JsonWebKey | undefined;
|
|
83
|
+
/** This theme's key. */
|
|
84
|
+
theme: string;
|
|
85
|
+
/** The largest body accepted, in bytes; 1 MB by default. */
|
|
86
|
+
maxBytes?: number;
|
|
87
|
+
/** Seconds since the epoch; defaults to now. */
|
|
88
|
+
now?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read a preview request. Two kinds, both refused — and nothing rendered —
|
|
92
|
+
* unless the token verifies for this storefront and theme:
|
|
93
|
+
*
|
|
94
|
+
* - `GET ?token=…&surface=…[&locale=…][&path=…]`: the editor frames the
|
|
95
|
+
* preview. The draft names what to show and carries nothing else, so the
|
|
96
|
+
* page renders as saved until the editor sends a draft.
|
|
97
|
+
* - `POST` JSON `{ token, draft }` from this storefront's own origin: the
|
|
98
|
+
* framed page re-rendering a draft. A cross-site form can never post one.
|
|
99
|
+
*/
|
|
100
|
+
export declare function readPreviewRequest(request: Request, options: PreviewRequestOptions): Promise<PreviewRequest>;
|
|
101
|
+
/**
|
|
102
|
+
* The headers every preview response carries: framed only by the token's
|
|
103
|
+
* editor, never stored by a browser or a CDN, never indexed, and its address
|
|
104
|
+
* — which holds the token — never sent to another origin.
|
|
105
|
+
*/
|
|
106
|
+
export declare function previewResponseHeaders(claims: PreviewClaims): Record<string, string>;
|
|
107
|
+
/**
|
|
108
|
+
* The store config as the preview renders it: the draft's settings and text
|
|
109
|
+
* edits over the saved ones, and no tracking — a preview is not a visit.
|
|
110
|
+
*/
|
|
111
|
+
export declare function previewConfig(config: StoreConfig, draft: PreviewDraft): StoreConfig;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/** Clock skew tolerated between the editor's server and the storefront. */
|
|
2
|
+
const CLOCK_SKEW_SECONDS = 30;
|
|
3
|
+
/** The longest a preview token may live. */
|
|
4
|
+
const MAX_LIFETIME_SECONDS = 60 * 60;
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
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
|
+
const isOrigin = (value) => {
|
|
31
|
+
if (typeof value !== "string")
|
|
32
|
+
return false;
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(value);
|
|
35
|
+
return (url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1") && url.origin === value;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
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. */
|
|
53
|
+
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;
|
|
79
|
+
if (typeof aud !== "string" ||
|
|
80
|
+
typeof theme !== "string" ||
|
|
81
|
+
!isOrigin(editor) ||
|
|
82
|
+
typeof iat !== "number" ||
|
|
83
|
+
typeof exp !== "number" ||
|
|
84
|
+
!Number.isFinite(iat) ||
|
|
85
|
+
!Number.isFinite(exp) ||
|
|
86
|
+
(sub !== undefined && typeof sub !== "string") ||
|
|
87
|
+
(jti !== undefined && typeof jti !== "string")) {
|
|
88
|
+
return { ok: false, reason: "invalid-claims" };
|
|
89
|
+
}
|
|
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" };
|
|
97
|
+
if (aud !== options.audience)
|
|
98
|
+
return { ok: false, reason: "wrong-audience" };
|
|
99
|
+
if (theme !== options.theme)
|
|
100
|
+
return { ok: false, reason: "wrong-theme" };
|
|
101
|
+
const claims = { aud, theme, editor, iat, exp };
|
|
102
|
+
if (sub !== undefined)
|
|
103
|
+
claims.sub = sub;
|
|
104
|
+
if (jti !== undefined)
|
|
105
|
+
claims.jti = jti;
|
|
106
|
+
return { ok: true, claims };
|
|
107
|
+
}
|
|
108
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
109
|
+
const SURFACE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
|
|
110
|
+
const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|\d{3}))?$/;
|
|
111
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
function parseDraft(value) {
|
|
113
|
+
if (!isRecord(value) || typeof value.surface !== "string" || !SURFACE_PATTERN.test(value.surface))
|
|
114
|
+
return null;
|
|
115
|
+
const draft = { surface: value.surface };
|
|
116
|
+
if (value.locale !== undefined) {
|
|
117
|
+
if (typeof value.locale !== "string" || !LOCALE_PATTERN.test(value.locale))
|
|
118
|
+
return null;
|
|
119
|
+
draft.locale = value.locale;
|
|
120
|
+
}
|
|
121
|
+
if (value.path !== undefined) {
|
|
122
|
+
if (typeof value.path !== "string" || !value.path.startsWith("/") || value.path.startsWith("//") || value.path.length > 2048 || /[\s\\]/.test(value.path))
|
|
123
|
+
return null;
|
|
124
|
+
draft.path = value.path;
|
|
125
|
+
}
|
|
126
|
+
if (value.document !== undefined) {
|
|
127
|
+
if (!isRecord(value.document))
|
|
128
|
+
return null;
|
|
129
|
+
draft.document = value.document;
|
|
130
|
+
}
|
|
131
|
+
if (value.settings !== undefined) {
|
|
132
|
+
if (!isRecord(value.settings))
|
|
133
|
+
return null;
|
|
134
|
+
draft.settings = value.settings;
|
|
135
|
+
}
|
|
136
|
+
if (value.content !== undefined) {
|
|
137
|
+
if (!isRecord(value.content))
|
|
138
|
+
return null;
|
|
139
|
+
const content = {};
|
|
140
|
+
for (const [locale, pack] of Object.entries(value.content)) {
|
|
141
|
+
if (!LOCALE_PATTERN.test(locale) || !isRecord(pack))
|
|
142
|
+
return null;
|
|
143
|
+
const texts = {};
|
|
144
|
+
for (const [key, text] of Object.entries(pack)) {
|
|
145
|
+
if (typeof text !== "string" || key === "__proto__")
|
|
146
|
+
return null;
|
|
147
|
+
texts[key] = text;
|
|
148
|
+
}
|
|
149
|
+
content[locale] = texts;
|
|
150
|
+
}
|
|
151
|
+
draft.content = content;
|
|
152
|
+
}
|
|
153
|
+
return draft;
|
|
154
|
+
}
|
|
155
|
+
const refuse = (status, reason) => ({ ok: false, status, reason });
|
|
156
|
+
/**
|
|
157
|
+
* Read a preview request. Two kinds, both refused — and nothing rendered —
|
|
158
|
+
* unless the token verifies for this storefront and theme:
|
|
159
|
+
*
|
|
160
|
+
* - `GET ?token=…&surface=…[&locale=…][&path=…]`: the editor frames the
|
|
161
|
+
* preview. The draft names what to show and carries nothing else, so the
|
|
162
|
+
* page renders as saved until the editor sends a draft.
|
|
163
|
+
* - `POST` JSON `{ token, draft }` from this storefront's own origin: the
|
|
164
|
+
* framed page re-rendering a draft. A cross-site form can never post one.
|
|
165
|
+
*/
|
|
166
|
+
export async function readPreviewRequest(request, options) {
|
|
167
|
+
if (options.publicKey === undefined || options.publicKey === "")
|
|
168
|
+
return refuse(404, "preview is not enabled on this storefront");
|
|
169
|
+
const url = new URL(request.url);
|
|
170
|
+
const audience = url.origin;
|
|
171
|
+
let token;
|
|
172
|
+
let draftValue;
|
|
173
|
+
if (request.method === "GET") {
|
|
174
|
+
token = url.searchParams.get("token");
|
|
175
|
+
const named = {};
|
|
176
|
+
for (const key of ["surface", "locale", "path"]) {
|
|
177
|
+
const value = url.searchParams.get(key);
|
|
178
|
+
if (value !== null)
|
|
179
|
+
named[key] = value;
|
|
180
|
+
}
|
|
181
|
+
draftValue = named;
|
|
182
|
+
}
|
|
183
|
+
else if (request.method === "POST") {
|
|
184
|
+
if (request.headers.get("Origin") !== audience)
|
|
185
|
+
return refuse(403, "a draft is posted by the preview page itself");
|
|
186
|
+
if (!(request.headers.get("Content-Type") ?? "").startsWith("application/json"))
|
|
187
|
+
return refuse(415, "a draft is posted as JSON");
|
|
188
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
189
|
+
const declared = Number(request.headers.get("Content-Length") ?? "0");
|
|
190
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
191
|
+
return refuse(413, "the draft is too large");
|
|
192
|
+
const body = await request.text();
|
|
193
|
+
if (encoder.encode(body).length > maxBytes)
|
|
194
|
+
return refuse(413, "the draft is too large");
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(body);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return refuse(400, "the body is not JSON");
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(parsed))
|
|
203
|
+
return refuse(400, "the body is not an object");
|
|
204
|
+
token = parsed.token;
|
|
205
|
+
draftValue = parsed.draft;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
return refuse(405, "a preview is opened with GET and re-rendered with POST");
|
|
209
|
+
}
|
|
210
|
+
if (typeof token !== "string")
|
|
211
|
+
return refuse(403, "a preview carries its token");
|
|
212
|
+
const verified = await verifyPreviewToken(token, {
|
|
213
|
+
publicKey: options.publicKey,
|
|
214
|
+
audience,
|
|
215
|
+
theme: options.theme,
|
|
216
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
217
|
+
});
|
|
218
|
+
if (!verified.ok)
|
|
219
|
+
return refuse(403, `the token is refused: ${verified.reason}`);
|
|
220
|
+
const draft = parseDraft(draftValue);
|
|
221
|
+
if (draft === null)
|
|
222
|
+
return refuse(400, "the draft is not a preview draft");
|
|
223
|
+
return { ok: true, claims: verified.claims, draft, token };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The headers every preview response carries: framed only by the token's
|
|
227
|
+
* editor, never stored by a browser or a CDN, never indexed, and its address
|
|
228
|
+
* — which holds the token — never sent to another origin.
|
|
229
|
+
*/
|
|
230
|
+
export function previewResponseHeaders(claims) {
|
|
231
|
+
return {
|
|
232
|
+
"Cache-Control": "private, no-store",
|
|
233
|
+
"Content-Security-Policy": `frame-ancestors ${claims.editor}`,
|
|
234
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
235
|
+
"Referrer-Policy": "same-origin",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The store config as the preview renders it: the draft's settings and text
|
|
240
|
+
* edits over the saved ones, and no tracking — a preview is not a visit.
|
|
241
|
+
*/
|
|
242
|
+
export function previewConfig(config, draft) {
|
|
243
|
+
return {
|
|
244
|
+
...config,
|
|
245
|
+
...(draft.settings === undefined ? {} : { themeSettings: draft.settings }),
|
|
246
|
+
contentOverrides: { ...config.contentOverrides, ...(draft.content ?? {}) },
|
|
247
|
+
pixelId: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { createStoreClient } from "./client/index.js";
|
|
|
2
2
|
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
|
+
export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPreviewToken } from "./preview.js";
|
|
6
|
+
export type { PreviewClaims, PreviewDraft, PreviewRequest, PreviewRequestOptions, PreviewTokenCheck, PreviewTokenOptions, PreviewTokenRefusal, } from "./preview.js";
|
package/dist/server.js
CHANGED
package/dist/utils/image.d.ts
CHANGED
|
@@ -53,6 +53,18 @@ export interface MediaImageParams {
|
|
|
53
53
|
* @returns `null` when there is no media domain, or the URL is not on it.
|
|
54
54
|
*/
|
|
55
55
|
export declare function mediaImageUrl(originalUrl: string, params: MediaImageParams, mediaDomain?: string): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The public URL of an object in the store's media bucket, from its key —
|
|
58
|
+
* what a theme's `image` field stores (`products/3f2a9c.webp`). The key is
|
|
59
|
+
* the image's identity; the URL depends on where the store serves its media,
|
|
60
|
+
* so it is derived here, at render, never stored. Pass the result to
|
|
61
|
+
* `mediaImageUrl` or Astro's `<Image>` to resize it.
|
|
62
|
+
*
|
|
63
|
+
* @returns `null` when there is no media domain, or the key cannot name an
|
|
64
|
+
* object: empty, too long, an empty, `.` or `..` segment (a leading slash
|
|
65
|
+
* makes an empty one), whitespace, control characters, a backslash, `?` or `#`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mediaUrl(key: string, mediaDomain?: string): string | null;
|
|
56
68
|
/**
|
|
57
69
|
* Predefined image sizes for common use cases
|
|
58
70
|
*/
|
package/dist/utils/image.js
CHANGED
|
@@ -43,16 +43,15 @@ export function optimizeImage(originalUrl, options, mediaDomain) {
|
|
|
43
43
|
export function mediaImageUrl(originalUrl, params, mediaDomain) {
|
|
44
44
|
if (!mediaDomain || !originalUrl)
|
|
45
45
|
return null;
|
|
46
|
-
|
|
46
|
+
const mediaOrigin = mediaOriginOf(mediaDomain);
|
|
47
47
|
let original;
|
|
48
48
|
try {
|
|
49
|
-
mediaOrigin = new URL(`https://${mediaDomain.replace(/^https?:\/\//, "")}`).origin;
|
|
50
49
|
original = new URL(originalUrl);
|
|
51
50
|
}
|
|
52
51
|
catch {
|
|
53
52
|
return null;
|
|
54
53
|
}
|
|
55
|
-
if (original.origin !== mediaOrigin)
|
|
54
|
+
if (mediaOrigin === null || original.origin !== mediaOrigin)
|
|
56
55
|
return null;
|
|
57
56
|
const options = ["onerror=redirect"];
|
|
58
57
|
for (const key of ["width", "height", "format", "quality", "fit"]) {
|
|
@@ -63,6 +62,37 @@ export function mediaImageUrl(originalUrl, params, mediaDomain) {
|
|
|
63
62
|
const imagePath = `${original.pathname.replace(/^\/+/, "")}${original.search}${original.hash}`;
|
|
64
63
|
return `${mediaOrigin}/cdn-cgi/image/${options.join(",")}/${imagePath}`;
|
|
65
64
|
}
|
|
65
|
+
/** The media domain (`media.example.com`, with or without a scheme) as an HTTPS origin, or `null`. */
|
|
66
|
+
function mediaOriginOf(mediaDomain) {
|
|
67
|
+
try {
|
|
68
|
+
return new URL(`https://${mediaDomain.replace(/^https?:\/\//, "")}`).origin;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The public URL of an object in the store's media bucket, from its key —
|
|
76
|
+
* what a theme's `image` field stores (`products/3f2a9c.webp`). The key is
|
|
77
|
+
* the image's identity; the URL depends on where the store serves its media,
|
|
78
|
+
* so it is derived here, at render, never stored. Pass the result to
|
|
79
|
+
* `mediaImageUrl` or Astro's `<Image>` to resize it.
|
|
80
|
+
*
|
|
81
|
+
* @returns `null` when there is no media domain, or the key cannot name an
|
|
82
|
+
* object: empty, too long, an empty, `.` or `..` segment (a leading slash
|
|
83
|
+
* makes an empty one), whitespace, control characters, a backslash, `?` or `#`.
|
|
84
|
+
*/
|
|
85
|
+
export function mediaUrl(key, mediaDomain) {
|
|
86
|
+
if (!mediaDomain || typeof key !== "string" || key.length === 0 || key.length > 512)
|
|
87
|
+
return null;
|
|
88
|
+
if (/[\s\\?#\u0000-\u001f\u007f]/.test(key))
|
|
89
|
+
return null;
|
|
90
|
+
const segments = key.split("/");
|
|
91
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".."))
|
|
92
|
+
return null;
|
|
93
|
+
const origin = mediaOriginOf(mediaDomain);
|
|
94
|
+
return origin === null ? null : `${origin}/${segments.map(encodeURIComponent).join("/")}`;
|
|
95
|
+
}
|
|
66
96
|
/**
|
|
67
97
|
* Predefined image sizes for common use cases
|
|
68
98
|
*/
|
package/package.json
CHANGED