@zappie/sdk 0.1.11 → 0.1.13

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +22 -6
  3. package/dist/cart/branch.d.ts +1 -1
  4. package/dist/cart/branch.js +6 -2
  5. package/dist/cart/branch.js.map +2 -2
  6. package/dist/cart/item.d.ts +1 -1
  7. package/dist/cart/quote.d.ts +1 -1
  8. package/dist/checkout-defaults/index.d.ts +17 -11
  9. package/dist/checkout-defaults/index.js +17 -2
  10. package/dist/checkout-defaults/index.js.map +2 -2
  11. package/dist/config/index.d.ts +10 -0
  12. package/dist/customer/navigation.js +2 -7
  13. package/dist/customer/navigation.js.map +2 -2
  14. package/dist/debug.d.ts +1 -1
  15. package/dist/debug.js +1 -1
  16. package/dist/index.d.ts +43 -43
  17. package/dist/index.js +6 -6
  18. package/dist/index.js.map +2 -2
  19. package/dist/integrations/index.d.ts +1 -1
  20. package/dist/internal/return-path.js +17 -0
  21. package/dist/internal/return-path.js.map +7 -0
  22. package/dist/internal.d.ts +1 -1
  23. package/dist/persist/catalogue.d.ts +1 -1
  24. package/dist/react/index.d.ts +1 -1
  25. package/dist/redirect/index.d.ts +15 -0
  26. package/dist/redirect/index.js +22 -0
  27. package/dist/redirect/index.js.map +7 -0
  28. package/dist/server/index.d.ts +87 -2
  29. package/dist/server/index.js +8 -0
  30. package/dist/server/index.js.map +2 -2
  31. package/dist/server/security.js +136 -0
  32. package/dist/server/security.js.map +7 -0
  33. package/dist/server/storefront-url/parse-v2.js +13 -5
  34. package/dist/server/storefront-url/parse-v2.js.map +2 -2
  35. package/dist/server/storefront-url/url.js +3 -1
  36. package/dist/server/storefront-url/url.js.map +2 -2
  37. package/dist/site-config/helpers.js +1 -1
  38. package/dist/site-config/helpers.js.map +2 -2
  39. package/dist/site-config/types.js.map +1 -1
  40. package/dist/site-config/validate.js +25 -9
  41. package/dist/site-config/validate.js.map +2 -2
  42. package/dist/testing/index.d.ts +1 -1
  43. package/dist/transport/search.d.ts +1 -1
  44. package/dist/transport/selected-menu.d.ts +1 -1
  45. package/package.json +9 -5
  46. package/dist/{client-DzUauU-b.d.ts → client-CSOZOwQW.d.ts} +24 -24
@@ -1,5 +1,5 @@
1
1
  import { c as CartFulfilment, W as WireFulfilment } from './types-gQXU2gEA.js';
2
- export { v as validateConfig } from './client-DzUauU-b.js';
2
+ export { v as validateConfig } from './client-CSOZOwQW.js';
3
3
  import './models-D__QbqEd.js';
4
4
 
5
5
  /**
@@ -1,3 +1,3 @@
1
- export { q as CATALOGUE_MAX_AGE_MS, r as CATALOGUE_MAX_BYTES, s as CatalogueScope, t as SavedCatalogue, u as catalogueDigest, w as catalogueKey, x as createCatalogueStorage, y as createSavedCatalogue } from '../client-DzUauU-b.js';
1
+ export { q as CATALOGUE_MAX_AGE_MS, r as CATALOGUE_MAX_BYTES, s as CatalogueScope, t as SavedCatalogue, u as catalogueDigest, w as catalogueKey, x as createCatalogueStorage, y as createSavedCatalogue } from '../client-CSOZOwQW.js';
2
2
  import '../models-D__QbqEd.js';
3
3
  import '../types-gQXU2gEA.js';
@@ -1,6 +1,6 @@
1
1
  export { VERSION } from '../index.js';
2
+ import { a as ZappieError, Z as Zappie } from '../client-CSOZOwQW.js';
2
3
  import { I as Item, a as Menu, S as Store, M as Money } from '../models-D__QbqEd.js';
3
- import { a as ZappieError, Z as Zappie } from '../client-DzUauU-b.js';
4
4
  import * as react from 'react';
5
5
  import { ReactNode } from 'react';
6
6
  import { d as CartModule, C as CartLine, c as CartFulfilment, e as CartState, f as CartIssue } from '../types-gQXU2gEA.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Return a same-origin redirect target, or a trusted fallback.
3
+ *
4
+ * The target must resolve to an HTTP(S) URL on the current origin and its
5
+ * returned path must begin with exactly one slash. On the server, only relative
6
+ * targets can match the sentinel origin because there is no request origin to
7
+ * compare.
8
+ *
9
+ * @param to - The untrusted redirect target, such as a query parameter.
10
+ * @param fallback - The trusted target to use when `to` is unsafe.
11
+ * @returns A same-origin relative target or `fallback`.
12
+ */
13
+ declare function safeRedirect(to: string | null | undefined, fallback: string): string;
14
+
15
+ export { safeRedirect };
@@ -0,0 +1,22 @@
1
+ // src/redirect/index.ts
2
+ function safeRedirect(to, fallback) {
3
+ if (typeof to !== "string" || /[\u0000-\u0020\u007f\s\\]/u.test(to) || /%(?:0[\da-f]|1[\da-f]|20|7f)/iu.test(to)) {
4
+ return fallback;
5
+ }
6
+ try {
7
+ const browserOrigin = typeof location === "undefined" ? void 0 : location.origin;
8
+ if (browserOrigin === "null") {
9
+ return fallback;
10
+ }
11
+ const origin = browserOrigin ?? "https://safe-redirect.invalid";
12
+ const target = new URL(to, origin);
13
+ const path = `${target.pathname}${target.search}${target.hash}`;
14
+ return (target.protocol === "http:" || target.protocol === "https:") && target.origin === origin && /^\/(?!\/)/.test(path) ? path : fallback;
15
+ } catch {
16
+ return fallback;
17
+ }
18
+ }
19
+ export {
20
+ safeRedirect
21
+ };
22
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/redirect/index.ts"],
4
+ "sourcesContent": ["/**\n * Return a same-origin redirect target, or a trusted fallback.\n *\n * The target must resolve to an HTTP(S) URL on the current origin and its\n * returned path must begin with exactly one slash. On the server, only relative\n * targets can match the sentinel origin because there is no request origin to\n * compare.\n *\n * @param to - The untrusted redirect target, such as a query parameter.\n * @param fallback - The trusted target to use when `to` is unsafe.\n * @returns A same-origin relative target or `fallback`.\n */\nexport function safeRedirect(\n to: string | null | undefined,\n fallback: string,\n): string {\n if (\n typeof to !== \"string\" ||\n /[\\u0000-\\u0020\\u007f\\s\\\\]/u.test(to) ||\n /%(?:0[\\da-f]|1[\\da-f]|20|7f)/iu.test(to)\n ) {\n return fallback;\n }\n try {\n const browserOrigin =\n typeof location === \"undefined\" ? undefined : location.origin;\n if (browserOrigin === \"null\") {\n return fallback;\n }\n const origin = browserOrigin ?? \"https://safe-redirect.invalid\";\n const target = new URL(to, origin);\n const path = `${target.pathname}${target.search}${target.hash}`;\n return (target.protocol === \"http:\" || target.protocol === \"https:\") &&\n target.origin === origin &&\n /^\\/(?!\\/)/.test(path)\n ? path\n : fallback;\n } catch {\n return fallback;\n }\n}\n"],
5
+ "mappings": ";AAYO,SAAS,aACd,IACA,UACQ;AACR,MACE,OAAO,OAAO,YACd,6BAA6B,KAAK,EAAE,KACpC,iCAAiC,KAAK,EAAE,GACxC;AACA,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,gBACJ,OAAO,aAAa,cAAc,SAAY,SAAS;AACzD,QAAI,kBAAkB,QAAQ;AAC5B,aAAO;AAAA,IACT;AACA,UAAM,SAAS,iBAAiB;AAChC,UAAM,SAAS,IAAI,IAAI,IAAI,MAAM;AACjC,UAAM,OAAO,GAAG,OAAO,QAAQ,GAAG,OAAO,MAAM,GAAG,OAAO,IAAI;AAC7D,YAAQ,OAAO,aAAa,WAAW,OAAO,aAAa,aACzD,OAAO,WAAW,UAClB,YAAY,KAAK,IAAI,IACnB,OACA;AAAA,EACN,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
6
+ "names": []
7
+ }
@@ -1,5 +1,5 @@
1
1
  import { a as Menu, F as Formatters, S as Store } from '../models-D__QbqEd.js';
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-DzUauU-b.js';
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';
5
5
 
