@zappie/sdk 0.1.16 → 0.1.17
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 +19 -0
- package/README.md +41 -15
- package/dist/debug.js +1 -1
- package/dist/index.js +1 -1
- package/dist/server/auth/crypto.js +106 -0
- package/dist/server/auth/crypto.js.map +7 -0
- package/dist/server/auth/jwt.js +204 -0
- package/dist/server/auth/jwt.js.map +7 -0
- package/dist/server/auth/session-store.js +46 -0
- package/dist/server/auth/session-store.js.map +7 -0
- package/dist/server/edge-snapshot.js +200 -0
- package/dist/server/edge-snapshot.js.map +7 -0
- package/dist/server/index.d.ts +324 -2
- package/dist/server/index.js +40 -0
- package/dist/server/index.js.map +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @zappie/sdk
|
|
2
2
|
|
|
3
|
+
## 0.1.17
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a151b0b: `@zappie/sdk/server` adds the menu snapshot types and helpers that edge menu reads are built on. `MenuSnapshot` wraps the SDK `Menu` that `menu.get()` returns, with the store, the branch, the API's menu version and a `contentHash`. `StoreSnapshot` wraps the SDK `Store`, and `AvailabilityPatch` lists the items, variants and choices that sold out since the menu was built.
|
|
8
|
+
|
|
9
|
+
- `canonicalJson()` writes a value with sorted keys, so equal values give the same text. `snapshotHash()` returns the first 16 hex digits of its SHA-256.
|
|
10
|
+
- `createMenuSnapshot()` builds a snapshot from a complete menu and refuses a partial one. `menuSnapshotFromPayload()` maps the API's menu payload with the same mapper `menu.get()` uses.
|
|
11
|
+
- `createStoreSnapshot()` and `createAvailabilityPatch()` build the other two. A patch's ids are sorted and unique, so equal patches hash the same.
|
|
12
|
+
- `applyAvailability()` returns the snapshot's menu with a patch applied, and ignores a patch older than the snapshot.
|
|
13
|
+
- `isMenuSnapshot()` checks a value read back from storage, and `snapshotKeys` names the storage keys.
|
|
14
|
+
|
|
15
|
+
- 195a5b8: `@zappie/sdk/server` adds the building blocks for signing customers in on a site you host yourself. They use Web Crypto only, so they run in Workers and Node 20 or later.
|
|
16
|
+
|
|
17
|
+
- `createPkce()` returns a PKCE verifier and its S256 challenge. `randomToken()` returns a random URL-safe value for state, nonces and ids.
|
|
18
|
+
- `sealValue()` and `openSealedValue()` keep a JSON value in an AES-GCM sealed string. Pass several secrets to rotate: the first seals and any of them opens. A `purpose` binds the value to one use. Without one, the format matches Zappie's hosted sign-in.
|
|
19
|
+
- `createTokenVerifier({ issuer, audience })` verifies RS256 and ES256 tokens against the issuer's keys at `<issuer>/.well-known/jwks.json`, or a `jwksUrl` you set. It caches the keys for an hour, reads them again once when a token names a new key, and keeps the last good keys while the issuer is down. While the issuer is down and no keys are cached, it reads them at most every 30 seconds, and a set with no usable keys never replaces good ones. It checks the issuer (a trailing slash on either side is ignored), the audience, the expiry with 60 seconds of leeway, `nbf`, `iat`, the nonce you pass and a required `cus_` subject. `verifyIdToken(token, { nonce })` needs the nonce and a subject, and refuses a token whose `typ` names another kind, such as an access token. A refused token throws `ZappieTokenError` with a `reason`.
|
|
20
|
+
- `SessionStore` is the interface every session store implements. `cookieSessionStore({ secret })` keeps the whole session sealed in the cookie for up to 30 days. It can't end sessions from the server, so its `deleteBySub` rejects, and signing out doesn't invalidate a copied cookie before it expires.
|
|
21
|
+
|
|
3
22
|
## 0.1.16
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -50,24 +50,50 @@ Prices always come from Zappie. The cart shows a local estimate while it waits,
|
|
|
50
50
|
|
|
51
51
|
## Entry points
|
|
52
52
|
|
|
53
|
-
| Import | For
|
|
54
|
-
| -------------------------------------------- |
|
|
55
|
-
| `@zappie/sdk` | The client, the cart, money formatting and errors
|
|
56
|
-
| `@zappie/sdk/react` | `ZappieProvider` and hooks such as `useStore`, `useMenu` and `useCart`
|
|
57
|
-
| `@zappie/sdk/checkout` | Opening Zappie's hosted checkout and checking it completed
|
|
58
|
-
| `@zappie/sdk/checkout-defaults` | The phone, address, region and time a signed-in checkout starts from
|
|
59
|
-
| `@zappie/sdk/customer` and `/customer/react` | Signed-in customers, their orders and saved addresses
|
|
60
|
-
| `@zappie/sdk/integrations` | Public integration IDs and browser keys
|
|
61
|
-
| `@zappie/sdk/places` | Address search and delivery-zone checks
|
|
62
|
-
| `@zappie/sdk/pricing` | An item's starting price and whether to show it as "From"
|
|
63
|
-
| `@zappie/sdk/server` | Server-side reads, storefront URLs
|
|
64
|
-
| `@zappie/sdk/template` and `/template/react` | Template settings and responsive images for template authors
|
|
65
|
-
| `@zappie/sdk/testing` | A mock client for your tests
|
|
66
|
-
| `@zappie/sdk/url` | Storefront URL parsing and canonical redirects
|
|
67
|
-
| `@zappie/sdk/redirect` | Same-origin validation for untrusted redirect targets
|
|
53
|
+
| Import | For |
|
|
54
|
+
| -------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
55
|
+
| `@zappie/sdk` | The client, the cart, money formatting and errors |
|
|
56
|
+
| `@zappie/sdk/react` | `ZappieProvider` and hooks such as `useStore`, `useMenu` and `useCart` |
|
|
57
|
+
| `@zappie/sdk/checkout` | Opening Zappie's hosted checkout and checking it completed |
|
|
58
|
+
| `@zappie/sdk/checkout-defaults` | The phone, address, region and time a signed-in checkout starts from |
|
|
59
|
+
| `@zappie/sdk/customer` and `/customer/react` | Signed-in customers, their orders and saved addresses |
|
|
60
|
+
| `@zappie/sdk/integrations` | Public integration IDs and browser keys |
|
|
61
|
+
| `@zappie/sdk/places` | Address search and delivery-zone checks |
|
|
62
|
+
| `@zappie/sdk/pricing` | An item's starting price and whether to show it as "From" |
|
|
63
|
+
| `@zappie/sdk/server` | Server-side reads, storefront URLs, `verifyWebhook()`, menu snapshots and sign-in crypto |
|
|
64
|
+
| `@zappie/sdk/template` and `/template/react` | Template settings and responsive images for template authors |
|
|
65
|
+
| `@zappie/sdk/testing` | A mock client for your tests |
|
|
66
|
+
| `@zappie/sdk/url` | Storefront URL parsing and canonical redirects |
|
|
67
|
+
| `@zappie/sdk/redirect` | Same-origin validation for untrusted redirect targets |
|
|
68
68
|
|
|
69
69
|
Customer sign-in runs through Zappie's site host, so `@zappie/sdk/customer` works on sites Zappie hosts. Sign-in for a site you host yourself isn't available yet.
|
|
70
70
|
|
|
71
|
+
A menu snapshot is the SDK `Menu` with the facts an edge cache needs: the
|
|
72
|
+
store, the branch, the API's menu version and a content hash. Build one with
|
|
73
|
+
`createMenuSnapshot()` or `menuSnapshotFromPayload()` from `@zappie/sdk/server`,
|
|
74
|
+
and apply a sold-out patch to it with `applyAvailability()`.
|
|
75
|
+
|
|
76
|
+
`@zappie/sdk/server` has the pieces that sign-in will be built from. They use Web Crypto only, so they run in Workers and Node 20 or later:
|
|
77
|
+
|
|
78
|
+
- `createPkce()` and `randomToken()` for the code challenge, state and nonce;
|
|
79
|
+
- `sealValue()` and `openSealedValue()` to keep a value in an AES-GCM cookie, with secret rotation;
|
|
80
|
+
- `createTokenVerifier()` to check a token's signature against the issuer's published keys, and its issuer, audience, expiry and subject. `verifyIdToken()` also needs the sign-in's nonce and refuses an access token;
|
|
81
|
+
- `SessionStore` and `cookieSessionStore()` to keep a signed-in session in a sealed cookie.
|
|
82
|
+
|
|
83
|
+
Make each sealing secret at least 32 random bytes, such as a value from `randomToken()`. Don't use a password or a phrase. The secret is hashed once with no stretching, so anyone holding one sealed value can test guesses offline.
|
|
84
|
+
|
|
85
|
+
`cookieSessionStore()` keeps the session in the cookie, not on your server. Signing out clears the cookie in that browser only. It doesn't invalidate a copied cookie, which still opens the session until it expires 30 days after sign-in. To end every session at once, rotate the secret. To end one session from the server, use a store that keeps sessions there.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { createTokenVerifier } from "@zappie/sdk/server";
|
|
89
|
+
|
|
90
|
+
const verifier = createTokenVerifier({
|
|
91
|
+
issuer: "https://accounts.zappie.ai",
|
|
92
|
+
audience: clientId,
|
|
93
|
+
});
|
|
94
|
+
const claims = await verifier.verifyIdToken(idToken, { nonce });
|
|
95
|
+
```
|
|
96
|
+
|
|
71
97
|
Pass any redirect target read from a query parameter through `safeRedirect`.
|
|
72
98
|
It keeps same-origin relative paths and returns your trusted fallback for an
|
|
73
99
|
external, malformed or unsupported target.
|
package/dist/debug.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ZappieError, isZappieError } from "./errors.js";
|
|
|
10
10
|
export * from "./models.js";
|
|
11
11
|
import { format, money } from "./money.js";
|
|
12
12
|
import { RestTransport } from "./transport/rest.js";
|
|
13
|
-
var VERSION = true ? "0.1.
|
|
13
|
+
var VERSION = true ? "0.1.17" : "0.0.0-development";
|
|
14
14
|
export {
|
|
15
15
|
API_VERSION,
|
|
16
16
|
RestTransport,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/server/auth/crypto.ts
|
|
2
|
+
var encoder = new TextEncoder();
|
|
3
|
+
var decoder = new TextDecoder();
|
|
4
|
+
function base64UrlEncode(bytes) {
|
|
5
|
+
let binary = "";
|
|
6
|
+
for (const byte of bytes) {
|
|
7
|
+
binary += String.fromCharCode(byte);
|
|
8
|
+
}
|
|
9
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
function base64UrlDecode(text) {
|
|
12
|
+
return /^[\w-]*$/.test(text) && text.length % 4 !== 1 ? Uint8Array.from(
|
|
13
|
+
atob(text.replaceAll("-", "+").replaceAll("_", "/")),
|
|
14
|
+
(c) => c.charCodeAt(0)
|
|
15
|
+
) : null;
|
|
16
|
+
}
|
|
17
|
+
function randomToken(bytes = 32) {
|
|
18
|
+
if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) {
|
|
19
|
+
throw new RangeError("randomToken takes between 16 and 1024 bytes.");
|
|
20
|
+
}
|
|
21
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
22
|
+
}
|
|
23
|
+
async function pkceChallenge(verifier) {
|
|
24
|
+
const digest = await crypto.subtle.digest(
|
|
25
|
+
"SHA-256",
|
|
26
|
+
encoder.encode(verifier)
|
|
27
|
+
);
|
|
28
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
29
|
+
}
|
|
30
|
+
async function createPkce() {
|
|
31
|
+
const verifier = randomToken();
|
|
32
|
+
return { verifier, challenge: await pkceChallenge(verifier), method: "S256" };
|
|
33
|
+
}
|
|
34
|
+
function secretsOf(secret) {
|
|
35
|
+
const list = typeof secret === "string" ? [secret] : secret;
|
|
36
|
+
if (!list.length || list.some((value) => encoder.encode(value).length < 32)) {
|
|
37
|
+
throw new TypeError("Each sealing secret needs at least 32 bytes.");
|
|
38
|
+
}
|
|
39
|
+
return list;
|
|
40
|
+
}
|
|
41
|
+
var keys = /* @__PURE__ */ new Map();
|
|
42
|
+
function keyFor(secret) {
|
|
43
|
+
if (!keys.has(secret)) {
|
|
44
|
+
if (keys.size >= 16) {
|
|
45
|
+
keys.clear();
|
|
46
|
+
}
|
|
47
|
+
keys.set(
|
|
48
|
+
secret,
|
|
49
|
+
crypto.subtle.digest("SHA-256", encoder.encode(secret)).then(
|
|
50
|
+
(digest) => crypto.subtle.importKey("raw", digest, "AES-GCM", false, [
|
|
51
|
+
"encrypt",
|
|
52
|
+
"decrypt"
|
|
53
|
+
])
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return keys.get(secret);
|
|
58
|
+
}
|
|
59
|
+
var params = (iv, purpose) => ({
|
|
60
|
+
name: "AES-GCM",
|
|
61
|
+
iv,
|
|
62
|
+
...purpose === void 0 ? {} : { additionalData: encoder.encode(purpose) }
|
|
63
|
+
});
|
|
64
|
+
async function sealValue(value, { secret, purpose }) {
|
|
65
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
66
|
+
const cipher = await crypto.subtle.encrypt(
|
|
67
|
+
params(iv, purpose),
|
|
68
|
+
await keyFor(secretsOf(secret)[0]),
|
|
69
|
+
encoder.encode(JSON.stringify(value))
|
|
70
|
+
);
|
|
71
|
+
return `${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(cipher))}`;
|
|
72
|
+
}
|
|
73
|
+
async function openSealedValue(sealed, { secret, purpose }) {
|
|
74
|
+
const list = secretsOf(secret);
|
|
75
|
+
const [ivText = "", cipherText = "", extra] = (sealed ?? "").split(".");
|
|
76
|
+
const iv = base64UrlDecode(ivText);
|
|
77
|
+
const cipher = base64UrlDecode(cipherText);
|
|
78
|
+
if (iv?.length !== 12 || !cipher?.length || extra !== void 0) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
for (const candidate of list) {
|
|
82
|
+
try {
|
|
83
|
+
const plain = await crypto.subtle.decrypt(
|
|
84
|
+
params(iv, purpose),
|
|
85
|
+
await keyFor(candidate),
|
|
86
|
+
cipher
|
|
87
|
+
);
|
|
88
|
+
return JSON.parse(decoder.decode(plain));
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
export {
|
|
95
|
+
base64UrlDecode,
|
|
96
|
+
base64UrlEncode,
|
|
97
|
+
createPkce,
|
|
98
|
+
decoder,
|
|
99
|
+
encoder,
|
|
100
|
+
openSealedValue,
|
|
101
|
+
pkceChallenge,
|
|
102
|
+
randomToken,
|
|
103
|
+
sealValue,
|
|
104
|
+
secretsOf
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=crypto.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/server/auth/crypto.ts"],
|
|
4
|
+
"sourcesContent": ["export const encoder = new TextEncoder();\nexport const decoder = new TextDecoder();\n\nexport function base64UrlEncode(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) {\n binary += String.fromCharCode(byte);\n }\n return btoa(binary)\n .replaceAll(\"+\", \"-\")\n .replaceAll(\"/\", \"_\")\n .replace(/=+$/, \"\");\n}\n\n// Null for anything but unpadded base64url, so a forged segment is refused\n// as malformed rather than thrown as a DOMException.\nexport function base64UrlDecode(text: string): Uint8Array<ArrayBuffer> | null {\n return /^[\\w-]*$/.test(text) && text.length % 4 !== 1\n ? Uint8Array.from(\n atob(text.replaceAll(\"-\", \"+\").replaceAll(\"_\", \"/\")),\n (c) => c.charCodeAt(0),\n )\n : null;\n}\n\n/** A PKCE code verifier and its S256 challenge for one sign-in attempt. */\nexport type PkcePair = {\n /** Keep this on the server and send it with the code exchange. */\n verifier: string;\n /** Send this to the authorize endpoint as `code_challenge`. */\n challenge: string;\n /** Send this as `code_challenge_method`. */\n method: \"S256\";\n};\n\n/** One sealing secret, or several while you rotate them. The first one seals. */\nexport type SealSecret = string | readonly string[];\n\n/** Options for sealing a value into a cookie-safe string. */\nexport type SealOptions = {\n /**\n * At least 32 bytes each, from a random generator such as `randomToken()`,\n * never a password or phrase. The key is the secret's SHA-256 with no\n * stretching, so a guessable secret can be found offline from one sealed\n * value. List the new secret first while you rotate.\n */\n secret: SealSecret;\n /**\n * Binds the sealed value to one use, such as `\"tx\"`, so it won't open for\n * another. Leave it out to match the hosted sign-in Worker's format.\n */\n purpose?: string;\n};\n\n/** Create a random URL-safe token from `bytes` random bytes, for state, nonces and ids. */\nexport function randomToken(bytes = 32): string {\n if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) {\n throw new RangeError(\"randomToken takes between 16 and 1024 bytes.\");\n }\n return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)));\n}\n\nexport async function pkceChallenge(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n encoder.encode(verifier),\n );\n return base64UrlEncode(new Uint8Array(digest));\n}\n\n/** Create a PKCE verifier and its S256 challenge. */\nexport async function createPkce(): Promise<PkcePair> {\n const verifier = randomToken();\n return { verifier, challenge: await pkceChallenge(verifier), method: \"S256\" };\n}\n\nexport function secretsOf(secret: SealSecret): readonly string[] {\n const list = typeof secret === \"string\" ? [secret] : secret;\n if (!list.length || list.some((value) => encoder.encode(value).length < 32)) {\n throw new TypeError(\"Each sealing secret needs at least 32 bytes.\");\n }\n return list;\n}\n\nconst keys = new Map<string, Promise<CryptoKey>>();\n\n// The key is the SHA-256 of the secret, as in the hosted sign-in Worker, so a\n// value it sealed opens here with the same secret.\nfunction keyFor(secret: string) {\n if (!keys.has(secret)) {\n if (keys.size >= 16) {\n keys.clear();\n }\n keys.set(\n secret,\n crypto.subtle\n .digest(\"SHA-256\", encoder.encode(secret))\n .then((digest) =>\n crypto.subtle.importKey(\"raw\", digest, \"AES-GCM\", false, [\n \"encrypt\",\n \"decrypt\",\n ]),\n ),\n );\n }\n return keys.get(secret) as Promise<CryptoKey>;\n}\n\nconst params = (iv: Uint8Array<ArrayBuffer>, purpose?: string) => ({\n name: \"AES-GCM\",\n iv,\n ...(purpose === undefined ? {} : { additionalData: encoder.encode(purpose) }),\n});\n\n/** Encrypt a JSON value with AES-GCM into a cookie-safe string. */\nexport async function sealValue(\n value: unknown,\n { secret, purpose }: SealOptions,\n): Promise<string> {\n const iv = crypto.getRandomValues(new Uint8Array(12));\n const cipher = await crypto.subtle.encrypt(\n params(iv, purpose),\n await keyFor(secretsOf(secret)[0] as string),\n encoder.encode(JSON.stringify(value)),\n );\n return `${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(cipher))}`;\n}\n\n/** Decrypt a sealed value with any listed secret. Returns null when none opens it. */\nexport async function openSealedValue<T = unknown>(\n sealed: string | null | undefined,\n { secret, purpose }: SealOptions,\n): Promise<T | null> {\n const list = secretsOf(secret);\n const [ivText = \"\", cipherText = \"\", extra] = (sealed ?? \"\").split(\".\");\n const iv = base64UrlDecode(ivText);\n const cipher = base64UrlDecode(cipherText);\n if (iv?.length !== 12 || !cipher?.length || extra !== undefined) {\n return null;\n }\n for (const candidate of list) {\n try {\n const plain = await crypto.subtle.decrypt(\n params(iv, purpose),\n await keyFor(candidate),\n cipher,\n );\n return JSON.parse(decoder.decode(plain)) as T;\n } catch {\n // Try the next secret.\n }\n }\n return null;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/server/auth/jwt.ts
|
|
2
|
+
import { ZappieError } from "../../errors.js";
|
|
3
|
+
import { base64UrlDecode, decoder, encoder } from "./crypto.js";
|
|
4
|
+
var messages = {
|
|
5
|
+
malformed: "The token isn't a signed JWT.",
|
|
6
|
+
unsupported_algorithm: "The token uses an algorithm this verifier refuses.",
|
|
7
|
+
unknown_key: "The token was signed with a key the issuer doesn't publish.",
|
|
8
|
+
invalid_signature: "The token's signature doesn't match.",
|
|
9
|
+
wrong_issuer: "The token comes from a different issuer.",
|
|
10
|
+
wrong_audience: "The token was issued for a different audience.",
|
|
11
|
+
wrong_nonce: "The token wasn't issued for this sign-in.",
|
|
12
|
+
wrong_subject: "The token's subject isn't a Zappie customer.",
|
|
13
|
+
wrong_type: "The token isn't an ID token.",
|
|
14
|
+
expired: "The token has expired.",
|
|
15
|
+
not_yet_valid: "The token isn't valid yet.",
|
|
16
|
+
keys_unavailable: "The identity issuer's signing keys couldn't be read."
|
|
17
|
+
};
|
|
18
|
+
var ZappieTokenError = class extends ZappieError {
|
|
19
|
+
constructor(reason, cause) {
|
|
20
|
+
super(
|
|
21
|
+
reason === "keys_unavailable" ? "network_error" : "unauthorized",
|
|
22
|
+
messages[reason],
|
|
23
|
+
cause === void 0 ? {} : { cause }
|
|
24
|
+
);
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
this.name = "ZappieTokenError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var specs = {
|
|
30
|
+
RS256: {
|
|
31
|
+
kty: "RSA",
|
|
32
|
+
jwk: (key) => ({ kty: "RSA", n: key.n, e: key.e }),
|
|
33
|
+
import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
34
|
+
verify: { name: "RSASSA-PKCS1-v1_5" }
|
|
35
|
+
},
|
|
36
|
+
ES256: {
|
|
37
|
+
kty: "EC",
|
|
38
|
+
jwk: (key) => ({ kty: "EC", crv: "P-256", x: key.x, y: key.y }),
|
|
39
|
+
import: { name: "ECDSA", namedCurve: "P-256" },
|
|
40
|
+
verify: { name: "ECDSA", hash: "SHA-256" }
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var fail = (reason, cause) => {
|
|
44
|
+
throw new ZappieTokenError(reason, cause);
|
|
45
|
+
};
|
|
46
|
+
var numeric = (value) => typeof value === "number" && Number.isFinite(value);
|
|
47
|
+
var trimSlash = (value) => value.replace(/\/+$/, "");
|
|
48
|
+
var KEY_RETRY_MS = 3e4;
|
|
49
|
+
function decodeJson(text) {
|
|
50
|
+
const bytes = base64UrlDecode(text) ?? fail("malformed");
|
|
51
|
+
let value;
|
|
52
|
+
try {
|
|
53
|
+
value = JSON.parse(decoder.decode(bytes));
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : fail("malformed");
|
|
57
|
+
}
|
|
58
|
+
function createTokenVerifier(options) {
|
|
59
|
+
const issuer = trimSlash(options.issuer);
|
|
60
|
+
const allowed = options.algorithms ?? ["RS256", "ES256"];
|
|
61
|
+
const tolerance = options.clockToleranceSec ?? 60;
|
|
62
|
+
const ttl = (options.cacheTtlSec ?? 3600) * 1e3;
|
|
63
|
+
const prefix = options.subjectPrefix === void 0 ? "cus_" : options.subjectPrefix;
|
|
64
|
+
const now = options.now ?? Date.now;
|
|
65
|
+
const read = options.fetch ?? ((url, init) => fetch(url, init));
|
|
66
|
+
if (!issuer || !options.audience || !allowed.length || allowed.some((alg) => !(alg in specs)) || !numeric(tolerance) || tolerance < 0 || !numeric(ttl) || ttl < 0) {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
"createTokenVerifier needs an issuer, an audience, RS256 or ES256, and a clock tolerance and cache time of 0 or more."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
let cached;
|
|
72
|
+
let pending;
|
|
73
|
+
let triedAt = Number.NEGATIVE_INFINITY;
|
|
74
|
+
let lastError;
|
|
75
|
+
const load = async () => {
|
|
76
|
+
try {
|
|
77
|
+
const response = await read(
|
|
78
|
+
options.jwksUrl ?? `${issuer}/.well-known/jwks.json`,
|
|
79
|
+
{
|
|
80
|
+
headers: { accept: "application/json" },
|
|
81
|
+
signal: AbortSignal.timeout(1e4)
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
const text = response.ok ? await response.text() : "";
|
|
85
|
+
if (!text || text.length > 1 << 20) {
|
|
86
|
+
throw new Error(`The key set answered ${response.status}.`);
|
|
87
|
+
}
|
|
88
|
+
const keys = /* @__PURE__ */ new Map();
|
|
89
|
+
const list = JSON.parse(text).keys;
|
|
90
|
+
for (const jwk of Array.isArray(list) ? list : []) {
|
|
91
|
+
for (const alg of allowed) {
|
|
92
|
+
const spec = specs[alg];
|
|
93
|
+
if (jwk.kty === spec.kty && (jwk.use ?? "sig") === "sig" && (jwk.alg ?? alg) === alg && (alg === "RS256" || jwk.crv === "P-256")) {
|
|
94
|
+
await crypto.subtle.importKey(
|
|
95
|
+
"jwk",
|
|
96
|
+
spec.jwk(jwk),
|
|
97
|
+
spec.import,
|
|
98
|
+
false,
|
|
99
|
+
["verify"]
|
|
100
|
+
).then((key) => keys.set(`${alg}:${String(jwk.kid ?? "")}`, key)).catch(() => void 0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!keys.size) {
|
|
105
|
+
throw new Error("The key set has no keys this verifier can use.");
|
|
106
|
+
}
|
|
107
|
+
return cached = { keys, at: now() };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return fail("keys_unavailable", error);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const find = (set, kid, alg) => {
|
|
113
|
+
if (typeof kid === "string") {
|
|
114
|
+
return set.keys.get(`${alg}:${kid}`);
|
|
115
|
+
}
|
|
116
|
+
const fits = [...set.keys].filter(([id]) => id.startsWith(`${alg}:`));
|
|
117
|
+
return fits.length === 1 ? fits[0]?.[1] : void 0;
|
|
118
|
+
};
|
|
119
|
+
const keyFor = async (kid, alg) => {
|
|
120
|
+
if (!pending) {
|
|
121
|
+
const key = cached && find(cached, kid, alg);
|
|
122
|
+
if (key && cached && now() - cached.at < ttl) {
|
|
123
|
+
return key;
|
|
124
|
+
}
|
|
125
|
+
if (now() - triedAt < KEY_RETRY_MS) {
|
|
126
|
+
return cached ? key : fail("keys_unavailable", lastError);
|
|
127
|
+
}
|
|
128
|
+
triedAt = now();
|
|
129
|
+
pending = load().finally(() => pending = void 0);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const set = await pending;
|
|
133
|
+
lastError = void 0;
|
|
134
|
+
return find(set, kid, alg);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
lastError = error;
|
|
137
|
+
return (cached && find(cached, kid, alg)) ?? fail("keys_unavailable", error);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const check = async (token, checks, idToken) => {
|
|
141
|
+
const parts = typeof token === "string" && token.length < 16384 ? token.split(".") : [];
|
|
142
|
+
const [head = "", body = "", sig = ""] = parts.length === 3 ? parts : fail("malformed");
|
|
143
|
+
const header = decodeJson(head);
|
|
144
|
+
const claims = decodeJson(body);
|
|
145
|
+
const signature = base64UrlDecode(sig) ?? fail("malformed");
|
|
146
|
+
const alg = header.alg;
|
|
147
|
+
if (!allowed.includes(alg) || header.crit !== void 0) {
|
|
148
|
+
fail("unsupported_algorithm");
|
|
149
|
+
}
|
|
150
|
+
if (idToken && header.typ !== void 0 && (typeof header.typ !== "string" || header.typ.toLowerCase().replace(/^application\//, "") !== "jwt")) {
|
|
151
|
+
fail("wrong_type");
|
|
152
|
+
}
|
|
153
|
+
const key = await keyFor(header.kid, alg) ?? fail("unknown_key");
|
|
154
|
+
if (alg === "ES256" && signature.length !== 64 || !await crypto.subtle.verify(
|
|
155
|
+
specs[alg].verify,
|
|
156
|
+
key,
|
|
157
|
+
signature,
|
|
158
|
+
encoder.encode(`${head}.${body}`)
|
|
159
|
+
)) {
|
|
160
|
+
fail("invalid_signature");
|
|
161
|
+
}
|
|
162
|
+
const seconds = now() / 1e3;
|
|
163
|
+
const times = [claims.nbf, claims.iat];
|
|
164
|
+
const sub = claims.sub;
|
|
165
|
+
if (typeof claims.iss !== "string" || trimSlash(claims.iss) !== issuer) {
|
|
166
|
+
fail("wrong_issuer");
|
|
167
|
+
}
|
|
168
|
+
if (![claims.aud].flat().includes(checks.audience ?? options.audience)) {
|
|
169
|
+
fail("wrong_audience");
|
|
170
|
+
}
|
|
171
|
+
if (!numeric(claims.exp) || times.some((time) => time !== void 0 && !numeric(time))) {
|
|
172
|
+
fail("malformed");
|
|
173
|
+
}
|
|
174
|
+
if (seconds > claims.exp + tolerance) {
|
|
175
|
+
fail("expired");
|
|
176
|
+
}
|
|
177
|
+
if (times.some((time) => time > seconds + tolerance)) {
|
|
178
|
+
fail("not_yet_valid");
|
|
179
|
+
}
|
|
180
|
+
if (checks.nonce !== void 0 && claims.nonce !== checks.nonce) {
|
|
181
|
+
fail("wrong_nonce");
|
|
182
|
+
}
|
|
183
|
+
if ((prefix !== null || idToken) && (typeof sub !== "string" || !sub || prefix !== null && (!sub.startsWith(prefix) || sub.length === prefix.length))) {
|
|
184
|
+
fail("wrong_subject");
|
|
185
|
+
}
|
|
186
|
+
return claims;
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
verify: (token, checks = {}) => check(token, checks, false),
|
|
190
|
+
async verifyIdToken(token, checks) {
|
|
191
|
+
if (typeof checks?.nonce !== "string" || !checks.nonce) {
|
|
192
|
+
throw new TypeError(
|
|
193
|
+
"verifyIdToken needs the nonce you sent with the sign-in."
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return await check(token, checks, true);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export {
|
|
201
|
+
ZappieTokenError,
|
|
202
|
+
createTokenVerifier
|
|
203
|
+
};
|
|
204
|
+
//# sourceMappingURL=jwt.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/server/auth/jwt.ts"],
|
|
4
|
+
"sourcesContent": ["import { ZappieError } from \"../../errors\";\nimport { base64UrlDecode, decoder, encoder } from \"./crypto\";\n\n/** Signing algorithms Sign in with Zappie tokens can use. */\nexport type TokenAlgorithm = \"RS256\" | \"ES256\";\n\nconst messages = {\n malformed: \"The token isn't a signed JWT.\",\n unsupported_algorithm: \"The token uses an algorithm this verifier refuses.\",\n unknown_key: \"The token was signed with a key the issuer doesn't publish.\",\n invalid_signature: \"The token's signature doesn't match.\",\n wrong_issuer: \"The token comes from a different issuer.\",\n wrong_audience: \"The token was issued for a different audience.\",\n wrong_nonce: \"The token wasn't issued for this sign-in.\",\n wrong_subject: \"The token's subject isn't a Zappie customer.\",\n wrong_type: \"The token isn't an ID token.\",\n expired: \"The token has expired.\",\n not_yet_valid: \"The token isn't valid yet.\",\n keys_unavailable: \"The identity issuer's signing keys couldn't be read.\",\n};\n\n/** Why a token was refused. */\nexport type TokenErrorReason = keyof typeof messages;\n\n/** A token Zappie's identity issuer didn't sign for you, or one that is no longer valid. */\nexport class ZappieTokenError extends ZappieError {\n constructor(\n readonly reason: TokenErrorReason,\n cause?: unknown,\n ) {\n super(\n reason === \"keys_unavailable\" ? \"network_error\" : \"unauthorized\",\n messages[reason],\n cause === undefined ? {} : { cause },\n );\n this.name = \"ZappieTokenError\";\n }\n}\n\n/** The verified claims of a token. Extra claims stay on the object. */\nexport type TokenClaims = {\n iss: string;\n aud: string | string[];\n exp: number;\n sub?: string;\n iat?: number;\n nbf?: number;\n nonce?: string;\n sid?: string;\n [claim: string]: unknown;\n};\n\n/** Settings for verifying tokens from Zappie's identity issuer. */\nexport type TokenVerifierOptions = {\n /** The issuer URL, such as `https://accounts.zappie.ai`. The token's `iss` must equal it. A trailing slash on either is ignored. */\n issuer: string;\n /** The audience the token must name: your site's client id for ID tokens, or the API audience for access tokens. */\n audience: string;\n /** Where to read the signing keys. Defaults to `<issuer>/.well-known/jwks.json`. */\n jwksUrl?: string;\n /** Allowed algorithms. Defaults to RS256 and ES256. */\n algorithms?: readonly TokenAlgorithm[];\n /** Seconds of clock skew allowed on `exp`, `nbf` and `iat`. Defaults to 60. */\n clockToleranceSec?: number;\n /** Seconds to keep the key set before reading it again, 0 or more. Defaults to one hour. */\n cacheTtlSec?: number;\n /** Prefix the `sub` claim must start with. Zappie customers are `cus_`. A token without `sub` is refused. Pass `null` to skip both checks. */\n subjectPrefix?: string | null;\n /** The fetch to read keys with. Defaults to the global fetch. */\n fetch?: typeof fetch;\n /** The current time in milliseconds. For tests. */\n now?: () => number;\n};\n\n/** Per-call checks on top of the verifier's settings. */\nexport type VerifyTokenChecks = {\n /** The nonce you sent with the sign-in. When you pass it, the token's `nonce` must equal it. */\n nonce?: string;\n /** Overrides the verifier's audience for this token. */\n audience?: string;\n};\n\n/** Checks for an ID token from a sign-in. */\nexport type IdTokenChecks = {\n /** The nonce you sent with this sign-in. The token's `nonce` must equal it. */\n nonce: string;\n /** Overrides the verifier's audience for this token. */\n audience?: string;\n};\n\n/** The verified claims of an ID token. */\nexport type IdTokenClaims = TokenClaims & { sub: string; nonce: string };\n\n/** Verifies signed tokens against the issuer's published keys. */\nexport type TokenVerifier = {\n /** Verify a token's signature and claims, and return its claims. */\n verify(token: string, checks?: VerifyTokenChecks): Promise<TokenClaims>;\n /**\n * Verify the ID token from a sign-in. It needs the nonce you sent, a `sub`,\n * and refuses a token whose `typ` header names another kind of token, such\n * as an access token (`at+jwt`).\n */\n verifyIdToken(token: string, checks: IdTokenChecks): Promise<IdTokenClaims>;\n};\n\ntype Jwk = Record<string, unknown>;\ntype KeySet = { keys: Map<string, CryptoKey>; at: number };\n\nconst specs = {\n RS256: {\n kty: \"RSA\",\n jwk: (key: Jwk) => ({ kty: \"RSA\", n: key.n, e: key.e }),\n import: { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\n verify: { name: \"RSASSA-PKCS1-v1_5\" },\n },\n ES256: {\n kty: \"EC\",\n jwk: (key: Jwk) => ({ kty: \"EC\", crv: \"P-256\", x: key.x, y: key.y }),\n import: { name: \"ECDSA\", namedCurve: \"P-256\" },\n verify: { name: \"ECDSA\", hash: \"SHA-256\" },\n },\n} as const;\n\nconst fail = (reason: TokenErrorReason, cause?: unknown): never => {\n throw new ZappieTokenError(reason, cause);\n};\nconst numeric = (value: unknown): value is number =>\n typeof value === \"number\" && Number.isFinite(value);\nconst trimSlash = (value: string) => value.replace(/\\/+$/, \"\");\nconst KEY_RETRY_MS = 30_000;\n\nfunction decodeJson(text: string): Record<string, unknown> {\n const bytes = base64UrlDecode(text) ?? fail(\"malformed\");\n let value: unknown;\n try {\n value = JSON.parse(decoder.decode(bytes));\n } catch {\n // Refused below.\n }\n return value && typeof value === \"object\" && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : fail(\"malformed\");\n}\n\n/**\n * Create a verifier for tokens from Zappie's identity issuer. It caches the\n * issuer's key set, reads it again when a token names an unknown key, and\n * keeps the last good set while the issuer is down. It uses Web Crypto only,\n * so it runs in Workers and Node 20 or later.\n */\nexport function createTokenVerifier(\n options: TokenVerifierOptions,\n): TokenVerifier {\n const issuer = trimSlash(options.issuer);\n const allowed: readonly string[] = options.algorithms ?? [\"RS256\", \"ES256\"];\n const tolerance = options.clockToleranceSec ?? 60;\n const ttl = (options.cacheTtlSec ?? 3600) * 1000;\n const prefix =\n options.subjectPrefix === undefined ? \"cus_\" : options.subjectPrefix;\n const now = options.now ?? Date.now;\n const read = options.fetch ?? ((url, init) => fetch(url, init));\n if (\n !issuer ||\n !options.audience ||\n !allowed.length ||\n allowed.some((alg) => !(alg in specs)) ||\n !numeric(tolerance) ||\n tolerance < 0 ||\n !numeric(ttl) ||\n ttl < 0\n ) {\n throw new TypeError(\n \"createTokenVerifier needs an issuer, an audience, RS256 or ES256, and a clock tolerance and cache time of 0 or more.\",\n );\n }\n\n let cached: KeySet | undefined;\n let pending: Promise<KeySet> | undefined;\n let triedAt = Number.NEGATIVE_INFINITY;\n let lastError: unknown;\n\n const load = async (): Promise<KeySet> => {\n try {\n const response = await read(\n options.jwksUrl ?? `${issuer}/.well-known/jwks.json`,\n {\n headers: { accept: \"application/json\" },\n signal: AbortSignal.timeout(10_000),\n },\n );\n const text = response.ok ? await response.text() : \"\";\n if (!text || text.length > 1 << 20) {\n throw new Error(`The key set answered ${response.status}.`);\n }\n const keys = new Map<string, CryptoKey>();\n const list = (JSON.parse(text) as { keys?: unknown }).keys;\n for (const jwk of Array.isArray(list) ? (list as Jwk[]) : []) {\n for (const alg of allowed as TokenAlgorithm[]) {\n const spec = specs[alg];\n if (\n jwk.kty === spec.kty &&\n (jwk.use ?? \"sig\") === \"sig\" &&\n (jwk.alg ?? alg) === alg &&\n (alg === \"RS256\" || jwk.crv === \"P-256\")\n ) {\n // A key this runtime can't import can't have signed a token we accept.\n await crypto.subtle\n .importKey(\n \"jwk\",\n spec.jwk(jwk) as JsonWebKey,\n spec.import,\n false,\n [\"verify\"],\n )\n .then((key) => keys.set(`${alg}:${String(jwk.kid ?? \"\")}`, key))\n .catch(() => undefined);\n }\n }\n }\n // An empty or unusable set is an issuer fault, not a rotation, so it\n // never replaces the keys that verified tokens until now.\n if (!keys.size) {\n throw new Error(\"The key set has no keys this verifier can use.\");\n }\n return (cached = { keys, at: now() });\n } catch (error) {\n return fail(\"keys_unavailable\", error);\n }\n };\n\n const find = (set: KeySet, kid: unknown, alg: string) => {\n if (typeof kid === \"string\") {\n return set.keys.get(`${alg}:${kid}`);\n }\n // Without a kid, only a key set with one key for the algorithm can answer.\n const fits = [...set.keys].filter(([id]) => id.startsWith(`${alg}:`));\n return fits.length === 1 ? fits[0]?.[1] : undefined;\n };\n\n // The key set is read at most every 30 seconds, whether a forged kid\n // missed the cache or the issuer is down, so neither can make every\n // request call the issuer.\n const keyFor = async (kid: unknown, alg: string) => {\n if (!pending) {\n const key = cached && find(cached, kid, alg);\n if (key && cached && now() - cached.at < ttl) {\n return key;\n }\n if (now() - triedAt < KEY_RETRY_MS) {\n return cached ? key : fail(\"keys_unavailable\", lastError);\n }\n triedAt = now();\n pending = load().finally(() => (pending = undefined));\n }\n try {\n const set = await pending;\n lastError = undefined;\n return find(set, kid, alg);\n } catch (error) {\n lastError = error;\n return (\n (cached && find(cached, kid, alg)) ?? fail(\"keys_unavailable\", error)\n );\n }\n };\n\n const check = async (\n token: string,\n checks: VerifyTokenChecks,\n idToken: boolean,\n ): Promise<TokenClaims> => {\n const parts =\n typeof token === \"string\" && token.length < 16_384\n ? token.split(\".\")\n : [];\n const [head = \"\", body = \"\", sig = \"\"] =\n parts.length === 3 ? parts : fail(\"malformed\");\n const header = decodeJson(head);\n const claims = decodeJson(body);\n const signature = base64UrlDecode(sig) ?? fail(\"malformed\");\n const alg = header.alg as TokenAlgorithm;\n if (!allowed.includes(alg) || header.crit !== undefined) {\n fail(\"unsupported_algorithm\");\n }\n if (\n idToken &&\n header.typ !== undefined &&\n (typeof header.typ !== \"string\" ||\n header.typ.toLowerCase().replace(/^application\\//, \"\") !== \"jwt\")\n ) {\n fail(\"wrong_type\");\n }\n const key = (await keyFor(header.kid, alg)) ?? fail(\"unknown_key\");\n if (\n (alg === \"ES256\" && signature.length !== 64) ||\n !(await crypto.subtle.verify(\n specs[alg].verify,\n key,\n signature,\n encoder.encode(`${head}.${body}`),\n ))\n ) {\n fail(\"invalid_signature\");\n }\n\n const seconds = now() / 1000;\n const times = [claims.nbf, claims.iat];\n const sub = claims.sub;\n if (typeof claims.iss !== \"string\" || trimSlash(claims.iss) !== issuer) {\n fail(\"wrong_issuer\");\n }\n if (![claims.aud].flat().includes(checks.audience ?? options.audience)) {\n fail(\"wrong_audience\");\n }\n if (\n !numeric(claims.exp) ||\n times.some((time) => time !== undefined && !numeric(time))\n ) {\n fail(\"malformed\");\n }\n if (seconds > (claims.exp as number) + tolerance) {\n fail(\"expired\");\n }\n if (times.some((time) => (time as number) > seconds + tolerance)) {\n fail(\"not_yet_valid\");\n }\n if (checks.nonce !== undefined && claims.nonce !== checks.nonce) {\n fail(\"wrong_nonce\");\n }\n if (\n (prefix !== null || idToken) &&\n (typeof sub !== \"string\" ||\n !sub ||\n (prefix !== null &&\n (!sub.startsWith(prefix) || sub.length === prefix.length)))\n ) {\n fail(\"wrong_subject\");\n }\n return claims as TokenClaims;\n };\n\n return {\n verify: (token, checks = {}) => check(token, checks, false),\n async verifyIdToken(token, checks) {\n if (typeof checks?.nonce !== \"string\" || !checks.nonce) {\n throw new TypeError(\n \"verifyIdToken needs the nonce you sent with the sign-in.\",\n );\n }\n return (await check(token, checks, true)) as IdTokenClaims;\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/server/auth/session-store.ts
|
|
2
|
+
import { ZappieError } from "../../errors.js";
|
|
3
|
+
import {
|
|
4
|
+
openSealedValue,
|
|
5
|
+
sealValue,
|
|
6
|
+
secretsOf
|
|
7
|
+
} from "./crypto.js";
|
|
8
|
+
function cookieSessionStore(options) {
|
|
9
|
+
const maxAge = (options.maxAgeSec ?? 30 * 24 * 60 * 60) * 1e3;
|
|
10
|
+
const now = options.now ?? Date.now;
|
|
11
|
+
const sealing = { secret: options.secret, purpose: "zappie_session" };
|
|
12
|
+
secretsOf(options.secret);
|
|
13
|
+
if (!(maxAge > 0 && Number.isFinite(maxAge))) {
|
|
14
|
+
throw new TypeError("maxAgeSec must be a positive number of seconds.");
|
|
15
|
+
}
|
|
16
|
+
const refuse = (message) => {
|
|
17
|
+
throw new ZappieError("invalid_request", message);
|
|
18
|
+
};
|
|
19
|
+
const seal = async (envelope) => {
|
|
20
|
+
const sealed = await sealValue(envelope, sealing);
|
|
21
|
+
return sealed.length > 3800 ? refuse(
|
|
22
|
+
"This session is too large for a cookie. Use a session store that keeps sessions on the server."
|
|
23
|
+
) : sealed;
|
|
24
|
+
};
|
|
25
|
+
const open = async (sid) => {
|
|
26
|
+
const envelope = await openSealedValue(sid, sealing);
|
|
27
|
+
return envelope?.v === 1 && envelope.exp > now() ? envelope : null;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
create: (data) => seal({ v: 1, exp: now() + maxAge, data }),
|
|
31
|
+
get: async (sid) => (await open(sid))?.data ?? null,
|
|
32
|
+
async update(sid, patch) {
|
|
33
|
+
const envelope = await open(sid);
|
|
34
|
+
return envelope ? seal({ ...envelope, data: { ...envelope.data, ...patch } }) : null;
|
|
35
|
+
},
|
|
36
|
+
// The session is the cookie, so clearing the cookie ends it.
|
|
37
|
+
delete: async () => void 0,
|
|
38
|
+
deleteBySub: async () => refuse(
|
|
39
|
+
"A cookie session store can't end sessions from the server. Use a session store that keeps sessions on the server for back-channel logout."
|
|
40
|
+
)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
cookieSessionStore
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=session-store.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/server/auth/session-store.ts"],
|
|
4
|
+
"sourcesContent": ["import { ZappieError } from \"../../errors\";\nimport {\n openSealedValue,\n sealValue,\n secretsOf,\n type SealSecret,\n} from \"./crypto\";\n\n/** What a signed-in session holds on the server. Tokens never reach the browser. */\nexport type SessionData = {\n /** The customer's subject, such as `cus_7Kd`. */\n sub: string;\n /** The OAuth client the session belongs to, your site's client id. */\n clientId: string;\n /** What the site may show about the customer. */\n customer: { id: string; givenName?: string; phoneMasked: string };\n accessToken: string;\n /** When the access token expires, in epoch milliseconds. */\n accessExp: number;\n refreshToken: string;\n /** The issuer's session id from the ID token, for back-channel logout. */\n idTokenSid?: string;\n scopes: string[];\n /** Epoch milliseconds. */\n createdAt: number;\n /** Epoch milliseconds. */\n lastSeenAt: number;\n mode: \"test\" | \"live\";\n};\n\n/**\n * Where signed-in sessions live. The session id is what the browser's\n * session cookie holds.\n */\nexport interface SessionStore {\n /** Save a new session and return its id. */\n create(data: SessionData): Promise<string>;\n /** Read a session, or null when it doesn't exist or has expired. */\n get(sid: string): Promise<SessionData | null>;\n /**\n * Change a session and return the id to keep in the cookie, or null when\n * the session no longer exists. Stores that keep sessions on the server\n * return the same id. The cookie store returns a new one.\n */\n update(sid: string, patch: Partial<SessionData>): Promise<string | null>;\n /** End one session. */\n delete(sid: string): Promise<void>;\n /** End every session for a customer and client, for back-channel logout. */\n deleteBySub(sub: string, clientId: string): Promise<void>;\n}\n\n/** Settings for the encrypted-cookie session store. */\nexport type CookieSessionStoreOptions = {\n /**\n * At least 32 bytes each, from a random generator such as `randomToken()`,\n * never a password or phrase. The key is the secret's SHA-256 with no\n * stretching, so a guessable secret can be found offline from one sealed\n * value. List the new secret first while you rotate.\n */\n secret: SealSecret;\n /** How long a session lasts from sign-in, in seconds. Defaults to 30 days. */\n maxAgeSec?: number;\n /** The current time in milliseconds. For tests. */\n now?: () => number;\n};\n\ntype Envelope = { v: 1; exp: number; data: SessionData };\n\n/**\n * Create a session store that keeps the whole session, sealed with AES-GCM,\n * in the cookie itself, so it needs no database. It can't end a session from\n * the server, so back-channel logout needs a store that keeps sessions there.\n * Signing out clears the cookie in that browser only. A copy of the cookie\n * still opens the session until it expires, 30 days after sign-in by\n * default, or until you rotate the secret out.\n */\nexport function cookieSessionStore(\n options: CookieSessionStoreOptions,\n): SessionStore {\n const maxAge = (options.maxAgeSec ?? 30 * 24 * 60 * 60) * 1000;\n const now = options.now ?? Date.now;\n const sealing = { secret: options.secret, purpose: \"zappie_session\" };\n secretsOf(options.secret);\n if (!(maxAge > 0 && Number.isFinite(maxAge))) {\n throw new TypeError(\"maxAgeSec must be a positive number of seconds.\");\n }\n const refuse = (message: string) => {\n throw new ZappieError(\"invalid_request\", message);\n };\n const seal = async (envelope: Envelope) => {\n const sealed = await sealValue(envelope, sealing);\n // Browsers drop a cookie over 4 KB without an error.\n return sealed.length > 3800\n ? refuse(\n \"This session is too large for a cookie. Use a session store that keeps sessions on the server.\",\n )\n : sealed;\n };\n const open = async (sid: string) => {\n const envelope = await openSealedValue<Envelope>(sid, sealing);\n return envelope?.v === 1 && envelope.exp > now() ? envelope : null;\n };\n\n return {\n create: (data) => seal({ v: 1, exp: now() + maxAge, data }),\n get: async (sid) => (await open(sid))?.data ?? null,\n async update(sid, patch) {\n const envelope = await open(sid);\n return envelope\n ? seal({ ...envelope, data: { ...envelope.data, ...patch } })\n : null;\n },\n // The session is the cookie, so clearing the cookie ends it.\n delete: async () => undefined,\n deleteBySub: async () =>\n refuse(\n \"A cookie session store can't end sessions from the server. Use a session store that keeps sessions on the server for back-channel logout.\",\n ),\n };\n}\n"],
|
|
5
|
+
"mappings": ";AAAA,SAAS,mBAAmB;AAC5B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAsEA,SAAS,mBACd,SACc;AACd,QAAM,UAAU,QAAQ,aAAa,KAAK,KAAK,KAAK,MAAM;AAC1D,QAAM,MAAM,QAAQ,OAAO,KAAK;AAChC,QAAM,UAAU,EAAE,QAAQ,QAAQ,QAAQ,SAAS,iBAAiB;AACpE,YAAU,QAAQ,MAAM;AACxB,MAAI,EAAE,SAAS,KAAK,OAAO,SAAS,MAAM,IAAI;AAC5C,UAAM,IAAI,UAAU,iDAAiD;AAAA,EACvE;AACA,QAAM,SAAS,CAAC,YAAoB;AAClC,UAAM,IAAI,YAAY,mBAAmB,OAAO;AAAA,EAClD;AACA,QAAM,OAAO,OAAO,aAAuB;AACzC,UAAM,SAAS,MAAM,UAAU,UAAU,OAAO;AAEhD,WAAO,OAAO,SAAS,OACnB;AAAA,MACE;AAAA,IACF,IACA;AAAA,EACN;AACA,QAAM,OAAO,OAAO,QAAgB;AAClC,UAAM,WAAW,MAAM,gBAA0B,KAAK,OAAO;AAC7D,WAAO,UAAU,MAAM,KAAK,SAAS,MAAM,IAAI,IAAI,WAAW;AAAA,EAChE;AAEA,SAAO;AAAA,IACL,QAAQ,CAAC,SAAS,KAAK,EAAE,GAAG,GAAG,KAAK,IAAI,IAAI,QAAQ,KAAK,CAAC;AAAA,IAC1D,KAAK,OAAO,SAAS,MAAM,KAAK,GAAG,IAAI,QAAQ;AAAA,IAC/C,MAAM,OAAO,KAAK,OAAO;AACvB,YAAM,WAAW,MAAM,KAAK,GAAG;AAC/B,aAAO,WACH,KAAK,EAAE,GAAG,UAAU,MAAM,EAAE,GAAG,SAAS,MAAM,GAAG,MAAM,EAAE,CAAC,IAC1D;AAAA,IACN;AAAA;AAAA,IAEA,QAAQ,YAAY;AAAA,IACpB,aAAa,YACX;AAAA,MACE;AAAA,IACF;AAAA,EACJ;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/server/edge-snapshot.ts
|
|
2
|
+
import { mapMenu } from "../transport/mappers.js";
|
|
3
|
+
var snapshotKeys = {
|
|
4
|
+
/** `store:<storeId>` holds a StoreSnapshot. */
|
|
5
|
+
store: (storeId) => `store:${storeId}`,
|
|
6
|
+
/** `menu:<storeId>:<branchId>`, or `menu:<storeId>` for the default branch. */
|
|
7
|
+
menu: (storeId, branchId) => branchId === void 0 ? `menu:${storeId}` : `menu:${storeId}:${branchId}`,
|
|
8
|
+
/** `avail:<storeId>:<branchId>` holds an AvailabilityPatch. */
|
|
9
|
+
availability: (storeId, branchId) => `avail:${storeId}:${branchId}`,
|
|
10
|
+
/** `ver:<storeId>` holds a SnapshotVersionRecord. */
|
|
11
|
+
version: (storeId) => `ver:${storeId}`
|
|
12
|
+
};
|
|
13
|
+
function canonical(value, seen) {
|
|
14
|
+
if (value === null || typeof value === "boolean") {
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === "number") {
|
|
21
|
+
if (!Number.isFinite(value)) {
|
|
22
|
+
throw new TypeError("Canonical JSON can't hold a non-finite number.");
|
|
23
|
+
}
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
}
|
|
26
|
+
if (typeof value !== "object") {
|
|
27
|
+
throw new TypeError(`Canonical JSON can't hold a ${typeof value}.`);
|
|
28
|
+
}
|
|
29
|
+
if (seen.has(value)) {
|
|
30
|
+
throw new TypeError("Canonical JSON can't hold a cycle.");
|
|
31
|
+
}
|
|
32
|
+
seen.add(value);
|
|
33
|
+
let out;
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
out = `[${value.map((entry) => entry === void 0 ? "null" : canonical(entry, seen)).join(",")}]`;
|
|
36
|
+
} else {
|
|
37
|
+
const prototype = Object.getPrototypeOf(value);
|
|
38
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
39
|
+
throw new TypeError("Canonical JSON holds plain objects only.");
|
|
40
|
+
}
|
|
41
|
+
out = `{${Object.keys(value).filter((key) => value[key] !== void 0).sort().map(
|
|
42
|
+
(key) => `${JSON.stringify(key)}:${canonical(value[key], seen)}`
|
|
43
|
+
).join(",")}}`;
|
|
44
|
+
}
|
|
45
|
+
seen.delete(value);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function canonicalJson(value) {
|
|
49
|
+
return canonical(value, /* @__PURE__ */ new Set());
|
|
50
|
+
}
|
|
51
|
+
async function snapshotHash(value) {
|
|
52
|
+
const digest = await crypto.subtle.digest(
|
|
53
|
+
"SHA-256",
|
|
54
|
+
new TextEncoder().encode(canonicalJson(value))
|
|
55
|
+
);
|
|
56
|
+
return Array.from(
|
|
57
|
+
new Uint8Array(digest).slice(0, 8),
|
|
58
|
+
(byte) => byte.toString(16).padStart(2, "0")
|
|
59
|
+
).join("");
|
|
60
|
+
}
|
|
61
|
+
var isoTime = (value) => {
|
|
62
|
+
const time = value === void 0 ? /* @__PURE__ */ new Date() : new Date(value);
|
|
63
|
+
if (Number.isNaN(time.getTime())) {
|
|
64
|
+
throw new TypeError("Snapshot times must be valid dates.");
|
|
65
|
+
}
|
|
66
|
+
return time.toISOString();
|
|
67
|
+
};
|
|
68
|
+
async function createMenuSnapshot({
|
|
69
|
+
storeId,
|
|
70
|
+
menu,
|
|
71
|
+
builtAt,
|
|
72
|
+
sourceUpdatedAt
|
|
73
|
+
}) {
|
|
74
|
+
if (!storeId || !menu.branchId || !menu.version) {
|
|
75
|
+
throw new TypeError(
|
|
76
|
+
"A menu snapshot needs a store, a branch and a menu version."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (menu.completeness === "partial") {
|
|
80
|
+
throw new TypeError("A partial menu can't become a snapshot.");
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
v: 1,
|
|
84
|
+
storeId,
|
|
85
|
+
branchId: menu.branchId,
|
|
86
|
+
version: menu.version,
|
|
87
|
+
contentHash: await snapshotHash(menu),
|
|
88
|
+
builtAt: isoTime(builtAt),
|
|
89
|
+
...sourceUpdatedAt === void 0 ? {} : { sourceUpdatedAt: isoTime(sourceUpdatedAt) },
|
|
90
|
+
menu
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function menuSnapshotFromPayload({
|
|
94
|
+
storeId,
|
|
95
|
+
branchId,
|
|
96
|
+
payload,
|
|
97
|
+
builtAt
|
|
98
|
+
}) {
|
|
99
|
+
const menu = mapMenu(payload, branchId);
|
|
100
|
+
const updates = (Array.isArray(payload?.menus) ? payload.menus : []).map((source) => source?.updated_at).filter(
|
|
101
|
+
(value) => typeof value === "string" && !Number.isNaN(Date.parse(value))
|
|
102
|
+
).sort((left, right) => Date.parse(right) - Date.parse(left));
|
|
103
|
+
return createMenuSnapshot({
|
|
104
|
+
storeId,
|
|
105
|
+
menu,
|
|
106
|
+
...builtAt === void 0 ? {} : { builtAt },
|
|
107
|
+
...updates[0] === void 0 ? {} : { sourceUpdatedAt: updates[0] }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function createStoreSnapshot({
|
|
111
|
+
store,
|
|
112
|
+
builtAt
|
|
113
|
+
}) {
|
|
114
|
+
if (!store.id) {
|
|
115
|
+
throw new TypeError("A store snapshot needs a store id.");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
v: 1,
|
|
119
|
+
storeId: store.id,
|
|
120
|
+
contentHash: await snapshotHash(store),
|
|
121
|
+
builtAt: isoTime(builtAt),
|
|
122
|
+
store
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
var ids = (values) => [...new Set(values ?? [])].sort();
|
|
126
|
+
async function createAvailabilityPatch(input) {
|
|
127
|
+
if (!input.menuVersion) {
|
|
128
|
+
throw new TypeError("An availability patch needs the API's menu version.");
|
|
129
|
+
}
|
|
130
|
+
const content = {
|
|
131
|
+
v: 1,
|
|
132
|
+
menuVersion: input.menuVersion,
|
|
133
|
+
soldOutItems: ids(input.soldOutItems),
|
|
134
|
+
soldOutVariants: ids(input.soldOutVariants),
|
|
135
|
+
soldOutOptions: ids(input.soldOutOptions),
|
|
136
|
+
...input.closedUntil === void 0 ? {} : { closedUntil: isoTime(input.closedUntil) }
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
...content,
|
|
140
|
+
version: await snapshotHash(content),
|
|
141
|
+
updatedAt: isoTime(input.updatedAt)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function applyAvailability(snapshot, patch) {
|
|
145
|
+
if (!patch || Date.parse(patch.updatedAt) < Date.parse(snapshot.builtAt)) {
|
|
146
|
+
return snapshot.menu;
|
|
147
|
+
}
|
|
148
|
+
const items = new Set(patch.soldOutItems);
|
|
149
|
+
const variants = new Set(patch.soldOutVariants);
|
|
150
|
+
const options = new Set(patch.soldOutOptions);
|
|
151
|
+
return {
|
|
152
|
+
...snapshot.menu,
|
|
153
|
+
version: patch.menuVersion,
|
|
154
|
+
items: Object.fromEntries(
|
|
155
|
+
Object.entries(snapshot.menu.items).map(([id, item]) => {
|
|
156
|
+
const touched = item.variants.some(
|
|
157
|
+
(variant) => variants.has(variant.id)
|
|
158
|
+
);
|
|
159
|
+
const patched = item.variants.map(
|
|
160
|
+
(variant) => variants.has(variant.id) ? { ...variant, isAvailable: false } : variant
|
|
161
|
+
);
|
|
162
|
+
return [
|
|
163
|
+
id,
|
|
164
|
+
{
|
|
165
|
+
...item,
|
|
166
|
+
// Selling out every variant sells out the item.
|
|
167
|
+
isAvailable: item.isAvailable && !items.has(id) && (!touched || patched.some((variant) => variant.isAvailable)),
|
|
168
|
+
variants: patched,
|
|
169
|
+
optionGroups: item.optionGroups.map((group) => ({
|
|
170
|
+
...group,
|
|
171
|
+
choices: group.choices.map(
|
|
172
|
+
(choice) => options.has(choice.id) ? { ...choice, isAvailable: false } : choice
|
|
173
|
+
)
|
|
174
|
+
}))
|
|
175
|
+
}
|
|
176
|
+
];
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function isMenuSnapshot(value) {
|
|
182
|
+
if (!value || typeof value !== "object") {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const snapshot = value;
|
|
186
|
+
const menu = snapshot.menu;
|
|
187
|
+
return snapshot.v === 1 && typeof snapshot.storeId === "string" && typeof snapshot.branchId === "string" && typeof snapshot.version === "string" && typeof snapshot.contentHash === "string" && typeof snapshot.builtAt === "string" && !!menu && typeof menu === "object" && menu.branchId === snapshot.branchId && menu.version === snapshot.version && menu.completeness !== "partial" && Array.isArray(menu.categories) && !!menu.items && typeof menu.items === "object";
|
|
188
|
+
}
|
|
189
|
+
export {
|
|
190
|
+
applyAvailability,
|
|
191
|
+
canonicalJson,
|
|
192
|
+
createAvailabilityPatch,
|
|
193
|
+
createMenuSnapshot,
|
|
194
|
+
createStoreSnapshot,
|
|
195
|
+
isMenuSnapshot,
|
|
196
|
+
menuSnapshotFromPayload,
|
|
197
|
+
snapshotHash,
|
|
198
|
+
snapshotKeys
|
|
199
|
+
};
|
|
200
|
+
//# sourceMappingURL=edge-snapshot.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/server/edge-snapshot.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Menu, Store } from \"../models\";\nimport { mapMenu } from \"../transport/mappers\";\n\n/**\n * A branch catalogue as the edge keeps it: the SDK `Menu` that `menu.get()`\n * returns, with the facts a snapshot writer and reader need around it.\n */\nexport type MenuSnapshot = {\n v: 1;\n storeId: string;\n branchId: string;\n /** The API's menu version, the same value `GET /menus/version` returns. */\n version: string;\n /** The first 16 hex digits of the SHA-256 of the menu's canonical JSON. */\n contentHash: string;\n /** When the snapshot was built, as an ISO time. */\n builtAt: string;\n /** The latest change among the source menus, as an ISO time, when the API sends it. */\n sourceUpdatedAt?: string;\n /** A complete branch catalogue. Partial category pages never become snapshots. */\n menu: Menu;\n};\n\n/** Store details as the edge keeps them: the SDK `Store` that `store.get()` returns. */\nexport type StoreSnapshot = {\n v: 1;\n storeId: string;\n /** The first 16 hex digits of the SHA-256 of the store's canonical JSON. */\n contentHash: string;\n /** When the snapshot was built, as an ISO time. */\n builtAt: string;\n store: Store;\n};\n\n/**\n * What sold out since a branch's menu snapshot was built. It is small, so an\n * availability change rewrites about 1 KB instead of the whole menu.\n */\nexport type AvailabilityPatch = {\n v: 1;\n /** The first 16 hex digits of the SHA-256 of this patch's canonical JSON. */\n version: string;\n /** The API's menu version after this change. A patched menu reports it. */\n menuVersion: string;\n soldOutItems: string[];\n soldOutVariants: string[];\n /** Choice ids in option groups. */\n soldOutOptions: string[];\n /** When the branch reopens, as an ISO time, if it is closed for now. */\n closedUntil?: string;\n /** When the patch was built, as an ISO time. */\n updatedAt: string;\n};\n\n/** The version record a writer saves last, so readers never see unwritten data. */\nexport type SnapshotVersionRecord = {\n /** The default branch's menu version. */\n version: string;\n builtAt: string;\n branches: Record<string, string>;\n};\n\n/** The edge key-value names for each snapshot. */\nexport const snapshotKeys = {\n /** `store:<storeId>` holds a StoreSnapshot. */\n store: (storeId: string) => `store:${storeId}`,\n /** `menu:<storeId>:<branchId>`, or `menu:<storeId>` for the default branch. */\n menu: (storeId: string, branchId?: string) =>\n branchId === undefined ? `menu:${storeId}` : `menu:${storeId}:${branchId}`,\n /** `avail:<storeId>:<branchId>` holds an AvailabilityPatch. */\n availability: (storeId: string, branchId: string) =>\n `avail:${storeId}:${branchId}`,\n /** `ver:<storeId>` holds a SnapshotVersionRecord. */\n version: (storeId: string) => `ver:${storeId}`,\n} as const;\n\nfunction canonical(value: unknown, seen: Set<object>): string {\n if (value === null || typeof value === \"boolean\") {\n return JSON.stringify(value);\n }\n if (typeof value === \"string\") {\n return JSON.stringify(value);\n }\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) {\n throw new TypeError(\"Canonical JSON can't hold a non-finite number.\");\n }\n return JSON.stringify(value);\n }\n if (typeof value !== \"object\") {\n throw new TypeError(`Canonical JSON can't hold a ${typeof value}.`);\n }\n if (seen.has(value)) {\n throw new TypeError(\"Canonical JSON can't hold a cycle.\");\n }\n seen.add(value);\n let out: string;\n if (Array.isArray(value)) {\n out = `[${value\n .map((entry) => (entry === undefined ? \"null\" : canonical(entry, seen)))\n .join(\",\")}]`;\n } else {\n const prototype = Object.getPrototypeOf(value) as unknown;\n if (prototype !== Object.prototype && prototype !== null) {\n throw new TypeError(\"Canonical JSON holds plain objects only.\");\n }\n out = `{${Object.keys(value)\n .filter((key) => (value as Record<string, unknown>)[key] !== undefined)\n .sort()\n .map(\n (key) =>\n `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key], seen)}`,\n )\n .join(\",\")}}`;\n }\n seen.delete(value);\n return out;\n}\n\n/**\n * Serialise a JSON value with object keys in sorted order and undefined\n * properties left out, so equal values always give the same text.\n */\nexport function canonicalJson(value: unknown): string {\n return canonical(value, new Set());\n}\n\n/** Hash a JSON value: the first 16 hex digits of the SHA-256 of its canonical JSON. */\nexport async function snapshotHash(value: unknown): Promise<string> {\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(canonicalJson(value)),\n );\n return Array.from(new Uint8Array(digest).slice(0, 8), (byte) =>\n byte.toString(16).padStart(2, \"0\"),\n ).join(\"\");\n}\n\nconst isoTime = (value: Date | string | undefined) => {\n const time = value === undefined ? new Date() : new Date(value);\n if (Number.isNaN(time.getTime())) {\n throw new TypeError(\"Snapshot times must be valid dates.\");\n }\n return time.toISOString();\n};\n\n/** Input for building a MenuSnapshot from an SDK menu. */\nexport type MenuSnapshotInput = {\n storeId: string;\n menu: Menu;\n /** Defaults to now. */\n builtAt?: Date | string;\n sourceUpdatedAt?: Date | string;\n};\n\n/** Build a MenuSnapshot from a complete SDK menu. */\nexport async function createMenuSnapshot({\n storeId,\n menu,\n builtAt,\n sourceUpdatedAt,\n}: MenuSnapshotInput): Promise<MenuSnapshot> {\n if (!storeId || !menu.branchId || !menu.version) {\n throw new TypeError(\n \"A menu snapshot needs a store, a branch and a menu version.\",\n );\n }\n if (menu.completeness === \"partial\") {\n throw new TypeError(\"A partial menu can't become a snapshot.\");\n }\n return {\n v: 1,\n storeId,\n branchId: menu.branchId,\n version: menu.version,\n contentHash: await snapshotHash(menu),\n builtAt: isoTime(builtAt),\n ...(sourceUpdatedAt === undefined\n ? {}\n : { sourceUpdatedAt: isoTime(sourceUpdatedAt) }),\n menu,\n };\n}\n\n/** Input for building a MenuSnapshot from the API's menu payload. */\nexport type MenuSnapshotPayloadInput = {\n storeId: string;\n branchId: string;\n /** The JSON body of the API's branch menu read. */\n payload: unknown;\n /** Defaults to now. */\n builtAt?: Date | string;\n};\n\n/**\n * Build a MenuSnapshot from the API's branch menu payload. It maps the payload\n * with the same mapper `menu.get()` uses, so the snapshot's menu is exactly\n * what the client SDK returns.\n */\nexport async function menuSnapshotFromPayload({\n storeId,\n branchId,\n payload,\n builtAt,\n}: MenuSnapshotPayloadInput): Promise<MenuSnapshot> {\n const menu = mapMenu(payload, branchId);\n const updates = (\n Array.isArray((payload as { menus?: unknown } | null)?.menus)\n ? ((payload as { menus: unknown[] }).menus as { updated_at?: unknown }[])\n : []\n )\n .map((source) => source?.updated_at)\n .filter(\n (value): value is string =>\n typeof value === \"string\" && !Number.isNaN(Date.parse(value)),\n )\n .sort((left, right) => Date.parse(right) - Date.parse(left));\n return createMenuSnapshot({\n storeId,\n menu,\n ...(builtAt === undefined ? {} : { builtAt }),\n ...(updates[0] === undefined ? {} : { sourceUpdatedAt: updates[0] }),\n });\n}\n\n/** Build a StoreSnapshot from an SDK store. */\nexport async function createStoreSnapshot({\n store,\n builtAt,\n}: {\n store: Store;\n builtAt?: Date | string;\n}): Promise<StoreSnapshot> {\n if (!store.id) {\n throw new TypeError(\"A store snapshot needs a store id.\");\n }\n return {\n v: 1,\n storeId: store.id,\n contentHash: await snapshotHash(store),\n builtAt: isoTime(builtAt),\n store,\n };\n}\n\n/** Input for building an AvailabilityPatch. */\nexport type AvailabilityPatchInput = {\n menuVersion: string;\n soldOutItems?: readonly string[];\n soldOutVariants?: readonly string[];\n soldOutOptions?: readonly string[];\n closedUntil?: Date | string;\n /** Defaults to now. */\n updatedAt?: Date | string;\n};\n\nconst ids = (values: readonly string[] | undefined) =>\n [...new Set(values ?? [])].sort();\n\n/** Build an AvailabilityPatch with sorted, unique ids and its content hash. */\nexport async function createAvailabilityPatch(\n input: AvailabilityPatchInput,\n): Promise<AvailabilityPatch> {\n if (!input.menuVersion) {\n throw new TypeError(\"An availability patch needs the API's menu version.\");\n }\n const content = {\n v: 1 as const,\n menuVersion: input.menuVersion,\n soldOutItems: ids(input.soldOutItems),\n soldOutVariants: ids(input.soldOutVariants),\n soldOutOptions: ids(input.soldOutOptions),\n ...(input.closedUntil === undefined\n ? {}\n : { closedUntil: isoTime(input.closedUntil) }),\n };\n return {\n ...content,\n version: await snapshotHash(content),\n updatedAt: isoTime(input.updatedAt),\n };\n}\n\n/**\n * Return the snapshot's menu with an availability patch applied. A patch\n * built before the snapshot is ignored, because the rebuilt menu already\n * holds newer availability. The result reports the patch's menu version.\n */\nexport function applyAvailability(\n snapshot: MenuSnapshot,\n patch: AvailabilityPatch | null | undefined,\n): Menu {\n if (!patch || Date.parse(patch.updatedAt) < Date.parse(snapshot.builtAt)) {\n return snapshot.menu;\n }\n const items = new Set(patch.soldOutItems);\n const variants = new Set(patch.soldOutVariants);\n const options = new Set(patch.soldOutOptions);\n return {\n ...snapshot.menu,\n version: patch.menuVersion,\n items: Object.fromEntries(\n Object.entries(snapshot.menu.items).map(([id, item]) => {\n const touched = item.variants.some((variant) =>\n variants.has(variant.id),\n );\n const patched = item.variants.map((variant) =>\n variants.has(variant.id)\n ? { ...variant, isAvailable: false }\n : variant,\n ) as typeof item.variants;\n return [\n id,\n {\n ...item,\n // Selling out every variant sells out the item.\n isAvailable:\n item.isAvailable &&\n !items.has(id) &&\n (!touched || patched.some((variant) => variant.isAvailable)),\n variants: patched,\n optionGroups: item.optionGroups.map((group) => ({\n ...group,\n choices: group.choices.map((choice) =>\n options.has(choice.id)\n ? { ...choice, isAvailable: false }\n : choice,\n ),\n })),\n },\n ];\n }),\n ),\n };\n}\n\n/** Check that a value read from the edge is a MenuSnapshot this SDK can use. */\nexport function isMenuSnapshot(value: unknown): value is MenuSnapshot {\n if (!value || typeof value !== \"object\") {\n return false;\n }\n const snapshot = value as Partial<MenuSnapshot>;\n const menu = snapshot.menu as Partial<Menu> | undefined;\n return (\n snapshot.v === 1 &&\n typeof snapshot.storeId === \"string\" &&\n typeof snapshot.branchId === \"string\" &&\n typeof snapshot.version === \"string\" &&\n typeof snapshot.contentHash === \"string\" &&\n typeof snapshot.builtAt === \"string\" &&\n !!menu &&\n typeof menu === \"object\" &&\n menu.branchId === snapshot.branchId &&\n menu.version === snapshot.version &&\n menu.completeness !== \"partial\" &&\n Array.isArray(menu.categories) &&\n !!menu.items &&\n typeof menu.items === \"object\"\n );\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as Menu,
|
|
1
|
+
import { a as Menu, S as Store, F as Formatters } from '../models-D__QbqEd.js';
|
|
2
2
|
import { a as ZappieError, b as ZappieConfig, T as Transport, S as StorageLike, c as StoreResource, M as MenuResource, d as ZappieEvents, C as Cache } from '../client-CSOZOwQW.js';
|
|
3
3
|
export { VERSION } from '../index.js';
|
|
4
4
|
import { d as CartModule, b as CartLineInput } from '../types-gQXU2gEA.js';
|
|
@@ -162,6 +162,328 @@ declare const parseStorefrontUrl: (input: StorefrontUrlInput, options?: Storefro
|
|
|
162
162
|
*/
|
|
163
163
|
declare const storefrontRedirect: (input: StorefrontUrlInput, options?: StorefrontUrlOptions) => string | null;
|
|
164
164
|
|
|
165
|
+
/** A PKCE code verifier and its S256 challenge for one sign-in attempt. */
|
|
166
|
+
type PkcePair = {
|
|
167
|
+
/** Keep this on the server and send it with the code exchange. */
|
|
168
|
+
verifier: string;
|
|
169
|
+
/** Send this to the authorize endpoint as `code_challenge`. */
|
|
170
|
+
challenge: string;
|
|
171
|
+
/** Send this as `code_challenge_method`. */
|
|
172
|
+
method: "S256";
|
|
173
|
+
};
|
|
174
|
+
/** One sealing secret, or several while you rotate them. The first one seals. */
|
|
175
|
+
type SealSecret = string | readonly string[];
|
|
176
|
+
/** Options for sealing a value into a cookie-safe string. */
|
|
177
|
+
type SealOptions = {
|
|
178
|
+
/**
|
|
179
|
+
* At least 32 bytes each, from a random generator such as `randomToken()`,
|
|
180
|
+
* never a password or phrase. The key is the secret's SHA-256 with no
|
|
181
|
+
* stretching, so a guessable secret can be found offline from one sealed
|
|
182
|
+
* value. List the new secret first while you rotate.
|
|
183
|
+
*/
|
|
184
|
+
secret: SealSecret;
|
|
185
|
+
/**
|
|
186
|
+
* Binds the sealed value to one use, such as `"tx"`, so it won't open for
|
|
187
|
+
* another. Leave it out to match the hosted sign-in Worker's format.
|
|
188
|
+
*/
|
|
189
|
+
purpose?: string;
|
|
190
|
+
};
|
|
191
|
+
/** Create a random URL-safe token from `bytes` random bytes, for state, nonces and ids. */
|
|
192
|
+
declare function randomToken(bytes?: number): string;
|
|
193
|
+
/** Create a PKCE verifier and its S256 challenge. */
|
|
194
|
+
declare function createPkce(): Promise<PkcePair>;
|
|
195
|
+
/** Encrypt a JSON value with AES-GCM into a cookie-safe string. */
|
|
196
|
+
declare function sealValue(value: unknown, { secret, purpose }: SealOptions): Promise<string>;
|
|
197
|
+
/** Decrypt a sealed value with any listed secret. Returns null when none opens it. */
|
|
198
|
+
declare function openSealedValue<T = unknown>(sealed: string | null | undefined, { secret, purpose }: SealOptions): Promise<T | null>;
|
|
199
|
+
|
|
200
|
+
/** Signing algorithms Sign in with Zappie tokens can use. */
|
|
201
|
+
type TokenAlgorithm = "RS256" | "ES256";
|
|
202
|
+
declare const messages: {
|
|
203
|
+
malformed: string;
|
|
204
|
+
unsupported_algorithm: string;
|
|
205
|
+
unknown_key: string;
|
|
206
|
+
invalid_signature: string;
|
|
207
|
+
wrong_issuer: string;
|
|
208
|
+
wrong_audience: string;
|
|
209
|
+
wrong_nonce: string;
|
|
210
|
+
wrong_subject: string;
|
|
211
|
+
wrong_type: string;
|
|
212
|
+
expired: string;
|
|
213
|
+
not_yet_valid: string;
|
|
214
|
+
keys_unavailable: string;
|
|
215
|
+
};
|
|
216
|
+
/** Why a token was refused. */
|
|
217
|
+
type TokenErrorReason = keyof typeof messages;
|
|
218
|
+
/** A token Zappie's identity issuer didn't sign for you, or one that is no longer valid. */
|
|
219
|
+
declare class ZappieTokenError extends ZappieError {
|
|
220
|
+
readonly reason: TokenErrorReason;
|
|
221
|
+
constructor(reason: TokenErrorReason, cause?: unknown);
|
|
222
|
+
}
|
|
223
|
+
/** The verified claims of a token. Extra claims stay on the object. */
|
|
224
|
+
type TokenClaims = {
|
|
225
|
+
iss: string;
|
|
226
|
+
aud: string | string[];
|
|
227
|
+
exp: number;
|
|
228
|
+
sub?: string;
|
|
229
|
+
iat?: number;
|
|
230
|
+
nbf?: number;
|
|
231
|
+
nonce?: string;
|
|
232
|
+
sid?: string;
|
|
233
|
+
[claim: string]: unknown;
|
|
234
|
+
};
|
|
235
|
+
/** Settings for verifying tokens from Zappie's identity issuer. */
|
|
236
|
+
type TokenVerifierOptions = {
|
|
237
|
+
/** The issuer URL, such as `https://accounts.zappie.ai`. The token's `iss` must equal it. A trailing slash on either is ignored. */
|
|
238
|
+
issuer: string;
|
|
239
|
+
/** The audience the token must name: your site's client id for ID tokens, or the API audience for access tokens. */
|
|
240
|
+
audience: string;
|
|
241
|
+
/** Where to read the signing keys. Defaults to `<issuer>/.well-known/jwks.json`. */
|
|
242
|
+
jwksUrl?: string;
|
|
243
|
+
/** Allowed algorithms. Defaults to RS256 and ES256. */
|
|
244
|
+
algorithms?: readonly TokenAlgorithm[];
|
|
245
|
+
/** Seconds of clock skew allowed on `exp`, `nbf` and `iat`. Defaults to 60. */
|
|
246
|
+
clockToleranceSec?: number;
|
|
247
|
+
/** Seconds to keep the key set before reading it again, 0 or more. Defaults to one hour. */
|
|
248
|
+
cacheTtlSec?: number;
|
|
249
|
+
/** Prefix the `sub` claim must start with. Zappie customers are `cus_`. A token without `sub` is refused. Pass `null` to skip both checks. */
|
|
250
|
+
subjectPrefix?: string | null;
|
|
251
|
+
/** The fetch to read keys with. Defaults to the global fetch. */
|
|
252
|
+
fetch?: typeof fetch;
|
|
253
|
+
/** The current time in milliseconds. For tests. */
|
|
254
|
+
now?: () => number;
|
|
255
|
+
};
|
|
256
|
+
/** Per-call checks on top of the verifier's settings. */
|
|
257
|
+
type VerifyTokenChecks = {
|
|
258
|
+
/** The nonce you sent with the sign-in. When you pass it, the token's `nonce` must equal it. */
|
|
259
|
+
nonce?: string;
|
|
260
|
+
/** Overrides the verifier's audience for this token. */
|
|
261
|
+
audience?: string;
|
|
262
|
+
};
|
|
263
|
+
/** Checks for an ID token from a sign-in. */
|
|
264
|
+
type IdTokenChecks = {
|
|
265
|
+
/** The nonce you sent with this sign-in. The token's `nonce` must equal it. */
|
|
266
|
+
nonce: string;
|
|
267
|
+
/** Overrides the verifier's audience for this token. */
|
|
268
|
+
audience?: string;
|
|
269
|
+
};
|
|
270
|
+
/** The verified claims of an ID token. */
|
|
271
|
+
type IdTokenClaims = TokenClaims & {
|
|
272
|
+
sub: string;
|
|
273
|
+
nonce: string;
|
|
274
|
+
};
|
|
275
|
+
/** Verifies signed tokens against the issuer's published keys. */
|
|
276
|
+
type TokenVerifier = {
|
|
277
|
+
/** Verify a token's signature and claims, and return its claims. */
|
|
278
|
+
verify(token: string, checks?: VerifyTokenChecks): Promise<TokenClaims>;
|
|
279
|
+
/**
|
|
280
|
+
* Verify the ID token from a sign-in. It needs the nonce you sent, a `sub`,
|
|
281
|
+
* and refuses a token whose `typ` header names another kind of token, such
|
|
282
|
+
* as an access token (`at+jwt`).
|
|
283
|
+
*/
|
|
284
|
+
verifyIdToken(token: string, checks: IdTokenChecks): Promise<IdTokenClaims>;
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* Create a verifier for tokens from Zappie's identity issuer. It caches the
|
|
288
|
+
* issuer's key set, reads it again when a token names an unknown key, and
|
|
289
|
+
* keeps the last good set while the issuer is down. It uses Web Crypto only,
|
|
290
|
+
* so it runs in Workers and Node 20 or later.
|
|
291
|
+
*/
|
|
292
|
+
declare function createTokenVerifier(options: TokenVerifierOptions): TokenVerifier;
|
|
293
|
+
|
|
294
|
+
/** What a signed-in session holds on the server. Tokens never reach the browser. */
|
|
295
|
+
type SessionData = {
|
|
296
|
+
/** The customer's subject, such as `cus_7Kd`. */
|
|
297
|
+
sub: string;
|
|
298
|
+
/** The OAuth client the session belongs to, your site's client id. */
|
|
299
|
+
clientId: string;
|
|
300
|
+
/** What the site may show about the customer. */
|
|
301
|
+
customer: {
|
|
302
|
+
id: string;
|
|
303
|
+
givenName?: string;
|
|
304
|
+
phoneMasked: string;
|
|
305
|
+
};
|
|
306
|
+
accessToken: string;
|
|
307
|
+
/** When the access token expires, in epoch milliseconds. */
|
|
308
|
+
accessExp: number;
|
|
309
|
+
refreshToken: string;
|
|
310
|
+
/** The issuer's session id from the ID token, for back-channel logout. */
|
|
311
|
+
idTokenSid?: string;
|
|
312
|
+
scopes: string[];
|
|
313
|
+
/** Epoch milliseconds. */
|
|
314
|
+
createdAt: number;
|
|
315
|
+
/** Epoch milliseconds. */
|
|
316
|
+
lastSeenAt: number;
|
|
317
|
+
mode: "test" | "live";
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* Where signed-in sessions live. The session id is what the browser's
|
|
321
|
+
* session cookie holds.
|
|
322
|
+
*/
|
|
323
|
+
interface SessionStore {
|
|
324
|
+
/** Save a new session and return its id. */
|
|
325
|
+
create(data: SessionData): Promise<string>;
|
|
326
|
+
/** Read a session, or null when it doesn't exist or has expired. */
|
|
327
|
+
get(sid: string): Promise<SessionData | null>;
|
|
328
|
+
/**
|
|
329
|
+
* Change a session and return the id to keep in the cookie, or null when
|
|
330
|
+
* the session no longer exists. Stores that keep sessions on the server
|
|
331
|
+
* return the same id. The cookie store returns a new one.
|
|
332
|
+
*/
|
|
333
|
+
update(sid: string, patch: Partial<SessionData>): Promise<string | null>;
|
|
334
|
+
/** End one session. */
|
|
335
|
+
delete(sid: string): Promise<void>;
|
|
336
|
+
/** End every session for a customer and client, for back-channel logout. */
|
|
337
|
+
deleteBySub(sub: string, clientId: string): Promise<void>;
|
|
338
|
+
}
|
|
339
|
+
/** Settings for the encrypted-cookie session store. */
|
|
340
|
+
type CookieSessionStoreOptions = {
|
|
341
|
+
/**
|
|
342
|
+
* At least 32 bytes each, from a random generator such as `randomToken()`,
|
|
343
|
+
* never a password or phrase. The key is the secret's SHA-256 with no
|
|
344
|
+
* stretching, so a guessable secret can be found offline from one sealed
|
|
345
|
+
* value. List the new secret first while you rotate.
|
|
346
|
+
*/
|
|
347
|
+
secret: SealSecret;
|
|
348
|
+
/** How long a session lasts from sign-in, in seconds. Defaults to 30 days. */
|
|
349
|
+
maxAgeSec?: number;
|
|
350
|
+
/** The current time in milliseconds. For tests. */
|
|
351
|
+
now?: () => number;
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* Create a session store that keeps the whole session, sealed with AES-GCM,
|
|
355
|
+
* in the cookie itself, so it needs no database. It can't end a session from
|
|
356
|
+
* the server, so back-channel logout needs a store that keeps sessions there.
|
|
357
|
+
* Signing out clears the cookie in that browser only. A copy of the cookie
|
|
358
|
+
* still opens the session until it expires, 30 days after sign-in by
|
|
359
|
+
* default, or until you rotate the secret out.
|
|
360
|
+
*/
|
|
361
|
+
declare function cookieSessionStore(options: CookieSessionStoreOptions): SessionStore;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A branch catalogue as the edge keeps it: the SDK `Menu` that `menu.get()`
|
|
365
|
+
* returns, with the facts a snapshot writer and reader need around it.
|
|
366
|
+
*/
|
|
367
|
+
type MenuSnapshot = {
|
|
368
|
+
v: 1;
|
|
369
|
+
storeId: string;
|
|
370
|
+
branchId: string;
|
|
371
|
+
/** The API's menu version, the same value `GET /menus/version` returns. */
|
|
372
|
+
version: string;
|
|
373
|
+
/** The first 16 hex digits of the SHA-256 of the menu's canonical JSON. */
|
|
374
|
+
contentHash: string;
|
|
375
|
+
/** When the snapshot was built, as an ISO time. */
|
|
376
|
+
builtAt: string;
|
|
377
|
+
/** The latest change among the source menus, as an ISO time, when the API sends it. */
|
|
378
|
+
sourceUpdatedAt?: string;
|
|
379
|
+
/** A complete branch catalogue. Partial category pages never become snapshots. */
|
|
380
|
+
menu: Menu;
|
|
381
|
+
};
|
|
382
|
+
/** Store details as the edge keeps them: the SDK `Store` that `store.get()` returns. */
|
|
383
|
+
type StoreSnapshot = {
|
|
384
|
+
v: 1;
|
|
385
|
+
storeId: string;
|
|
386
|
+
/** The first 16 hex digits of the SHA-256 of the store's canonical JSON. */
|
|
387
|
+
contentHash: string;
|
|
388
|
+
/** When the snapshot was built, as an ISO time. */
|
|
389
|
+
builtAt: string;
|
|
390
|
+
store: Store;
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* What sold out since a branch's menu snapshot was built. It is small, so an
|
|
394
|
+
* availability change rewrites about 1 KB instead of the whole menu.
|
|
395
|
+
*/
|
|
396
|
+
type AvailabilityPatch = {
|
|
397
|
+
v: 1;
|
|
398
|
+
/** The first 16 hex digits of the SHA-256 of this patch's canonical JSON. */
|
|
399
|
+
version: string;
|
|
400
|
+
/** The API's menu version after this change. A patched menu reports it. */
|
|
401
|
+
menuVersion: string;
|
|
402
|
+
soldOutItems: string[];
|
|
403
|
+
soldOutVariants: string[];
|
|
404
|
+
/** Choice ids in option groups. */
|
|
405
|
+
soldOutOptions: string[];
|
|
406
|
+
/** When the branch reopens, as an ISO time, if it is closed for now. */
|
|
407
|
+
closedUntil?: string;
|
|
408
|
+
/** When the patch was built, as an ISO time. */
|
|
409
|
+
updatedAt: string;
|
|
410
|
+
};
|
|
411
|
+
/** The version record a writer saves last, so readers never see unwritten data. */
|
|
412
|
+
type SnapshotVersionRecord = {
|
|
413
|
+
/** The default branch's menu version. */
|
|
414
|
+
version: string;
|
|
415
|
+
builtAt: string;
|
|
416
|
+
branches: Record<string, string>;
|
|
417
|
+
};
|
|
418
|
+
/** The edge key-value names for each snapshot. */
|
|
419
|
+
declare const snapshotKeys: {
|
|
420
|
+
/** `store:<storeId>` holds a StoreSnapshot. */
|
|
421
|
+
readonly store: (storeId: string) => string;
|
|
422
|
+
/** `menu:<storeId>:<branchId>`, or `menu:<storeId>` for the default branch. */
|
|
423
|
+
readonly menu: (storeId: string, branchId?: string) => string;
|
|
424
|
+
/** `avail:<storeId>:<branchId>` holds an AvailabilityPatch. */
|
|
425
|
+
readonly availability: (storeId: string, branchId: string) => string;
|
|
426
|
+
/** `ver:<storeId>` holds a SnapshotVersionRecord. */
|
|
427
|
+
readonly version: (storeId: string) => string;
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* Serialise a JSON value with object keys in sorted order and undefined
|
|
431
|
+
* properties left out, so equal values always give the same text.
|
|
432
|
+
*/
|
|
433
|
+
declare function canonicalJson(value: unknown): string;
|
|
434
|
+
/** Hash a JSON value: the first 16 hex digits of the SHA-256 of its canonical JSON. */
|
|
435
|
+
declare function snapshotHash(value: unknown): Promise<string>;
|
|
436
|
+
/** Input for building a MenuSnapshot from an SDK menu. */
|
|
437
|
+
type MenuSnapshotInput = {
|
|
438
|
+
storeId: string;
|
|
439
|
+
menu: Menu;
|
|
440
|
+
/** Defaults to now. */
|
|
441
|
+
builtAt?: Date | string;
|
|
442
|
+
sourceUpdatedAt?: Date | string;
|
|
443
|
+
};
|
|
444
|
+
/** Build a MenuSnapshot from a complete SDK menu. */
|
|
445
|
+
declare function createMenuSnapshot({ storeId, menu, builtAt, sourceUpdatedAt, }: MenuSnapshotInput): Promise<MenuSnapshot>;
|
|
446
|
+
/** Input for building a MenuSnapshot from the API's menu payload. */
|
|
447
|
+
type MenuSnapshotPayloadInput = {
|
|
448
|
+
storeId: string;
|
|
449
|
+
branchId: string;
|
|
450
|
+
/** The JSON body of the API's branch menu read. */
|
|
451
|
+
payload: unknown;
|
|
452
|
+
/** Defaults to now. */
|
|
453
|
+
builtAt?: Date | string;
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* Build a MenuSnapshot from the API's branch menu payload. It maps the payload
|
|
457
|
+
* with the same mapper `menu.get()` uses, so the snapshot's menu is exactly
|
|
458
|
+
* what the client SDK returns.
|
|
459
|
+
*/
|
|
460
|
+
declare function menuSnapshotFromPayload({ storeId, branchId, payload, builtAt, }: MenuSnapshotPayloadInput): Promise<MenuSnapshot>;
|
|
461
|
+
/** Build a StoreSnapshot from an SDK store. */
|
|
462
|
+
declare function createStoreSnapshot({ store, builtAt, }: {
|
|
463
|
+
store: Store;
|
|
464
|
+
builtAt?: Date | string;
|
|
465
|
+
}): Promise<StoreSnapshot>;
|
|
466
|
+
/** Input for building an AvailabilityPatch. */
|
|
467
|
+
type AvailabilityPatchInput = {
|
|
468
|
+
menuVersion: string;
|
|
469
|
+
soldOutItems?: readonly string[];
|
|
470
|
+
soldOutVariants?: readonly string[];
|
|
471
|
+
soldOutOptions?: readonly string[];
|
|
472
|
+
closedUntil?: Date | string;
|
|
473
|
+
/** Defaults to now. */
|
|
474
|
+
updatedAt?: Date | string;
|
|
475
|
+
};
|
|
476
|
+
/** Build an AvailabilityPatch with sorted, unique ids and its content hash. */
|
|
477
|
+
declare function createAvailabilityPatch(input: AvailabilityPatchInput): Promise<AvailabilityPatch>;
|
|
478
|
+
/**
|
|
479
|
+
* Return the snapshot's menu with an availability patch applied. A patch
|
|
480
|
+
* built before the snapshot is ignored, because the rebuilt menu already
|
|
481
|
+
* holds newer availability. The result reports the patch's menu version.
|
|
482
|
+
*/
|
|
483
|
+
declare function applyAvailability(snapshot: MenuSnapshot, patch: AvailabilityPatch | null | undefined): Menu;
|
|
484
|
+
/** Check that a value read from the edge is a MenuSnapshot this SDK can use. */
|
|
485
|
+
declare function isMenuSnapshot(value: unknown): value is MenuSnapshot;
|
|
486
|
+
|
|
165
487
|
/**
|
|
166
488
|
* The store, theme and optional build policies you export from zappie.config.ts.
|
|
167
489
|
*/
|
|
@@ -338,4 +660,4 @@ declare function createZappieServer({ secretKey, ...config }: ZappieServerConfig
|
|
|
338
660
|
};
|
|
339
661
|
}>;
|
|
340
662
|
|
|
341
|
-
export { type EdgeSnapshotReader, STOREFRONT_QUERY_PARAMETERS, STOREFRONT_V1_QUERY_PARAMETERS, type SecurityHeadersOptions, type StorefrontInstallation, type StorefrontPageState, type StorefrontQuery, type StorefrontRoute, type StorefrontUrlInput, type StorefrontUrlOptions, type VerifyWebhookOptions, type WebhookEvent, type WebhookEventType, type ZappieServerConfig, ZappieWebhookSignatureError, buildStorefrontUrl, createSecurityNonce, createZappieServer, inlineScriptHashes, parseStorefrontUrl, securityHeaders, storefrontRedirect, verifyWebhook };
|
|
663
|
+
export { type AvailabilityPatch, type AvailabilityPatchInput, type CookieSessionStoreOptions, type EdgeSnapshotReader, type IdTokenChecks, type IdTokenClaims, type MenuSnapshot, type MenuSnapshotInput, type MenuSnapshotPayloadInput, type PkcePair, STOREFRONT_QUERY_PARAMETERS, STOREFRONT_V1_QUERY_PARAMETERS, type SealOptions, type SealSecret, type SecurityHeadersOptions, type SessionData, type SessionStore, type SnapshotVersionRecord, type StoreSnapshot, type StorefrontInstallation, type StorefrontPageState, type StorefrontQuery, type StorefrontRoute, type StorefrontUrlInput, type StorefrontUrlOptions, type TokenAlgorithm, type TokenClaims, type TokenErrorReason, type TokenVerifier, type TokenVerifierOptions, type VerifyTokenChecks, type VerifyWebhookOptions, type WebhookEvent, type WebhookEventType, type ZappieServerConfig, ZappieTokenError, ZappieWebhookSignatureError, applyAvailability, buildStorefrontUrl, canonicalJson, cookieSessionStore, createAvailabilityPatch, createMenuSnapshot, createPkce, createSecurityNonce, createStoreSnapshot, createTokenVerifier, createZappieServer, inlineScriptHashes, isMenuSnapshot, menuSnapshotFromPayload, openSealedValue, parseStorefrontUrl, randomToken, sealValue, securityHeaders, snapshotHash, snapshotKeys, storefrontRedirect, verifyWebhook };
|
package/dist/server/index.js
CHANGED
|
@@ -8,6 +8,30 @@ import {
|
|
|
8
8
|
STOREFRONT_V1_QUERY_PARAMETERS,
|
|
9
9
|
storefrontRedirect
|
|
10
10
|
} from "../url/index.js";
|
|
11
|
+
import {
|
|
12
|
+
createPkce,
|
|
13
|
+
openSealedValue,
|
|
14
|
+
randomToken,
|
|
15
|
+
sealValue
|
|
16
|
+
} from "./auth/crypto.js";
|
|
17
|
+
import {
|
|
18
|
+
createTokenVerifier,
|
|
19
|
+
ZappieTokenError
|
|
20
|
+
} from "./auth/jwt.js";
|
|
21
|
+
import {
|
|
22
|
+
cookieSessionStore
|
|
23
|
+
} from "./auth/session-store.js";
|
|
24
|
+
import {
|
|
25
|
+
applyAvailability,
|
|
26
|
+
canonicalJson,
|
|
27
|
+
createAvailabilityPatch,
|
|
28
|
+
createMenuSnapshot,
|
|
29
|
+
createStoreSnapshot,
|
|
30
|
+
isMenuSnapshot,
|
|
31
|
+
menuSnapshotFromPayload,
|
|
32
|
+
snapshotHash,
|
|
33
|
+
snapshotKeys
|
|
34
|
+
} from "./edge-snapshot.js";
|
|
11
35
|
import {
|
|
12
36
|
createSecurityNonce,
|
|
13
37
|
inlineScriptHashes,
|
|
@@ -27,13 +51,29 @@ export {
|
|
|
27
51
|
STOREFRONT_QUERY_PARAMETERS,
|
|
28
52
|
STOREFRONT_V1_QUERY_PARAMETERS,
|
|
29
53
|
VERSION,
|
|
54
|
+
ZappieTokenError,
|
|
30
55
|
ZappieWebhookSignatureError,
|
|
56
|
+
applyAvailability,
|
|
31
57
|
buildStorefrontUrl,
|
|
58
|
+
canonicalJson,
|
|
59
|
+
cookieSessionStore,
|
|
60
|
+
createAvailabilityPatch,
|
|
61
|
+
createMenuSnapshot,
|
|
62
|
+
createPkce,
|
|
32
63
|
createSecurityNonce,
|
|
64
|
+
createStoreSnapshot,
|
|
65
|
+
createTokenVerifier,
|
|
33
66
|
createZappieServer,
|
|
34
67
|
inlineScriptHashes,
|
|
68
|
+
isMenuSnapshot,
|
|
69
|
+
menuSnapshotFromPayload,
|
|
70
|
+
openSealedValue,
|
|
35
71
|
parseStorefrontUrl,
|
|
72
|
+
randomToken,
|
|
73
|
+
sealValue,
|
|
36
74
|
securityHeaders,
|
|
75
|
+
snapshotHash,
|
|
76
|
+
snapshotKeys,
|
|
37
77
|
storefrontRedirect,
|
|
38
78
|
verifyWebhook
|
|
39
79
|
};
|
package/dist/server/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/server/index.ts"],
|
|
4
|
-
"sourcesContent": ["import { buildClient } from \"../client\";\nimport type { ZappieConfig } from \"../config\";\nimport type { Menu } from \"../models\";\nexport { VERSION } from \"../index\";\nexport {\n buildStorefrontUrl,\n parseStorefrontUrl,\n STOREFRONT_QUERY_PARAMETERS,\n STOREFRONT_V1_QUERY_PARAMETERS,\n storefrontRedirect,\n type StorefrontInstallation,\n type StorefrontPageState,\n type StorefrontQuery,\n type StorefrontRoute,\n type StorefrontUrlInput,\n type StorefrontUrlOptions,\n} from \"../url/index\";\nexport {\n createSecurityNonce,\n inlineScriptHashes,\n securityHeaders,\n type SecurityHeadersOptions,\n} from \"./security\";\nexport {\n verifyWebhook,\n ZappieWebhookSignatureError,\n type VerifyWebhookOptions,\n type WebhookEvent,\n type WebhookEventType,\n} from \"./webhook\";\n/** An edge reader that returns a Menu snapshot or null. */\nexport interface EdgeSnapshotReader {\n getMenu(storeId: string, branchId: string): Promise<Menu | null>;\n}\n/** Settings for your server client. Keep secret keys out of browser bundles. */\nexport type ZappieServerConfig = Omit<ZappieConfig, \"key\"> & {\n secretKey: string;\n};\n/** Create a server client with your secret key and configured transport. */\nexport function createZappieServer({\n secretKey,\n ...config\n}: ZappieServerConfig) {\n return buildClient({ ...config, key: secretKey }, true);\n}\n"],
|
|
5
|
-
"mappings": ";AAAA,SAAS,mBAAmB;AAG5B,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAOK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OAIK;AAUA,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,SAAO,YAAY,EAAE,GAAG,QAAQ,KAAK,UAAU,GAAG,IAAI;AACxD;",
|
|
4
|
+
"sourcesContent": ["import { buildClient } from \"../client\";\nimport type { ZappieConfig } from \"../config\";\nimport type { Menu } from \"../models\";\nexport { VERSION } from \"../index\";\nexport {\n buildStorefrontUrl,\n parseStorefrontUrl,\n STOREFRONT_QUERY_PARAMETERS,\n STOREFRONT_V1_QUERY_PARAMETERS,\n storefrontRedirect,\n type StorefrontInstallation,\n type StorefrontPageState,\n type StorefrontQuery,\n type StorefrontRoute,\n type StorefrontUrlInput,\n type StorefrontUrlOptions,\n} from \"../url/index\";\nexport {\n createPkce,\n openSealedValue,\n randomToken,\n sealValue,\n type PkcePair,\n type SealOptions,\n type SealSecret,\n} from \"./auth/crypto\";\nexport {\n createTokenVerifier,\n ZappieTokenError,\n type IdTokenChecks,\n type IdTokenClaims,\n type TokenAlgorithm,\n type TokenClaims,\n type TokenErrorReason,\n type TokenVerifier,\n type TokenVerifierOptions,\n type VerifyTokenChecks,\n} from \"./auth/jwt\";\nexport {\n cookieSessionStore,\n type CookieSessionStoreOptions,\n type SessionData,\n type SessionStore,\n} from \"./auth/session-store\";\nexport {\n applyAvailability,\n canonicalJson,\n createAvailabilityPatch,\n createMenuSnapshot,\n createStoreSnapshot,\n isMenuSnapshot,\n menuSnapshotFromPayload,\n snapshotHash,\n snapshotKeys,\n type AvailabilityPatch,\n type AvailabilityPatchInput,\n type MenuSnapshot,\n type MenuSnapshotInput,\n type MenuSnapshotPayloadInput,\n type SnapshotVersionRecord,\n type StoreSnapshot,\n} from \"./edge-snapshot\";\nexport {\n createSecurityNonce,\n inlineScriptHashes,\n securityHeaders,\n type SecurityHeadersOptions,\n} from \"./security\";\nexport {\n verifyWebhook,\n ZappieWebhookSignatureError,\n type VerifyWebhookOptions,\n type WebhookEvent,\n type WebhookEventType,\n} from \"./webhook\";\n/** An edge reader that returns a Menu snapshot or null. */\nexport interface EdgeSnapshotReader {\n getMenu(storeId: string, branchId: string): Promise<Menu | null>;\n}\n/** Settings for your server client. Keep secret keys out of browser bundles. */\nexport type ZappieServerConfig = Omit<ZappieConfig, \"key\"> & {\n secretKey: string;\n};\n/** Create a server client with your secret key and configured transport. */\nexport function createZappieServer({\n secretKey,\n ...config\n}: ZappieServerConfig) {\n return buildClient({ ...config, key: secretKey }, true);\n}\n"],
|
|
5
|
+
"mappings": ";AAAA,SAAS,mBAAmB;AAG5B,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAOK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OASK;AACP;AAAA,EACE;AAAA,OAIK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAQK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OAIK;AAUA,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,SAAO,YAAY,EAAE,GAAG,QAAQ,KAAK,UAAU,GAAG,IAAI;AACxD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|