@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 +28 -0
- package/README.md +1 -1
- package/USAGE.md +29 -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/preview.d.ts +7 -2
- package/dist/preview.js +15 -71
- package/dist/revalidate.d.ts +37 -0
- package/dist/revalidate.js +66 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/dist/signed-token.d.ts +19 -0
- package/dist/signed-token.js +81 -0
- 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.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 —
|
|
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
|
|
888
|
-
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
|
|
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)
|
|
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/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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
55
|
-
if (
|
|
56
|
-
return
|
|
57
|
-
const
|
|
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
|
|
91
|
-
if (
|
|
92
|
-
return { ok: false, reason:
|
|
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