@@ -162,6 +162,91 @@ declare const parseStorefrontUrl: (input: StorefrontUrlInput, options?: Storefro
162
162
  */
163
163
  declare const storefrontRedirect: (input: StorefrontUrlInput, options?: StorefrontUrlOptions) => string | null;
164
164
 
165
+ /**
166
+ * The store, theme and optional build policies you export from zappie.config.ts.
167
+ */
168
+ type ZappieSiteConfig = {
169
+ store: string;
170
+ branches?: string[];
171
+ theme: string;
172
+ prerender?: string[];
173
+ menu?: {
174
+ hiddenCategories?: string[];
175
+ };
176
+ packages?: {
177
+ allow?: string[];
178
+ };
179
+ /** Time-bounded acknowledgements for advisories that have no fixed version. */
180
+ audit?: {
181
+ ignore?: {
182
+ id: string;
183
+ reason: string;
184
+ until: string;
185
+ }[];
186
+ };
187
+ outbound?: {
188
+ allow?: string[];
189
+ };
190
+ /** Add sources to the fixed storefront Content Security Policy. */
191
+ security?: {
192
+ csp?: {
193
+ imgSrc?: string[];
194
+ connectSrc?: string[];
195
+ fontSrc?: string[];
196
+ styleSrc?: string[];
197
+ frameSrc?: string[];
198
+ };
199
+ };
200
+ /** Each budget left out uses DEFAULT_BUDGETS. */
201
+ budgets?: {
202
+ jsKbGz?: number;
203
+ cssKbGz?: number;
204
+ lcpMs?: number;
205
+ override?: {
206
+ reason: string;
207
+ };
208
+ };
209
+ /** Records explained exceptions to storefront readiness checks. */
210
+ readiness?: {
211
+ /** Lets this site skip the initial route discovery requirement. */
212
+ routeDiscovery?: {
213
+ /** Explains why this site does not use initial route discovery. */
214
+ optOut: {
215
+ /** States the constraint that prevents initial route discovery. */
216
+ reason: string;
217
+ };
218
+ };
219
+ };
220
+ analytics?: {
221
+ forward?: {
222
+ ga4?: string;
223
+ metaPixel?: string;
224
+ };
225
+ };
226
+ };
227
+
228
+ /** Inputs for a storefront document's report-only security headers. */
229
+ type SecurityHeadersOptions = {
230
+ /** A fresh value from createSecurityNonce for a server-rendered document. */
231
+ nonce?: string;
232
+ /** SHA-256 CSP source expressions for inline scripts in a prerendered document. */
233
+ scriptHashes?: string[];
234
+ /** The site configuration. Invalid CSP additions are ignored. */
235
+ config?: ZappieSiteConfig;
236
+ /** The configured ZAPPIE_API URL whose origin the browser may call. */
237
+ apiUrl?: string;
238
+ /** Existing response headers to extend. */
239
+ headers?: HeadersInit;
240
+ /** Hosted responses leave framing to Zappie's dispatch Worker. */
241
+ hosted?: boolean;
242
+ };
243
+ /** Create an unpredictable CSP nonce with Web Crypto in Node or a Worker. */
244
+ declare function createSecurityNonce(): string;
245
+ /** Compute CSP SHA-256 source expressions for inline scripts in prerendered HTML. */
246
+ declare function inlineScriptHashes(html: string): Promise<string[]>;
247
+ /** Build the report-only security headers for a storefront document response. */
248
+ declare function securityHeaders(options?: SecurityHeadersOptions): Headers;
249
+
165
250
  /** Event names sent by the Zappie webhook API. */
166
251
  type WebhookEventType = "menu.updated" | "branch.hours_changed" | "store.updated" | "checkout.session.completed" | "checkout.session.expired" | "order.created" | "order.paid" | "order.status_changed" | "customer.signed_in" | "payout.initiated" | "payout.paid" | "payout.failed" | "payout_request.created" | "payout_request.approved" | "payout_request.rejected" | "payment.received";
167
252
  /** The event envelope sent in a Zappie webhook request. */
@@ -237,4 +322,4 @@ declare function createZappieServer({ secretKey, ...config }: ZappieServerConfig
237
322
  };
238
323
  }>;
239
324
 
240
- export { type EdgeSnapshotReader, STOREFRONT_QUERY_PARAMETERS, STOREFRONT_V1_QUERY_PARAMETERS, type StorefrontInstallation, type StorefrontPageState, type StorefrontQuery, type StorefrontRoute, type StorefrontUrlInput, type StorefrontUrlOptions, type VerifyWebhookOptions, type WebhookEvent, type WebhookEventType, type ZappieServerConfig, ZappieWebhookSignatureError, buildStorefrontUrl, createZappieServer, parseStorefrontUrl, storefrontRedirect, verifyWebhook };
325
+ 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 };
@@ -8,6 +8,11 @@ import {
8
8
  STOREFRONT_V1_QUERY_PARAMETERS,
9
9
  storefrontRedirect
10
10
  } from "../url/index.js";
11
+ import {
12
+ createSecurityNonce,
13
+ inlineScriptHashes,
14
+ securityHeaders
15
+ } from "./security.js";
11
16
  import {
12
17
  verifyWebhook,
13
18
  ZappieWebhookSignatureError
@@ -24,8 +29,11 @@ export {
24
29
  VERSION,
25
30
  ZappieWebhookSignatureError,
26
31
  buildStorefrontUrl,
32
+ createSecurityNonce,
27
33
  createZappieServer,
34
+ inlineScriptHashes,
28
35
  parseStorefrontUrl,
36
+ securityHeaders,
29
37
  storefrontRedirect,
30
38
  verifyWebhook
31
39
  };
@@ -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 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,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 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;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,136 @@
1
+ // src/server/security.ts
2
+ import { hostname, record } from "../site-config/helpers.js";
3
+ var DEFAULT_SOURCES = {
4
+ "img-src": ["'self'", "data:", "https:"],
5
+ "connect-src": ["'self'"],
6
+ "font-src": ["'self'", "https://fonts.gstatic.com"],
7
+ "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
8
+ "frame-src": [
9
+ "https://checkout.zappie.ai",
10
+ "https://elements.zappie.ai",
11
+ "https://accounts.zappie.ai"
12
+ ]
13
+ };
14
+ var sourceNames = {
15
+ imgSrc: "img-src",
16
+ connectSrc: "connect-src",
17
+ fontSrc: "font-src",
18
+ styleSrc: "style-src",
19
+ frameSrc: "frame-src"
20
+ };
21
+ var base64 = (bytes) => btoa(String.fromCharCode(...bytes));
22
+ function createSecurityNonce() {
23
+ return base64(crypto.getRandomValues(new Uint8Array(18))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
24
+ }
25
+ async function inlineScriptHashes(html) {
26
+ const hashes = await Promise.all(
27
+ [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)].filter(([, attributes = ""]) => !/(?:^|\s)src\s*=/i.test(attributes)).map(async ([, , source = ""]) => {
28
+ const digest = await crypto.subtle.digest(
29
+ "SHA-256",
30
+ new TextEncoder().encode(source)
31
+ );
32
+ return `'sha256-${base64(new Uint8Array(digest))}'`;
33
+ })
34
+ );
35
+ return [...new Set(hashes)];
36
+ }
37
+ function apiOrigin(value) {
38
+ if (value === void 0) {
39
+ return "https://storefront-api.zappie.ai";
40
+ }
41
+ try {
42
+ const url = new URL(value);
43
+ if (url.protocol === "https:" || url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
44
+ return url.origin;
45
+ }
46
+ } catch {
47
+ return void 0;
48
+ }
49
+ return void 0;
50
+ }
51
+ function configuredSources(config) {
52
+ const configured = /* @__PURE__ */ new Map();
53
+ const csp = config?.security?.csp;
54
+ if (!record(csp)) {
55
+ return configured;
56
+ }
57
+ for (const [name, values] of Object.entries(csp)) {
58
+ const directive = sourceNames[name];
59
+ if (!directive || !Array.isArray(values)) {
60
+ continue;
61
+ }
62
+ const valid = values.filter(
63
+ (value) => typeof value === "string" && !/[;,\s]/.test(value) && hostname(value)
64
+ );
65
+ configured.set(
66
+ directive,
67
+ valid.map((host) => `https://${host}`)
68
+ );
69
+ }
70
+ return configured;
71
+ }
72
+ function securityHeaders(options = {}) {
73
+ if (!options.nonce && !options.scriptHashes?.length) {
74
+ throw new TypeError(
75
+ "securityHeaders needs a nonce or an inline script hash."
76
+ );
77
+ }
78
+ const sources = Object.fromEntries(
79
+ Object.entries(DEFAULT_SOURCES).map(([name, values]) => [
80
+ name,
81
+ [...values]
82
+ ])
83
+ );
84
+ const storefrontApiOrigin = apiOrigin(options.apiUrl);
85
+ if (storefrontApiOrigin) {
86
+ sources["connect-src"].push(storefrontApiOrigin);
87
+ }
88
+ for (const [name, values] of configuredSources(options.config)) {
89
+ sources[name].push(...values);
90
+ }
91
+ const outbound = options.config?.outbound?.allow;
92
+ if (Array.isArray(outbound)) {
93
+ sources["connect-src"].push(
94
+ ...outbound.filter(
95
+ (host) => typeof host === "string" && !/[;,\s]/.test(host) && hostname(host)
96
+ ).map((host) => `https://${host}`)
97
+ );
98
+ }
99
+ for (const values of Object.values(sources)) {
100
+ values.splice(0, values.length, ...new Set(values));
101
+ }
102
+ const scriptSources = [
103
+ "'self'",
104
+ ...options.nonce ? [`'nonce-${options.nonce}'`] : [],
105
+ ...options.scriptHashes ?? [],
106
+ "'strict-dynamic'"
107
+ ];
108
+ const directives = [
109
+ "default-src 'self'",
110
+ `script-src ${scriptSources.join(" ")}`,
111
+ ...Object.entries(sources).map(
112
+ ([name, values]) => `${name} ${values.join(" ")}`
113
+ ),
114
+ "form-action 'self' https://accounts.zappie.ai https://checkout.zappie.ai",
115
+ "base-uri 'self'",
116
+ "object-src 'none'",
117
+ ...!options.hosted ? ["frame-ancestors 'self'"] : [],
118
+ "report-uri /_zappie/csp"
119
+ ];
120
+ const headers = new Headers(options.headers);
121
+ headers.set("content-security-policy-report-only", directives.join("; "));
122
+ headers.set("strict-transport-security", "max-age=31536000");
123
+ headers.set("x-content-type-options", "nosniff");
124
+ headers.set("referrer-policy", "strict-origin-when-cross-origin");
125
+ headers.set(
126
+ "permissions-policy",
127
+ 'camera=(), microphone=(), usb=(), geolocation=(self), payment=(self "https://checkout.zappie.ai")'
128
+ );
129
+ return headers;
130
+ }
131
+ export {
132
+ createSecurityNonce,
133
+ inlineScriptHashes,
134
+ securityHeaders
135
+ };
136
+ //# sourceMappingURL=security.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/server/security.ts"],
4
+ "sourcesContent": ["import { hostname, record } from \"../site-config/helpers\";\nimport type { ZappieSiteConfig } from \"../site-config/types\";\n\nconst DEFAULT_SOURCES = {\n \"img-src\": [\"'self'\", \"data:\", \"https:\"],\n \"connect-src\": [\"'self'\"],\n \"font-src\": [\"'self'\", \"https://fonts.gstatic.com\"],\n \"style-src\": [\"'self'\", \"'unsafe-inline'\", \"https://fonts.googleapis.com\"],\n \"frame-src\": [\n \"https://checkout.zappie.ai\",\n \"https://elements.zappie.ai\",\n \"https://accounts.zappie.ai\",\n ],\n} as const;\n\n/** Inputs for a storefront document's report-only security headers. */\nexport type SecurityHeadersOptions = {\n /** A fresh value from createSecurityNonce for a server-rendered document. */\n nonce?: string;\n /** SHA-256 CSP source expressions for inline scripts in a prerendered document. */\n scriptHashes?: string[];\n /** The site configuration. Invalid CSP additions are ignored. */\n config?: ZappieSiteConfig;\n /** The configured ZAPPIE_API URL whose origin the browser may call. */\n apiUrl?: string;\n /** Existing response headers to extend. */\n headers?: HeadersInit;\n /** Hosted responses leave framing to Zappie's dispatch Worker. */\n hosted?: boolean;\n};\n\nconst sourceNames = {\n imgSrc: \"img-src\",\n connectSrc: \"connect-src\",\n fontSrc: \"font-src\",\n styleSrc: \"style-src\",\n frameSrc: \"frame-src\",\n} as const;\n\nconst base64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));\n\n/** Create an unpredictable CSP nonce with Web Crypto in Node or a Worker. */\nexport function createSecurityNonce(): string {\n return base64(crypto.getRandomValues(new Uint8Array(18)))\n .replaceAll(\"+\", \"-\")\n .replaceAll(\"/\", \"_\")\n .replace(/=+$/, \"\");\n}\n\n/** Compute CSP SHA-256 source expressions for inline scripts in prerendered HTML. */\nexport async function inlineScriptHashes(html: string): Promise<string[]> {\n const hashes = await Promise.all(\n [...html.matchAll(/<script\\b([^>]*)>([\\s\\S]*?)<\\/script>/gi)]\n .filter(([, attributes = \"\"]) => !/(?:^|\\s)src\\s*=/i.test(attributes))\n .map(async ([, , source = \"\"]) => {\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(source),\n );\n return `'sha256-${base64(new Uint8Array(digest))}'`;\n }),\n );\n return [...new Set(hashes)];\n}\n\nfunction apiOrigin(value: string | undefined) {\n if (value === undefined) {\n return \"https://storefront-api.zappie.ai\";\n }\n try {\n const url = new URL(value);\n if (\n url.protocol === \"https:\" ||\n (url.protocol === \"http:\" &&\n [\"localhost\", \"127.0.0.1\", \"[::1]\"].includes(url.hostname))\n ) {\n return url.origin;\n }\n } catch {\n return undefined;\n }\n return undefined;\n}\n\nfunction configuredSources(config: ZappieSiteConfig | undefined) {\n const configured = new Map<string, string[]>();\n const csp = config?.security?.csp as unknown;\n if (!record(csp)) {\n return configured;\n }\n for (const [name, values] of Object.entries(csp)) {\n const directive = sourceNames[name as keyof typeof sourceNames];\n if (!directive || !Array.isArray(values)) {\n continue;\n }\n const valid = values.filter(\n (value): value is string =>\n typeof value === \"string\" && !/[;,\\s]/.test(value) && hostname(value),\n );\n configured.set(\n directive,\n valid.map((host) => `https://${host}`),\n );\n }\n return configured;\n}\n\n/** Build the report-only security headers for a storefront document response. */\nexport function securityHeaders(options: SecurityHeadersOptions = {}): Headers {\n if (!options.nonce && !options.scriptHashes?.length) {\n throw new TypeError(\n \"securityHeaders needs a nonce or an inline script hash.\",\n );\n }\n const sources = Object.fromEntries(\n Object.entries(DEFAULT_SOURCES).map(([name, values]) => [\n name,\n [...values],\n ]),\n ) as Record<(typeof sourceNames)[keyof typeof sourceNames], string[]>;\n const storefrontApiOrigin = apiOrigin(options.apiUrl);\n if (storefrontApiOrigin) {\n sources[\"connect-src\"].push(storefrontApiOrigin);\n }\n for (const [name, values] of configuredSources(options.config)) {\n sources[name as keyof typeof sources].push(...values);\n }\n const outbound = options.config?.outbound?.allow as unknown;\n if (Array.isArray(outbound)) {\n sources[\"connect-src\"].push(\n ...outbound\n .filter(\n (host): host is string =>\n typeof host === \"string\" && !/[;,\\s]/.test(host) && hostname(host),\n )\n .map((host) => `https://${host}`),\n );\n }\n for (const values of Object.values(sources)) {\n values.splice(0, values.length, ...new Set(values));\n }\n const scriptSources = [\n \"'self'\",\n ...(options.nonce ? [`'nonce-${options.nonce}'`] : []),\n ...(options.scriptHashes ?? []),\n \"'strict-dynamic'\",\n ];\n const directives = [\n \"default-src 'self'\",\n `script-src ${scriptSources.join(\" \")}`,\n ...Object.entries(sources).map(\n ([name, values]) => `${name} ${values.join(\" \")}`,\n ),\n \"form-action 'self' https://accounts.zappie.ai https://checkout.zappie.ai\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n ...(!options.hosted ? [\"frame-ancestors 'self'\"] : []),\n \"report-uri /_zappie/csp\",\n ];\n const headers = new Headers(options.headers);\n headers.set(\"content-security-policy-report-only\", directives.join(\"; \"));\n headers.set(\"strict-transport-security\", \"max-age=31536000\");\n headers.set(\"x-content-type-options\", \"nosniff\");\n headers.set(\"referrer-policy\", \"strict-origin-when-cross-origin\");\n headers.set(\n \"permissions-policy\",\n 'camera=(), microphone=(), usb=(), geolocation=(self), payment=(self \"https://checkout.zappie.ai\")',\n );\n return headers;\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -1,4 +1,5 @@
1
1
  // src/server/storefront-url/parse-v2.ts
2
+ import { sameOriginPath } from "../../internal/return-path.js";
2
3
  import { canonicalRoute } from "./canonical.js";
3
4
  import { normalizedPath, segment } from "./installation.js";
4
5
  import { parseV1 } from "./parse-v1.js";
@@ -24,13 +25,20 @@ var canonical = (url, state, business) => {
24
25
  ...common
25
26
  };
26
27
  }
27
- if (pathname === "/checkout/return" || pathname === "/sign-in") {
28
- const signIn = pathname === "/sign-in";
29
- const value = url.searchParams.get(signIn ? "return" : "session");
28
+ if (pathname === "/checkout/return") {
29
+ const session = url.searchParams.get("session");
30
30
  return {
31
- type: signIn ? "sign-in" : "checkout-return",
31
+ type: "checkout-return",
32
32
  ...common,
33
- ...value !== null ? { [signIn ? "returnTo" : "session"]: value } : {}
33
+ ...session !== null ? { session } : {}
34
+ };
35
+ }
36
+ if (pathname === "/sign-in") {
37
+ const returnTo = sameOriginPath(url.searchParams.get("return"));
38
+ return {
39
+ type: "sign-in",
40
+ ...common,
41
+ ...returnTo !== void 0 ? { returnTo } : {}
34
42
  };
35
43
  }
36
44
  if (parts[0] === "pages" && parts[1] && parts.length === 2) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/server/storefront-url/parse-v2.ts"],
4
- "sourcesContent": ["import { canonicalRoute } from \"./canonical\";\nimport { normalizedPath, segment } from \"./installation\";\nimport { parseV1 } from \"./parse-v1\";\nimport type {\n StorefrontInstallation,\n StorefrontPageState,\n StorefrontRoute,\n StorefrontUrlInput,\n StorefrontUrlOptions,\n} from \"./types\";\nimport { inputUrl, pageState } from \"./url\";\n\nconst canonical = (\n url: URL,\n state: StorefrontPageState,\n business: string,\n): StorefrontRoute | null => {\n const pathname = normalizedPath(url.pathname);\n const parts = pathname.split(\"/\").filter(Boolean);\n const branch = url.searchParams.get(\"branch\");\n const common = { business, ...(branch !== null ? { branch } : {}), state };\n if (pathname === \"/\") {\n return { type: \"order-menu\", ...common };\n }\n if (parts[0] === \"item\" && parts[1] && parts.length === 2) {\n return {\n type: \"item\",\n ...common,\n item: segment(parts, 1),\n };\n }\n if (pathname === \"/cart\" || pathname === \"/checkout\") {\n return {\n type: pathname === \"/cart\" ? \"cart\" : \"checkout\",\n ...common,\n };\n }\n if (pathname === \"/checkout/return\" || pathname === \"/sign-in\") {\n const signIn = pathname === \"/sign-in\";\n const value = url.searchParams.get(signIn ? \"return\" : \"session\");\n return {\n type: signIn ? \"sign-in\" : \"checkout-return\",\n ...common,\n ...(value !== null ? { [signIn ? \"returnTo\" : \"session\"]: value } : {}),\n };\n }\n if (parts[0] === \"pages\" && parts[1] && parts.length === 2) {\n return {\n type: \"content-page\",\n ...common,\n slug: segment(parts, 1),\n };\n }\n if (parts[0] === \"account\" && parts.length <= 2) {\n const pages = {\n \"payment-methods\": \"payment-methods\",\n transactions: \"transactions\",\n \"store-credit\": \"add-store-credit\",\n } as const;\n if (parts.length === 1) {\n return { type: \"profile\", page: \"edit\", state };\n }\n const page = parts[1];\n if (page && Object.hasOwn(pages, page)) {\n return {\n type: \"profile\",\n page: pages[page as keyof typeof pages],\n state,\n };\n }\n }\n return null;\n};\n\nexport const parseV2 = (\n input: StorefrontUrlInput,\n options: StorefrontUrlOptions & { installation: StorefrontInstallation },\n): StorefrontRoute => {\n try {\n const url = inputUrl(input);\n const state = pageState(url);\n const installation = options.installation;\n const route = canonical(url, state, installation.business);\n return canonicalRoute(route ?? parseV1(input, options), installation);\n } catch {\n return { type: \"unknown\", pathname: \"\", state: { query: {} } };\n }\n};\n"],
5
- "mappings": ";AAAA,SAAS,sBAAsB;AAC/B,SAAS,gBAAgB,eAAe;AACxC,SAAS,eAAe;AAQxB,SAAS,UAAU,iBAAiB;AAEpC,IAAM,YAAY,CAChB,KACA,OACA,aAC2B;AAC3B,QAAM,WAAW,eAAe,IAAI,QAAQ;AAC5C,QAAM,QAAQ,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AAChD,QAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,QAAM,SAAS,EAAE,UAAU,GAAI,WAAW,OAAO,EAAE,OAAO,IAAI,CAAC,GAAI,MAAM;AACzE,MAAI,aAAa,KAAK;AACpB,WAAO,EAAE,MAAM,cAAc,GAAG,OAAO;AAAA,EACzC;AACA,MAAI,MAAM,CAAC,MAAM,UAAU,MAAM,CAAC,KAAK,MAAM,WAAW,GAAG;AACzD,WAAO;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,MACH,MAAM,QAAQ,OAAO,CAAC;AAAA,IACxB;AAAA,EACF;AACA,MAAI,aAAa,WAAW,aAAa,aAAa;AACpD,WAAO;AAAA,MACL,MAAM,aAAa,UAAU,SAAS;AAAA,MACtC,GAAG;AAAA,IACL;AAAA,EACF;AACA,MAAI,aAAa,sBAAsB,aAAa,YAAY;AAC9D,UAAM,SAAS,aAAa;AAC5B,UAAM,QAAQ,IAAI,aAAa,IAAI,SAAS,WAAW,SAAS;AAChE,WAAO;AAAA,MACL,MAAM,SAAS,YAAY;AAAA,MAC3B,GAAG;AAAA,MACH,GAAI,UAAU,OAAO,EAAE,CAAC,SAAS,aAAa,SAAS,GAAG,MAAM,IAAI,CAAC;AAAA,IACvE;AAAA,EACF;AACA,MAAI,MAAM,CAAC,MAAM,WAAW,MAAM,CAAC,KAAK,MAAM,WAAW,GAAG;AAC1D,WAAO;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,MACH,MAAM,QAAQ,OAAO,CAAC;AAAA,IACxB;AAAA,EACF;AACA,MAAI,MAAM,CAAC,MAAM,aAAa,MAAM,UAAU,GAAG;AAC/C,UAAM,QAAQ;AAAA,MACZ,mBAAmB;AAAA,MACnB,cAAc;AAAA,MACd,gBAAgB;AAAA,IAClB;AACA,QAAI,MAAM,WAAW,GAAG;AACtB,aAAO,EAAE,MAAM,WAAW,MAAM,QAAQ,MAAM;AAAA,IAChD;AACA,UAAM,OAAO,MAAM,CAAC;AACpB,QAAI,QAAQ,OAAO,OAAO,OAAO,IAAI,GAAG;AACtC,aAAO;AAAA,QACL,MAAM;AAAA,QACN,MAAM,MAAM,IAA0B;AAAA,QACtC;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEO,IAAM,UAAU,CACrB,OACA,YACoB;AACpB,MAAI;AACF,UAAM,MAAM,SAAS,KAAK;AAC1B,UAAM,QAAQ,UAAU,GAAG;AAC3B,UAAM,eAAe,QAAQ;AAC7B,UAAM,QAAQ,UAAU,KAAK,OAAO,aAAa,QAAQ;AACzD,WAAO,eAAe,SAAS,QAAQ,OAAO,OAAO,GAAG,YAAY;AAAA,EACtE,QAAQ;AACN,WAAO,EAAE,MAAM,WAAW,UAAU,IAAI,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE;AAAA,EAC/D;AACF;",
4
+ "sourcesContent": ["import { sameOriginPath } from \"../../internal/return-path\";\nimport { canonicalRoute } from \"./canonical\";\nimport { normalizedPath, segment } from \"./installation\";\nimport { parseV1 } from \"./parse-v1\";\nimport type {\n StorefrontInstallation,\n StorefrontPageState,\n StorefrontRoute,\n StorefrontUrlInput,\n StorefrontUrlOptions,\n} from \"./types\";\nimport { inputUrl, pageState } from \"./url\";\n\nconst canonical = (\n url: URL,\n state: StorefrontPageState,\n business: string,\n): StorefrontRoute | null => {\n const pathname = normalizedPath(url.pathname);\n const parts = pathname.split(\"/\").filter(Boolean);\n const branch = url.searchParams.get(\"branch\");\n const common = { business, ...(branch !== null ? { branch } : {}), state };\n if (pathname === \"/\") {\n return { type: \"order-menu\", ...common };\n }\n if (parts[0] === \"item\" && parts[1] && parts.length === 2) {\n return {\n type: \"item\",\n ...common,\n item: segment(parts, 1),\n };\n }\n if (pathname === \"/cart\" || pathname === \"/checkout\") {\n return {\n type: pathname === \"/cart\" ? \"cart\" : \"checkout\",\n ...common,\n };\n }\n if (pathname === \"/checkout/return\") {\n const session = url.searchParams.get(\"session\");\n return {\n type: \"checkout-return\",\n ...common,\n ...(session !== null ? { session } : {}),\n };\n }\n if (pathname === \"/sign-in\") {\n // A return path that leaves the site is dropped, so a crafted link cannot redirect off it.\n const returnTo = sameOriginPath(url.searchParams.get(\"return\"));\n return {\n type: \"sign-in\",\n ...common,\n ...(returnTo !== undefined ? { returnTo } : {}),\n };\n }\n if (parts[0] === \"pages\" && parts[1] && parts.length === 2) {\n return {\n type: \"content-page\",\n ...common,\n slug: segment(parts, 1),\n };\n }\n if (parts[0] === \"account\" && parts.length <= 2) {\n const pages = {\n \"payment-methods\": \"payment-methods\",\n transactions: \"transactions\",\n \"store-credit\": \"add-store-credit\",\n } as const;\n if (parts.length === 1) {\n return { type: \"profile\", page: \"edit\", state };\n }\n const page = parts[1];\n if (page && Object.hasOwn(pages, page)) {\n return {\n type: \"profile\",\n page: pages[page as keyof typeof pages],\n state,\n };\n }\n }\n return null;\n};\n\nexport const parseV2 = (\n input: StorefrontUrlInput,\n options: StorefrontUrlOptions & { installation: StorefrontInstallation },\n): StorefrontRoute => {\n try {\n const url = inputUrl(input);\n const state = pageState(url);\n const installation = options.installation;\n const route = canonical(url, state, installation.business);\n return canonicalRoute(route ?? parseV1(input, options), installation);\n } catch {\n return { type: \"unknown\", pathname: \"\", state: { query: {} } };\n }\n};\n"],
5
+ "mappings": ";AAAA,SAAS,sBAAsB;AAC/B,SAAS,sBAAsB;AAC/B,SAAS,gBAAgB,eAAe;AACxC,SAAS,eAAe;AAQxB,SAAS,UAAU,iBAAiB;AAEpC,IAAM,YAAY,CAChB,KACA,OACA,aAC2B;AAC3B,QAAM,WAAW,eAAe,IAAI,QAAQ;AAC5C,QAAM,QAAQ,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AAChD,QAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,QAAM,SAAS,EAAE,UAAU,GAAI,WAAW,OAAO,EAAE,OAAO,IAAI,CAAC,GAAI,MAAM;AACzE,MAAI,aAAa,KAAK;AACpB,WAAO,EAAE,MAAM,cAAc,GAAG,OAAO;AAAA,EACzC;AACA,MAAI,MAAM,CAAC,MAAM,UAAU,MAAM,CAAC,KAAK,MAAM,WAAW,GAAG;AACzD,WAAO;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,MACH,MAAM,QAAQ,OAAO,CAAC;AAAA,IACxB;AAAA,EACF;AACA,MAAI,aAAa,WAAW,aAAa,aAAa;AACpD,WAAO;AAAA,MACL,MAAM,aAAa,UAAU,SAAS;AAAA,MACtC,GAAG;AAAA,IACL;AAAA,EACF;AACA,MAAI,aAAa,oBAAoB;AACnC,UAAM,UAAU,IAAI,aAAa,IAAI,SAAS;AAC9C,WAAO;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,MACH,GAAI,YAAY,OAAO,EAAE,QAAQ,IAAI,CAAC;AAAA,IACxC;AAAA,EACF;AACA,MAAI,aAAa,YAAY;AAE3B,UAAM,WAAW,eAAe,IAAI,aAAa,IAAI,QAAQ,CAAC;AAC9D,WAAO;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,MACH,GAAI,aAAa,SAAY,EAAE,SAAS,IAAI,CAAC;AAAA,IAC/C;AAAA,EACF;AACA,MAAI,MAAM,CAAC,MAAM,WAAW,MAAM,CAAC,KAAK,MAAM,WAAW,GAAG;AAC1D,WAAO;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,MACH,MAAM,QAAQ,OAAO,CAAC;AAAA,IACxB;AAAA,EACF;AACA,MAAI,MAAM,CAAC,MAAM,aAAa,MAAM,UAAU,GAAG;AAC/C,UAAM,QAAQ;AAAA,MACZ,mBAAmB;AAAA,MACnB,cAAc;AAAA,MACd,gBAAgB;AAAA,IAClB;AACA,QAAI,MAAM,WAAW,GAAG;AACtB,aAAO,EAAE,MAAM,WAAW,MAAM,QAAQ,MAAM;AAAA,IAChD;AACA,UAAM,OAAO,MAAM,CAAC;AACpB,QAAI,QAAQ,OAAO,OAAO,OAAO,IAAI,GAAG;AACtC,aAAO;AAAA,QACL,MAAM;AAAA,QACN,MAAM,MAAM,IAA0B;AAAA,QACtC;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEO,IAAM,UAAU,CACrB,OACA,YACoB;AACpB,MAAI;AACF,UAAM,MAAM,SAAS,KAAK;AAC1B,UAAM,QAAQ,UAAU,GAAG;AAC3B,UAAM,eAAe,QAAQ;AAC7B,UAAM,QAAQ,UAAU,KAAK,OAAO,aAAa,QAAQ;AACzD,WAAO,eAAe,SAAS,QAAQ,OAAO,OAAO,GAAG,YAAY;AAAA,EACtE,QAAQ;AACN,WAAO,EAAE,MAAM,WAAW,UAAU,IAAI,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE;AAAA,EAC/D;AACF;",
6
6
  "names": []
7
7
  }
@@ -20,7 +20,9 @@ var inputUrl = (input) => {
20
20
  const value = typeof input === "string" ? input : input.url;
21
21
  return new URL(value, FALLBACK_ORIGIN);
22
22
  };
23
- var isAttributionKey = (key) => /^utm_|^(ref|\w*c..?id|.?braid|li_fat_id)$/.test(key);
23
+ var isAttributionKey = (key) => /^utm_|^(ref|(fb|g|d|tt|tw)clid|msclkid|[gw]braid|li_fat_id|ScCid)$/.test(
24
+ key
25
+ );
24
26
  var queryRecord = (params, contract) => {
25
27
  const allowed = contract === "v2" ? STOREFRONT_QUERY_PARAMETERS : STOREFRONT_V1_QUERY_PARAMETERS;
26
28
  return Object.fromEntries(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/server/storefront-url/url.ts"],
4
- "sourcesContent": ["import {\n STOREFRONT_QUERY_PARAMETERS,\n STOREFRONT_V1_QUERY_PARAMETERS,\n type StorefrontPageState,\n type StorefrontUrlInput,\n} from \"./types\";\n\nexport const FALLBACK_ORIGIN = \"https://storefront.invalid\";\nexport const ITEM_MODAL_PREFIX = \"product_\";\n\nexport const decode = (value: string) => {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n};\n\nexport const encode = (value: string) => encodeURIComponent(value);\n\nexport const inputUrl = (input: StorefrontUrlInput) => {\n if (input instanceof URL) {\n return new URL(input.toString());\n }\n const value = typeof input === \"string\" ? input : input.url;\n return new URL(value, FALLBACK_ORIGIN);\n};\n\nexport const isAttributionKey = (key: string) =>\n /^utm_|^(ref|\\w*c..?id|.?braid|li_fat_id)$/.test(key);\n\nexport const queryRecord = (params: URLSearchParams, contract: \"v1\" | \"v2\") => {\n const allowed: readonly string[] =\n contract === \"v2\"\n ? STOREFRONT_QUERY_PARAMETERS\n : STOREFRONT_V1_QUERY_PARAMETERS;\n return Object.fromEntries(\n [...params].filter(\n ([key]) => allowed.includes(key) || isAttributionKey(key),\n ),\n );\n};\n\nexport const STATE_FIELDS = {\n menu_id: \"menuId\",\n category_id: \"categoryId\",\n menu_layout: \"menuLayout\",\n modal_id: \"modalId\",\n auth_modal_id: \"authModalId\",\n payment_id: \"paymentId\",\n lightbox: \"lightbox\",\n catalogue_search: \"catalogueSearch\",\n catalogue_categories: \"catalogueCategories\",\n} as const;\n\nexport const pageState = (\n url: URL,\n contract: \"v1\" | \"v2\" = \"v2\",\n): StorefrontPageState => {\n const query = queryRecord(url.searchParams, contract);\n const state: StorefrontPageState = { query };\n for (const [key, field] of Object.entries(STATE_FIELDS)) {\n const value = url.searchParams.get(key);\n if (value !== null) {\n state[field] = value;\n }\n }\n const pickup = url.searchParams.get(\"pickup\");\n if (pickup === \"true\" || pickup === \"false\") {\n state.mode = pickup === \"true\" ? \"pickup\" : \"delivery\";\n }\n if (\n state.categoryId === undefined &&\n contract === \"v2\" &&\n url.hash.startsWith(\"#c-\")\n ) {\n state.categoryId = decode(url.hash.slice(3));\n }\n return state;\n};\n"],
5
- "mappings": ";AAAA;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAEA,IAAM,kBAAkB;AACxB,IAAM,oBAAoB;AAE1B,IAAM,SAAS,CAAC,UAAkB;AACvC,MAAI;AACF,WAAO,mBAAmB,KAAK;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,IAAM,SAAS,CAAC,UAAkB,mBAAmB,KAAK;AAE1D,IAAM,WAAW,CAAC,UAA8B;AACrD,MAAI,iBAAiB,KAAK;AACxB,WAAO,IAAI,IAAI,MAAM,SAAS,CAAC;AAAA,EACjC;AACA,QAAM,QAAQ,OAAO,UAAU,WAAW,QAAQ,MAAM;AACxD,SAAO,IAAI,IAAI,OAAO,eAAe;AACvC;AAEO,IAAM,mBAAmB,CAAC,QAC/B,4CAA4C,KAAK,GAAG;AAE/C,IAAM,cAAc,CAAC,QAAyB,aAA0B;AAC7E,QAAM,UACJ,aAAa,OACT,8BACA;AACN,SAAO,OAAO;AAAA,IACZ,CAAC,GAAG,MAAM,EAAE;AAAA,MACV,CAAC,CAAC,GAAG,MAAM,QAAQ,SAAS,GAAG,KAAK,iBAAiB,GAAG;AAAA,IAC1D;AAAA,EACF;AACF;AAEO,IAAM,eAAe;AAAA,EAC1B,SAAS;AAAA,EACT,aAAa;AAAA,EACb,aAAa;AAAA,EACb,UAAU;AAAA,EACV,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB,sBAAsB;AACxB;AAEO,IAAM,YAAY,CACvB,KACA,WAAwB,SACA;AACxB,QAAM,QAAQ,YAAY,IAAI,cAAc,QAAQ;AACpD,QAAM,QAA6B,EAAE,MAAM;AAC3C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,YAAY,GAAG;AACvD,UAAM,QAAQ,IAAI,aAAa,IAAI,GAAG;AACtC,QAAI,UAAU,MAAM;AAClB,YAAM,KAAK,IAAI;AAAA,IACjB;AAAA,EACF;AACA,QAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,MAAI,WAAW,UAAU,WAAW,SAAS;AAC3C,UAAM,OAAO,WAAW,SAAS,WAAW;AAAA,EAC9C;AACA,MACE,MAAM,eAAe,UACrB,aAAa,QACb,IAAI,KAAK,WAAW,KAAK,GACzB;AACA,UAAM,aAAa,OAAO,IAAI,KAAK,MAAM,CAAC,CAAC;AAAA,EAC7C;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["import {\n STOREFRONT_QUERY_PARAMETERS,\n STOREFRONT_V1_QUERY_PARAMETERS,\n type StorefrontPageState,\n type StorefrontUrlInput,\n} from \"./types\";\n\nexport const FALLBACK_ORIGIN = \"https://storefront.invalid\";\nexport const ITEM_MODAL_PREFIX = \"product_\";\n\nexport const decode = (value: string) => {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n};\n\nexport const encode = (value: string) => encodeURIComponent(value);\n\nexport const inputUrl = (input: StorefrontUrlInput) => {\n if (input instanceof URL) {\n return new URL(input.toString());\n }\n const value = typeof input === \"string\" ? input : input.url;\n return new URL(value, FALLBACK_ORIGIN);\n};\n\n// The exact ad click IDs, so an ordinary key such as doc_id or src_id never passes.\nexport const isAttributionKey = (key: string) =>\n /^utm_|^(ref|(fb|g|d|tt|tw)clid|msclkid|[gw]braid|li_fat_id|ScCid)$/.test(\n key,\n );\n\nexport const queryRecord = (params: URLSearchParams, contract: \"v1\" | \"v2\") => {\n const allowed: readonly string[] =\n contract === \"v2\"\n ? STOREFRONT_QUERY_PARAMETERS\n : STOREFRONT_V1_QUERY_PARAMETERS;\n return Object.fromEntries(\n [...params].filter(\n ([key]) => allowed.includes(key) || isAttributionKey(key),\n ),\n );\n};\n\nexport const STATE_FIELDS = {\n menu_id: \"menuId\",\n category_id: \"categoryId\",\n menu_layout: \"menuLayout\",\n modal_id: \"modalId\",\n auth_modal_id: \"authModalId\",\n payment_id: \"paymentId\",\n lightbox: \"lightbox\",\n catalogue_search: \"catalogueSearch\",\n catalogue_categories: \"catalogueCategories\",\n} as const;\n\nexport const pageState = (\n url: URL,\n contract: \"v1\" | \"v2\" = \"v2\",\n): StorefrontPageState => {\n const query = queryRecord(url.searchParams, contract);\n const state: StorefrontPageState = { query };\n for (const [key, field] of Object.entries(STATE_FIELDS)) {\n const value = url.searchParams.get(key);\n if (value !== null) {\n state[field] = value;\n }\n }\n const pickup = url.searchParams.get(\"pickup\");\n if (pickup === \"true\" || pickup === \"false\") {\n state.mode = pickup === \"true\" ? \"pickup\" : \"delivery\";\n }\n if (\n state.categoryId === undefined &&\n contract === \"v2\" &&\n url.hash.startsWith(\"#c-\")\n ) {\n state.categoryId = decode(url.hash.slice(3));\n }\n return state;\n};\n"],
5
+ "mappings": ";AAAA;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAEA,IAAM,kBAAkB;AACxB,IAAM,oBAAoB;AAE1B,IAAM,SAAS,CAAC,UAAkB;AACvC,MAAI;AACF,WAAO,mBAAmB,KAAK;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,IAAM,SAAS,CAAC,UAAkB,mBAAmB,KAAK;AAE1D,IAAM,WAAW,CAAC,UAA8B;AACrD,MAAI,iBAAiB,KAAK;AACxB,WAAO,IAAI,IAAI,MAAM,SAAS,CAAC;AAAA,EACjC;AACA,QAAM,QAAQ,OAAO,UAAU,WAAW,QAAQ,MAAM;AACxD,SAAO,IAAI,IAAI,OAAO,eAAe;AACvC;AAGO,IAAM,mBAAmB,CAAC,QAC/B,qEAAqE;AAAA,EACnE;AACF;AAEK,IAAM,cAAc,CAAC,QAAyB,aAA0B;AAC7E,QAAM,UACJ,aAAa,OACT,8BACA;AACN,SAAO,OAAO;AAAA,IACZ,CAAC,GAAG,MAAM,EAAE;AAAA,MACV,CAAC,CAAC,GAAG,MAAM,QAAQ,SAAS,GAAG,KAAK,iBAAiB,GAAG;AAAA,IAC1D;AAAA,EACF;AACF;AAEO,IAAM,eAAe;AAAA,EAC1B,SAAS;AAAA,EACT,aAAa;AAAA,EACb,aAAa;AAAA,EACb,UAAU;AAAA,EACV,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB,sBAAsB;AACxB;AAEO,IAAM,YAAY,CACvB,KACA,WAAwB,SACA;AACxB,QAAM,QAAQ,YAAY,IAAI,cAAc,QAAQ;AACpD,QAAM,QAA6B,EAAE,MAAM;AAC3C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,YAAY,GAAG;AACvD,UAAM,QAAQ,IAAI,aAAa,IAAI,GAAG;AACtC,QAAI,UAAU,MAAM;AAClB,YAAM,KAAK,IAAI;AAAA,IACjB;AAAA,EACF;AACA,QAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;AAC5C,MAAI,WAAW,UAAU,WAAW,SAAS;AAC3C,UAAM,OAAO,WAAW,SAAS,WAAW;AAAA,EAC9C;AACA,MACE,MAAM,eAAe,UACrB,aAAa,QACb,IAAI,KAAK,WAAW,KAAK,GACzB;AACA,UAAM,aAAa,OAAO,IAAI,KAAK,MAAM,CAAC,CAAC;AAAA,EAC7C;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -41,7 +41,7 @@ var globallyAllowed = (value) => GLOBAL_PACKAGE_ALLOWLIST.some(
41
41
  function hostname(value) {
42
42
  const wildcard = value.startsWith("*.");
43
43
  const host = wildcard ? value.slice(2) : value;
44
- if (!host.includes(".") || /^\d+(?:\.\d+){3}$/.test(host) || host.length > 253 || host.includes("..")) {
44
+ if (!host.includes(".") || /^\d+(?:\.\d+){3}$/.test(host) || host.length > 253 || host.includes("..") || wildcard && /^(?:ac|co|com|edu|gov|net|org)\.[^.]+$/i.test(host)) {
45
45
  return false;
46
46
  }
47
47
  return host.split(".").every(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/site-config/helpers.ts"],
4
- "sourcesContent": ["import { GLOBAL_PACKAGE_ALLOWLIST, type ConfigIssue } from \"./types\";\n\nexport const record = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nfunction distance(left: string, right: string) {\n let row = Array.from({ length: right.length + 1 }, (_, index) => index);\n for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {\n const next = [leftIndex];\n for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {\n next[rightIndex] = Math.min(\n (next[rightIndex - 1] ?? 0) + 1,\n (row[rightIndex] ?? 0) + 1,\n (row[rightIndex - 1] ?? 0) +\n (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),\n );\n }\n row = next;\n }\n return row[right.length] ?? Infinity;\n}\n\nexport function unknownKeys(\n value: Record<string, unknown>,\n allowed: readonly string[],\n path: string,\n issue: (value: ConfigIssue) => void,\n) {\n for (const key of Object.keys(value)) {\n if (allowed.includes(key)) {\n continue;\n }\n const closest = allowed\n .map((candidate) => ({ candidate, score: distance(key, candidate) }))\n .sort((left, right) => left.score - right.score)[0];\n const hint = closest && closest.score <= 2 ? closest.candidate : undefined;\n issue({\n path: path ? `${path}.${key}` : key,\n code: \"unknown_key\",\n message: hint\n ? `Unknown field \"${key}\". Rename it to \"${hint}\".`\n : `Unknown field \"${key}\". Remove it from the configuration.`,\n severity: \"error\",\n });\n }\n}\n\nexport const nonblank = (value: unknown): value is string =>\n typeof value === \"string\" && value.trim().length > 0;\n\nexport const packageName = (value: string) =>\n value.length <= 214 &&\n /^(?:@[a-z0-9][a-z0-9._~-]*\\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/.test(\n value,\n );\n\nexport const globallyAllowed = (value: string) =>\n GLOBAL_PACKAGE_ALLOWLIST.some((allowed) =>\n allowed.endsWith(\"/*\")\n ? value.startsWith(allowed.slice(0, -1))\n : value === allowed,\n );\n\nexport function hostname(value: string) {\n const wildcard = value.startsWith(\"*.\");\n const host = wildcard ? value.slice(2) : value;\n if (\n !host.includes(\".\") ||\n /^\\d+(?:\\.\\d+){3}$/.test(host) ||\n host.length > 253 ||\n host.includes(\"..\")\n ) {\n return false;\n }\n return host\n .split(\".\")\n .every(\n (label) =>\n label.length > 0 &&\n label.length <= 63 &&\n /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),\n );\n}\n"],
5
- "mappings": ";AAAA,SAAS,gCAAkD;AAEpD,IAAM,SAAS,CAAC,UACrB,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAErE,SAAS,SAAS,MAAc,OAAe;AAC7C,MAAI,MAAM,MAAM,KAAK,EAAE,QAAQ,MAAM,SAAS,EAAE,GAAG,CAAC,GAAG,UAAU,KAAK;AACtE,WAAS,YAAY,GAAG,aAAa,KAAK,QAAQ,aAAa,GAAG;AAChE,UAAM,OAAO,CAAC,SAAS;AACvB,aAAS,aAAa,GAAG,cAAc,MAAM,QAAQ,cAAc,GAAG;AACpE,WAAK,UAAU,IAAI,KAAK;AAAA,SACrB,KAAK,aAAa,CAAC,KAAK,KAAK;AAAA,SAC7B,IAAI,UAAU,KAAK,KAAK;AAAA,SACxB,IAAI,aAAa,CAAC,KAAK,MACrB,KAAK,YAAY,CAAC,MAAM,MAAM,aAAa,CAAC,IAAI,IAAI;AAAA,MACzD;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACA,SAAO,IAAI,MAAM,MAAM,KAAK;AAC9B;AAEO,SAAS,YACd,OACA,SACA,MACA,OACA;AACA,aAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,QAAI,QAAQ,SAAS,GAAG,GAAG;AACzB;AAAA,IACF;AACA,UAAM,UAAU,QACb,IAAI,CAAC,eAAe,EAAE,WAAW,OAAO,SAAS,KAAK,SAAS,EAAE,EAAE,EACnE,KAAK,CAAC,MAAM,UAAU,KAAK,QAAQ,MAAM,KAAK,EAAE,CAAC;AACpD,UAAM,OAAO,WAAW,QAAQ,SAAS,IAAI,QAAQ,YAAY;AACjE,UAAM;AAAA,MACJ,MAAM,OAAO,GAAG,IAAI,IAAI,GAAG,KAAK;AAAA,MAChC,MAAM;AAAA,MACN,SAAS,OACL,kBAAkB,GAAG,oBAAoB,IAAI,OAC7C,kBAAkB,GAAG;AAAA,MACzB,UAAU;AAAA,IACZ,CAAC;AAAA,EACH;AACF;AAEO,IAAM,WAAW,CAAC,UACvB,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS;AAE9C,IAAM,cAAc,CAAC,UAC1B,MAAM,UAAU,OAChB,4EAA4E;AAAA,EAC1E;AACF;AAEK,IAAM,kBAAkB,CAAC,UAC9B,yBAAyB;AAAA,EAAK,CAAC,YAC7B,QAAQ,SAAS,IAAI,IACjB,MAAM,WAAW,QAAQ,MAAM,GAAG,EAAE,CAAC,IACrC,UAAU;AAChB;AAEK,SAAS,SAAS,OAAe;AACtC,QAAM,WAAW,MAAM,WAAW,IAAI;AACtC,QAAM,OAAO,WAAW,MAAM,MAAM,CAAC,IAAI;AACzC,MACE,CAAC,KAAK,SAAS,GAAG,KAClB,oBAAoB,KAAK,IAAI,KAC7B,KAAK,SAAS,OACd,KAAK,SAAS,IAAI,GAClB;AACA,WAAO;AAAA,EACT;AACA,SAAO,KACJ,MAAM,GAAG,EACT;AAAA,IACC,CAAC,UACC,MAAM,SAAS,KACf,MAAM,UAAU,MAChB,qCAAqC,KAAK,KAAK;AAAA,EACnD;AACJ;",
4
+ "sourcesContent": ["import { GLOBAL_PACKAGE_ALLOWLIST, type ConfigIssue } from \"./types\";\n\nexport const record = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nfunction distance(left: string, right: string) {\n let row = Array.from({ length: right.length + 1 }, (_, index) => index);\n for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {\n const next = [leftIndex];\n for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {\n next[rightIndex] = Math.min(\n (next[rightIndex - 1] ?? 0) + 1,\n (row[rightIndex] ?? 0) + 1,\n (row[rightIndex - 1] ?? 0) +\n (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),\n );\n }\n row = next;\n }\n return row[right.length] ?? Infinity;\n}\n\nexport function unknownKeys(\n value: Record<string, unknown>,\n allowed: readonly string[],\n path: string,\n issue: (value: ConfigIssue) => void,\n) {\n for (const key of Object.keys(value)) {\n if (allowed.includes(key)) {\n continue;\n }\n const closest = allowed\n .map((candidate) => ({ candidate, score: distance(key, candidate) }))\n .sort((left, right) => left.score - right.score)[0];\n const hint = closest && closest.score <= 2 ? closest.candidate : undefined;\n issue({\n path: path ? `${path}.${key}` : key,\n code: \"unknown_key\",\n message: hint\n ? `Unknown field \"${key}\". Rename it to \"${hint}\".`\n : `Unknown field \"${key}\". Remove it from the configuration.`,\n severity: \"error\",\n });\n }\n}\n\nexport const nonblank = (value: unknown): value is string =>\n typeof value === \"string\" && value.trim().length > 0;\n\nexport const packageName = (value: string) =>\n value.length <= 214 &&\n /^(?:@[a-z0-9][a-z0-9._~-]*\\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/.test(\n value,\n );\n\nexport const globallyAllowed = (value: string) =>\n GLOBAL_PACKAGE_ALLOWLIST.some((allowed) =>\n allowed.endsWith(\"/*\")\n ? value.startsWith(allowed.slice(0, -1))\n : value === allowed,\n );\n\nexport function hostname(value: string) {\n const wildcard = value.startsWith(\"*.\");\n const host = wildcard ? value.slice(2) : value;\n if (\n !host.includes(\".\") ||\n /^\\d+(?:\\.\\d+){3}$/.test(host) ||\n host.length > 253 ||\n host.includes(\"..\") ||\n (wildcard && /^(?:ac|co|com|edu|gov|net|org)\\.[^.]+$/i.test(host))\n ) {\n return false;\n }\n return host\n .split(\".\")\n .every(\n (label) =>\n label.length > 0 &&\n label.length <= 63 &&\n /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),\n );\n}\n"],
5
+ "mappings": ";AAAA,SAAS,gCAAkD;AAEpD,IAAM,SAAS,CAAC,UACrB,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAErE,SAAS,SAAS,MAAc,OAAe;AAC7C,MAAI,MAAM,MAAM,KAAK,EAAE,QAAQ,MAAM,SAAS,EAAE,GAAG,CAAC,GAAG,UAAU,KAAK;AACtE,WAAS,YAAY,GAAG,aAAa,KAAK,QAAQ,aAAa,GAAG;AAChE,UAAM,OAAO,CAAC,SAAS;AACvB,aAAS,aAAa,GAAG,cAAc,MAAM,QAAQ,cAAc,GAAG;AACpE,WAAK,UAAU,IAAI,KAAK;AAAA,SACrB,KAAK,aAAa,CAAC,KAAK,KAAK;AAAA,SAC7B,IAAI,UAAU,KAAK,KAAK;AAAA,SACxB,IAAI,aAAa,CAAC,KAAK,MACrB,KAAK,YAAY,CAAC,MAAM,MAAM,aAAa,CAAC,IAAI,IAAI;AAAA,MACzD;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACA,SAAO,IAAI,MAAM,MAAM,KAAK;AAC9B;AAEO,SAAS,YACd,OACA,SACA,MACA,OACA;AACA,aAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,QAAI,QAAQ,SAAS,GAAG,GAAG;AACzB;AAAA,IACF;AACA,UAAM,UAAU,QACb,IAAI,CAAC,eAAe,EAAE,WAAW,OAAO,SAAS,KAAK,SAAS,EAAE,EAAE,EACnE,KAAK,CAAC,MAAM,UAAU,KAAK,QAAQ,MAAM,KAAK,EAAE,CAAC;AACpD,UAAM,OAAO,WAAW,QAAQ,SAAS,IAAI,QAAQ,YAAY;AACjE,UAAM;AAAA,MACJ,MAAM,OAAO,GAAG,IAAI,IAAI,GAAG,KAAK;AAAA,MAChC,MAAM;AAAA,MACN,SAAS,OACL,kBAAkB,GAAG,oBAAoB,IAAI,OAC7C,kBAAkB,GAAG;AAAA,MACzB,UAAU;AAAA,IACZ,CAAC;AAAA,EACH;AACF;AAEO,IAAM,WAAW,CAAC,UACvB,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS;AAE9C,IAAM,cAAc,CAAC,UAC1B,MAAM,UAAU,OAChB,4EAA4E;AAAA,EAC1E;AACF;AAEK,IAAM,kBAAkB,CAAC,UAC9B,yBAAyB;AAAA,EAAK,CAAC,YAC7B,QAAQ,SAAS,IAAI,IACjB,MAAM,WAAW,QAAQ,MAAM,GAAG,EAAE,CAAC,IACrC,UAAU;AAChB;AAEK,SAAS,SAAS,OAAe;AACtC,QAAM,WAAW,MAAM,WAAW,IAAI;AACtC,QAAM,OAAO,WAAW,MAAM,MAAM,CAAC,IAAI;AACzC,MACE,CAAC,KAAK,SAAS,GAAG,KAClB,oBAAoB,KAAK,IAAI,KAC7B,KAAK,SAAS,OACd,KAAK,SAAS,IAAI,KACjB,YAAY,0CAA0C,KAAK,IAAI,GAChE;AACA,WAAO;AAAA,EACT;AACA,SAAO,KACJ,MAAM,GAAG,EACT;AAAA,IACC,CAAC,UACC,MAAM,SAAS,KACf,MAAM,UAAU,MAChB,qCAAqC,KAAK,KAAK;AAAA,EACnD;AACJ;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/site-config/types.ts"],
4
- "sourcesContent": ["/**\n * Default site budgets: 200 KB gzipped JavaScript, 50 KB gzipped CSS and 2500 ms LCP.\n */\nexport const DEFAULT_BUDGETS = {\n jsKbGz: 200,\n cssKbGz: 50,\n lcpMs: 2500,\n} as const;\n\n/**\n * Package names and patterns allowed globally without a site-specific allowlist entry.\n */\nexport const GLOBAL_PACKAGE_ALLOWLIST = [\n \"react\",\n \"react-dom\",\n \"react-router\",\n \"@react-router/*\",\n \"@zappie/*\",\n \"tailwindcss\",\n \"@tailwindcss/*\",\n \"clsx\",\n \"tailwind-merge\",\n \"class-variance-authority\",\n \"lucide-react\",\n \"date-fns\",\n \"embla-carousel-react\",\n \"framer-motion/motion\",\n \"zod\",\n \"@radix-ui/*\",\n \"sonner\",\n \"vaul\",\n] as const;\n\n/**\n * The store, theme and optional build policies you export from zappie.config.ts.\n */\nexport type ZappieSiteConfig = {\n store: string;\n branches?: string[];\n theme: string;\n prerender?: string[];\n menu?: { hiddenCategories?: string[] };\n packages?: { allow?: string[] };\n /** Time-bounded acknowledgements for advisories that have no fixed version. */\n audit?: {\n ignore?: { id: string; reason: string; until: string }[];\n };\n outbound?: { allow?: string[] };\n /** Each budget left out uses DEFAULT_BUDGETS. */\n budgets?: {\n jsKbGz?: number;\n cssKbGz?: number;\n lcpMs?: number;\n override?: { reason: string };\n };\n /** Records explained exceptions to storefront readiness checks. */\n readiness?: {\n /** Lets this site skip the initial route discovery requirement. */\n routeDiscovery?: {\n /** Explains why this site does not use initial route discovery. */\n optOut: {\n /** States the constraint that prevents initial route discovery. */\n reason: string;\n };\n };\n };\n analytics?: { forward?: { ga4?: string; metaPixel?: string } };\n};\n\n/**\n * A configuration diagnostic with its field path, stable code, message and severity.\n */\nexport type ConfigIssue = {\n path: string;\n code:\n | \"invalid_type\"\n | \"missing_required\"\n | \"unknown_key\"\n | \"invalid_format\"\n | \"duplicate\"\n | \"redundant_allowlist\"\n | \"budget_override\";\n message: string;\n severity: \"error\" | \"warning\";\n};\n\n/**\n * Configuration validation results. Warnings alone do not make valid false.\n */\nexport type ConfigValidation = { valid: boolean; issues: ConfigIssue[] };\n"],
4
+ "sourcesContent": ["/**\n * Default site budgets: 200 KB gzipped JavaScript, 50 KB gzipped CSS and 2500 ms LCP.\n */\nexport const DEFAULT_BUDGETS = {\n jsKbGz: 200,\n cssKbGz: 50,\n lcpMs: 2500,\n} as const;\n\n/**\n * Package names and patterns allowed globally without a site-specific allowlist entry.\n */\nexport const GLOBAL_PACKAGE_ALLOWLIST = [\n \"react\",\n \"react-dom\",\n \"react-router\",\n \"@react-router/*\",\n \"@zappie/*\",\n \"tailwindcss\",\n \"@tailwindcss/*\",\n \"clsx\",\n \"tailwind-merge\",\n \"class-variance-authority\",\n \"lucide-react\",\n \"date-fns\",\n \"embla-carousel-react\",\n \"framer-motion/motion\",\n \"zod\",\n \"@radix-ui/*\",\n \"sonner\",\n \"vaul\",\n] as const;\n\n/**\n * The store, theme and optional build policies you export from zappie.config.ts.\n */\nexport type ZappieSiteConfig = {\n store: string;\n branches?: string[];\n theme: string;\n prerender?: string[];\n menu?: { hiddenCategories?: string[] };\n packages?: { allow?: string[] };\n /** Time-bounded acknowledgements for advisories that have no fixed version. */\n audit?: {\n ignore?: { id: string; reason: string; until: string }[];\n };\n outbound?: { allow?: string[] };\n /** Add sources to the fixed storefront Content Security Policy. */\n security?: {\n csp?: {\n imgSrc?: string[];\n connectSrc?: string[];\n fontSrc?: string[];\n styleSrc?: string[];\n frameSrc?: string[];\n };\n };\n /** Each budget left out uses DEFAULT_BUDGETS. */\n budgets?: {\n jsKbGz?: number;\n cssKbGz?: number;\n lcpMs?: number;\n override?: { reason: string };\n };\n /** Records explained exceptions to storefront readiness checks. */\n readiness?: {\n /** Lets this site skip the initial route discovery requirement. */\n routeDiscovery?: {\n /** Explains why this site does not use initial route discovery. */\n optOut: {\n /** States the constraint that prevents initial route discovery. */\n reason: string;\n };\n };\n };\n analytics?: { forward?: { ga4?: string; metaPixel?: string } };\n};\n\n/**\n * A configuration diagnostic with its field path, stable code, message and severity.\n */\nexport type ConfigIssue = {\n path: string;\n code:\n | \"invalid_type\"\n | \"missing_required\"\n | \"unknown_key\"\n | \"invalid_format\"\n | \"duplicate\"\n | \"redundant_allowlist\"\n | \"budget_override\";\n message: string;\n severity: \"error\" | \"warning\";\n};\n\n/**\n * Configuration validation results. Warnings alone do not make valid false.\n */\nexport type ConfigValidation = { valid: boolean; issues: ConfigIssue[] };\n"],
5
5
  "mappings": ";AAGO,IAAM,kBAAkB;AAAA,EAC7B,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,OAAO;AACT;AAKO,IAAM,2BAA2B;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;",
6
6
  "names": []
7
7
  }
@@ -17,6 +17,7 @@ var rootKeys = [
17
17
  "packages",
18
18
  "audit",
19
19
  "outbound",
20
+ "security",
20
21
  "budgets",
21
22
  "readiness",
22
23
  "analytics"
@@ -70,16 +71,20 @@ function validateConfig(input) {
70
71
  });
71
72
  });
72
73
  nested(input.outbound, "outbound", ["allow"], issue, (value) => {
73
- const allow = stringList(value.allow, "outbound.allow", error);
74
- allow?.forEach((host, index) => {
75
- if (!hostname(host)) {
76
- error(
77
- `outbound.allow[${index}]`,
78
- "invalid_format",
79
- "Expected a hostname or leading *. subdomain pattern without a scheme, path, port or IP. Use a value such as api.example.com."
80
- );
74
+ hostList(value.allow, "outbound.allow", error);
75
+ });
76
+ nested(input.security, "security", ["csp"], issue, (security) => {
77
+ nested(
78
+ security.csp,
79
+ "security.csp",
80
+ ["imgSrc", "connectSrc", "fontSrc", "styleSrc", "frameSrc"],
81
+ issue,
82
+ (csp) => {
83
+ for (const [name, value] of Object.entries(csp)) {
84
+ hostList(value, `security.csp.${name}`, error);
85
+ }
81
86
  }
82
- });
87
+ );
83
88
  });
84
89
  budgets(input.budgets, issue, error);
85
90
  nested(
@@ -180,6 +185,17 @@ function stringList(value, path, error, options = {}) {
180
185
  }
181
186
  return value;
182
187
  }
188
+ function hostList(value, path, error) {
189
+ stringList(value, path, error)?.forEach((host, index) => {
190
+ if (!hostname(host)) {
191
+ error(
192
+ `${path}[${index}]`,
193
+ "invalid_format",
194
+ "Expected a hostname or leading *. subdomain pattern without a scheme, path, port or IP. Use a value such as api.example.com."
195
+ );
196
+ }
197
+ });
198
+ }
183
199
  function paths(value, error) {
184
200
  const values = stringList(value, "prerender", error, { unique: true });
185
201
  values?.forEach((path, index) => {