perspectapi-ts-sdk 2.0.0 → 2.2.1

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/README.md CHANGED
@@ -148,6 +148,88 @@ export default {
148
148
  ```
149
149
 
150
150
  When PerspectAPI sends a webhook—or when your Worker mutates data directly—call `perspect.cache.invalidate({ tags: [...] })` using the tags emitted by the SDK (`products:site:<site>`, `content:slug:<site>:<slug>`, etc.) so stale entries are purged immediately.
151
+
152
+ ### Webhook-driven cache invalidation
153
+
154
+ Most deployments rely on PerspectAPI webhooks to bust cache entries whenever content changes. Your app needs an HTTP endpoint that:
155
+
156
+ 1. Verifies the webhook signature (or shared secret).
157
+ 2. Maps the webhook payload to the cache tags that were used when reading data.
158
+ 3. Calls `client.cache.invalidate({ tags })`.
159
+
160
+ Below is a minimal Cloudflare Worker handler; the same flow works in Express, Fastify, etc.—just swap the request parsing.
161
+
162
+ ```ts
163
+ // worker.ts
164
+ import { PerspectApiClient } from 'perspectapi-ts-sdk';
165
+
166
+ const kvAdapter = (kv: KVNamespace) => ({
167
+ get: (key: string) => kv.get(key),
168
+ set: (key: string, value: string, options?: { ttlSeconds?: number }) =>
169
+ options?.ttlSeconds
170
+ ? kv.put(key, value, { expirationTtl: options.ttlSeconds })
171
+ : kv.put(key, value),
172
+ delete: (key: string) => kv.delete(key),
173
+ });
174
+
175
+ const perspect = new PerspectApiClient({
176
+ baseUrl: env.PERSPECT_API_URL,
177
+ apiKey: env.PERSPECT_API_KEY,
178
+ cache: { adapter: kvAdapter(env.PERSPECT_CACHE), defaultTtlSeconds: 300 },
179
+ });
180
+
181
+ type WebhookEvent =
182
+ | { type: 'product.updated'; site: string; slug?: string; id?: number }
183
+ | { type: 'content.published'; site: string; slug: string; id: number }
184
+ | { type: 'category.updated'; site: string; slug: string };
185
+
186
+ const tagMap: Record<string, (e: WebhookEvent) => string[]> = {
187
+ 'product.updated': event => [
188
+ `products`,
189
+ `products:site:${event.site}`,
190
+ event.slug ? `products:slug:${event.site}:${event.slug}` : '',
191
+ event.id ? `products:id:${event.id}` : '',
192
+ ],
193
+ 'content.published': event => [
194
+ `content`,
195
+ `content:site:${event.site}`,
196
+ `content:slug:${event.site}:${event.slug}`,
197
+ `content:id:${event.id}`,
198
+ ],
199
+ 'category.updated': event => [
200
+ `categories`,
201
+ `categories:site:${event.site}`,
202
+ `categories:product:${event.site}:${event.slug}`,
203
+ ],
204
+ };
205
+
206
+ export default {
207
+ async fetch(request: Request, env: Env) {
208
+ const url = new URL(request.url);
209
+
210
+ if (url.pathname === '/webhooks/perspect' && request.method === 'POST') {
211
+ const event = (await request.json()) as WebhookEvent;
212
+
213
+ // TODO: validate shared secret / signature before proceeding
214
+
215
+ const buildTags = tagMap[event.type];
216
+ if (buildTags) {
217
+ const tags = buildTags(event).filter(Boolean);
218
+ if (tags.length) {
219
+ await perspect.cache.invalidate({ tags });
220
+ }
221
+ }
222
+
223
+ return new Response(null, { status: 202 });
224
+ }
225
+
226
+ // ...rest of your app
227
+ return new Response('ok');
228
+ },
229
+ };
230
+ ```
231
+
232
+ > 🔁 Adjust the `WebhookEvent` union and `tagMap` to match the actual payloads you receive. PerspectAPI webhooks also carry version IDs and environment metadata that you can use for more granular targeting if needed.
151
233
  ```
152
234
 
153
235
  ## Image Transformations
package/dist/index.d.mts CHANGED
@@ -424,6 +424,34 @@ type CheckoutMetadataValue = string | number | boolean | null | CheckoutMetadata
424
424
  [key: string]: CheckoutMetadataValue;
425
425
  };
426
426
  type CheckoutMetadata = Record<string, CheckoutMetadataValue>;
427
+ type CheckoutTaxStrategy = 'disabled' | 'gateway_auto' | 'manual_rates' | 'external_service';
428
+ type CheckoutTaxExemptionStatus = 'none' | 'exempt' | 'reverse_charge';
429
+ interface CheckoutTaxCustomerExemptionRequest {
430
+ status?: CheckoutTaxExemptionStatus;
431
+ reason?: string;
432
+ tax_id?: string;
433
+ tax_id_type?: string;
434
+ certificate_url?: string;
435
+ metadata?: Record<string, any>;
436
+ expires_at?: string;
437
+ }
438
+ interface CheckoutTaxRequest {
439
+ strategy?: CheckoutTaxStrategy;
440
+ customer_identifier?: string;
441
+ customer_profile_id?: string;
442
+ customer_display_name?: string;
443
+ allow_exemption?: boolean;
444
+ require_tax_id?: boolean;
445
+ save_profile?: boolean;
446
+ customer_exemption?: CheckoutTaxCustomerExemptionRequest;
447
+ manual_rate_percent?: number;
448
+ manual_rate_map?: Record<string, number>;
449
+ external_service?: {
450
+ provider: string;
451
+ config?: Record<string, any>;
452
+ };
453
+ metadata?: Record<string, any>;
454
+ }
427
455
  interface CreateCheckoutSessionRequest {
428
456
  priceId?: string;
429
457
  quantity?: number;
@@ -455,6 +483,7 @@ interface CreateCheckoutSessionRequest {
455
483
  allowed_countries: string[];
456
484
  };
457
485
  billing_address_collection?: 'auto' | 'required';
486
+ tax?: CheckoutTaxRequest;
458
487
  }
459
488
  interface CheckoutSession {
460
489
  id: string;
@@ -2322,6 +2351,10 @@ interface CheckoutSessionOptions {
2322
2351
  * Optional resolver to convert a product record into a Stripe price ID.
2323
2352
  */
2324
2353
  priceIdResolver?: (product: Product, mode: 'live' | 'test') => string | undefined;
2354
+ /**
2355
+ * Optional tax configuration that will be forwarded to the checkout API.
2356
+ */
2357
+ tax?: CheckoutTaxRequest;
2325
2358
  }
2326
2359
  /**
2327
2360
  * Convenience helper that creates a checkout session by looking up Stripe price IDs
@@ -2331,4 +2364,4 @@ declare function createCheckoutSession(options: CheckoutSessionOptions): Promise
2331
2364
  error: string;
2332
2365
  }>;
2333
2366
 
2334
- export { type ApiError, type ApiKey, ApiKeysClient, type ApiResponse, AuthClient, type AuthResponse, BaseClient, type BlogPost, type CacheConfig, CacheManager, CategoriesClient, type Category, CheckoutClient, type CheckoutMetadata, type CheckoutMetadataValue, type CheckoutSession, type CheckoutSessionOptions, ContactClient, type ContactStatusResponse, type ContactSubmission, type ContactSubmitResponse, type Content, ContentClient, type ContentQueryParams, type ContentStatus, type ContentType, type CreateApiKeyRequest, type CreateCategoryRequest, type CreateCheckoutSessionRequest, type CreateContactRequest, type CreateContentRequest, type CreateNewsletterSubscriptionRequest, type CreateOrganizationRequest, type CreatePaymentGatewayRequest, type CreateProductRequest, type CreateSiteRequest, type CreateWebhookRequest, DEFAULT_IMAGE_SIZES, HttpClient, type HttpMethod, type ImageTransformOptions, InMemoryCacheAdapter, type LoadContentBySlugOptions, type LoadContentOptions, type LoadProductBySlugOptions, type LoadProductsOptions, type LoaderLogger, type LoaderOptions, type MediaItem, NewsletterClient, type NewsletterConfirmResponse, type NewsletterList, type NewsletterPreferences, type NewsletterStatusResponse, type NewsletterSubscribeResponse, type NewsletterSubscription, type NewsletterUnsubscribeRequest, type NewsletterUnsubscribeResponse, NoopCacheAdapter, type Organization, OrganizationsClient, type PaginatedResponse, type PaginationParams, type PaymentGateway, PerspectApiClient, type PerspectApiConfig, type Product, type ProductQueryParams, ProductsClient, type RequestOptions, type ResponsiveImageSizes, type SignInRequest, type SignUpRequest, type Site, SitesClient, type UpdateApiKeyRequest, type UpdateContentRequest, type User, type Webhook, WebhooksClient, buildImageUrl, createApiError, createCheckoutSession, createPerspectApiClient, PerspectApiClient as default, generateResponsiveImageHtml, generateResponsiveUrls, generateSizesAttribute, generateSrcSet, loadAllContent, loadContentBySlug, loadPages, loadPosts, loadProductBySlug, loadProducts, transformContent, transformMediaItem, transformProduct };
2367
+ export { type ApiError, type ApiKey, ApiKeysClient, type ApiResponse, AuthClient, type AuthResponse, BaseClient, type BlogPost, type CacheConfig, CacheManager, CategoriesClient, type Category, CheckoutClient, type CheckoutMetadata, type CheckoutMetadataValue, type CheckoutSession, type CheckoutSessionOptions, type CheckoutTaxCustomerExemptionRequest, type CheckoutTaxExemptionStatus, type CheckoutTaxRequest, type CheckoutTaxStrategy, ContactClient, type ContactStatusResponse, type ContactSubmission, type ContactSubmitResponse, type Content, ContentClient, type ContentQueryParams, type ContentStatus, type ContentType, type CreateApiKeyRequest, type CreateCategoryRequest, type CreateCheckoutSessionRequest, type CreateContactRequest, type CreateContentRequest, type CreateNewsletterSubscriptionRequest, type CreateOrganizationRequest, type CreatePaymentGatewayRequest, type CreateProductRequest, type CreateSiteRequest, type CreateWebhookRequest, DEFAULT_IMAGE_SIZES, HttpClient, type HttpMethod, type ImageTransformOptions, InMemoryCacheAdapter, type LoadContentBySlugOptions, type LoadContentOptions, type LoadProductBySlugOptions, type LoadProductsOptions, type LoaderLogger, type LoaderOptions, type MediaItem, NewsletterClient, type NewsletterConfirmResponse, type NewsletterList, type NewsletterPreferences, type NewsletterStatusResponse, type NewsletterSubscribeResponse, type NewsletterSubscription, type NewsletterUnsubscribeRequest, type NewsletterUnsubscribeResponse, NoopCacheAdapter, type Organization, OrganizationsClient, type PaginatedResponse, type PaginationParams, type PaymentGateway, PerspectApiClient, type PerspectApiConfig, type Product, type ProductQueryParams, ProductsClient, type RequestOptions, type ResponsiveImageSizes, type SignInRequest, type SignUpRequest, type Site, SitesClient, type UpdateApiKeyRequest, type UpdateContentRequest, type User, type Webhook, WebhooksClient, buildImageUrl, createApiError, createCheckoutSession, createPerspectApiClient, PerspectApiClient as default, generateResponsiveImageHtml, generateResponsiveUrls, generateSizesAttribute, generateSrcSet, loadAllContent, loadContentBySlug, loadPages, loadPosts, loadProductBySlug, loadProducts, transformContent, transformMediaItem, transformProduct };
package/dist/index.d.ts CHANGED
@@ -424,6 +424,34 @@ type CheckoutMetadataValue = string | number | boolean | null | CheckoutMetadata
424
424
  [key: string]: CheckoutMetadataValue;
425
425
  };
426
426
  type CheckoutMetadata = Record<string, CheckoutMetadataValue>;
427
+ type CheckoutTaxStrategy = 'disabled' | 'gateway_auto' | 'manual_rates' | 'external_service';
428
+ type CheckoutTaxExemptionStatus = 'none' | 'exempt' | 'reverse_charge';
429
+ interface CheckoutTaxCustomerExemptionRequest {
430
+ status?: CheckoutTaxExemptionStatus;
431
+ reason?: string;
432
+ tax_id?: string;
433
+ tax_id_type?: string;
434
+ certificate_url?: string;
435
+ metadata?: Record<string, any>;
436
+ expires_at?: string;
437
+ }
438
+ interface CheckoutTaxRequest {
439
+ strategy?: CheckoutTaxStrategy;
440
+ customer_identifier?: string;
441
+ customer_profile_id?: string;
442
+ customer_display_name?: string;
443
+ allow_exemption?: boolean;
444
+ require_tax_id?: boolean;
445
+ save_profile?: boolean;
446
+ customer_exemption?: CheckoutTaxCustomerExemptionRequest;
447
+ manual_rate_percent?: number;
448
+ manual_rate_map?: Record<string, number>;
449
+ external_service?: {
450
+ provider: string;
451
+ config?: Record<string, any>;
452
+ };
453
+ metadata?: Record<string, any>;
454
+ }
427
455
  interface CreateCheckoutSessionRequest {
428
456
  priceId?: string;
429
457
  quantity?: number;
@@ -455,6 +483,7 @@ interface CreateCheckoutSessionRequest {
455
483
  allowed_countries: string[];
456
484
  };
457
485
  billing_address_collection?: 'auto' | 'required';
486
+ tax?: CheckoutTaxRequest;
458
487
  }
459
488
  interface CheckoutSession {
460
489
  id: string;
@@ -2322,6 +2351,10 @@ interface CheckoutSessionOptions {
2322
2351
  * Optional resolver to convert a product record into a Stripe price ID.
2323
2352
  */
2324
2353
  priceIdResolver?: (product: Product, mode: 'live' | 'test') => string | undefined;
2354
+ /**
2355
+ * Optional tax configuration that will be forwarded to the checkout API.
2356
+ */
2357
+ tax?: CheckoutTaxRequest;
2325
2358
  }
2326
2359
  /**
2327
2360
  * Convenience helper that creates a checkout session by looking up Stripe price IDs
@@ -2331,4 +2364,4 @@ declare function createCheckoutSession(options: CheckoutSessionOptions): Promise
2331
2364
  error: string;
2332
2365
  }>;
2333
2366
 
2334
- export { type ApiError, type ApiKey, ApiKeysClient, type ApiResponse, AuthClient, type AuthResponse, BaseClient, type BlogPost, type CacheConfig, CacheManager, CategoriesClient, type Category, CheckoutClient, type CheckoutMetadata, type CheckoutMetadataValue, type CheckoutSession, type CheckoutSessionOptions, ContactClient, type ContactStatusResponse, type ContactSubmission, type ContactSubmitResponse, type Content, ContentClient, type ContentQueryParams, type ContentStatus, type ContentType, type CreateApiKeyRequest, type CreateCategoryRequest, type CreateCheckoutSessionRequest, type CreateContactRequest, type CreateContentRequest, type CreateNewsletterSubscriptionRequest, type CreateOrganizationRequest, type CreatePaymentGatewayRequest, type CreateProductRequest, type CreateSiteRequest, type CreateWebhookRequest, DEFAULT_IMAGE_SIZES, HttpClient, type HttpMethod, type ImageTransformOptions, InMemoryCacheAdapter, type LoadContentBySlugOptions, type LoadContentOptions, type LoadProductBySlugOptions, type LoadProductsOptions, type LoaderLogger, type LoaderOptions, type MediaItem, NewsletterClient, type NewsletterConfirmResponse, type NewsletterList, type NewsletterPreferences, type NewsletterStatusResponse, type NewsletterSubscribeResponse, type NewsletterSubscription, type NewsletterUnsubscribeRequest, type NewsletterUnsubscribeResponse, NoopCacheAdapter, type Organization, OrganizationsClient, type PaginatedResponse, type PaginationParams, type PaymentGateway, PerspectApiClient, type PerspectApiConfig, type Product, type ProductQueryParams, ProductsClient, type RequestOptions, type ResponsiveImageSizes, type SignInRequest, type SignUpRequest, type Site, SitesClient, type UpdateApiKeyRequest, type UpdateContentRequest, type User, type Webhook, WebhooksClient, buildImageUrl, createApiError, createCheckoutSession, createPerspectApiClient, PerspectApiClient as default, generateResponsiveImageHtml, generateResponsiveUrls, generateSizesAttribute, generateSrcSet, loadAllContent, loadContentBySlug, loadPages, loadPosts, loadProductBySlug, loadProducts, transformContent, transformMediaItem, transformProduct };
2367
+ export { type ApiError, type ApiKey, ApiKeysClient, type ApiResponse, AuthClient, type AuthResponse, BaseClient, type BlogPost, type CacheConfig, CacheManager, CategoriesClient, type Category, CheckoutClient, type CheckoutMetadata, type CheckoutMetadataValue, type CheckoutSession, type CheckoutSessionOptions, type CheckoutTaxCustomerExemptionRequest, type CheckoutTaxExemptionStatus, type CheckoutTaxRequest, type CheckoutTaxStrategy, ContactClient, type ContactStatusResponse, type ContactSubmission, type ContactSubmitResponse, type Content, ContentClient, type ContentQueryParams, type ContentStatus, type ContentType, type CreateApiKeyRequest, type CreateCategoryRequest, type CreateCheckoutSessionRequest, type CreateContactRequest, type CreateContentRequest, type CreateNewsletterSubscriptionRequest, type CreateOrganizationRequest, type CreatePaymentGatewayRequest, type CreateProductRequest, type CreateSiteRequest, type CreateWebhookRequest, DEFAULT_IMAGE_SIZES, HttpClient, type HttpMethod, type ImageTransformOptions, InMemoryCacheAdapter, type LoadContentBySlugOptions, type LoadContentOptions, type LoadProductBySlugOptions, type LoadProductsOptions, type LoaderLogger, type LoaderOptions, type MediaItem, NewsletterClient, type NewsletterConfirmResponse, type NewsletterList, type NewsletterPreferences, type NewsletterStatusResponse, type NewsletterSubscribeResponse, type NewsletterSubscription, type NewsletterUnsubscribeRequest, type NewsletterUnsubscribeResponse, NoopCacheAdapter, type Organization, OrganizationsClient, type PaginatedResponse, type PaginationParams, type PaymentGateway, PerspectApiClient, type PerspectApiConfig, type Product, type ProductQueryParams, ProductsClient, type RequestOptions, type ResponsiveImageSizes, type SignInRequest, type SignUpRequest, type Site, SitesClient, type UpdateApiKeyRequest, type UpdateContentRequest, type User, type Webhook, WebhooksClient, buildImageUrl, createApiError, createCheckoutSession, createPerspectApiClient, PerspectApiClient as default, generateResponsiveImageHtml, generateResponsiveUrls, generateSizesAttribute, generateSrcSet, loadAllContent, loadContentBySlug, loadPages, loadPosts, loadProductBySlug, loadProducts, transformContent, transformMediaItem, transformProduct };
package/dist/index.js CHANGED
@@ -354,6 +354,7 @@ var CacheManager = class {
354
354
  }
355
355
  async getOrSet(key, resolveValue, policy) {
356
356
  if (!this.enabled || policy?.skipCache) {
357
+ console.log("[Cache] Cache disabled or skipped", { key, enabled: this.enabled, skipCache: policy?.skipCache });
357
358
  const value2 = await resolveValue();
358
359
  if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
359
360
  await this.set(key, value2, policy);
@@ -365,12 +366,16 @@ var CacheManager = class {
365
366
  if (cachedRaw) {
366
367
  const entry = this.deserialize(cachedRaw);
367
368
  if (!entry.expiresAt || entry.expiresAt > Date.now()) {
369
+ console.log("[Cache] \u2713 HIT", { key, tags: entry.tags });
368
370
  return entry.value;
369
371
  }
372
+ console.log("[Cache] \u2717 EXPIRED", { key, expiresAt: new Date(entry.expiresAt) });
370
373
  await this.adapter.delete(namespacedKey);
371
374
  if (entry.tags?.length) {
372
375
  await this.removeKeyFromTags(namespacedKey, entry.tags);
373
376
  }
377
+ } else {
378
+ console.log("[Cache] \u2717 MISS", { key });
374
379
  }
375
380
  const value = await resolveValue();
376
381
  await this.set(key, value, policy);
@@ -388,6 +393,7 @@ var CacheManager = class {
388
393
  tags: options?.tags,
389
394
  metadata: options?.metadata
390
395
  };
396
+ console.log("[Cache] SET", { key, ttlSeconds, tags: options?.tags });
391
397
  await this.adapter.set(namespacedKey, this.serialize(entry), {
392
398
  ttlSeconds: ttlSeconds > 0 ? ttlSeconds : void 0
393
399
  });
@@ -406,34 +412,42 @@ var CacheManager = class {
406
412
  if (!this.enabled) {
407
413
  return;
408
414
  }
415
+ let totalInvalidated = 0;
409
416
  if (options.keys?.length) {
410
417
  const namespacedKeys = options.keys.map((key) => this.namespacedKey(key));
418
+ console.log("[Cache] INVALIDATE by keys", { count: options.keys.length, keys: options.keys });
411
419
  if (this.adapter.deleteMany) {
412
420
  await this.adapter.deleteMany(namespacedKeys);
413
421
  } else {
414
422
  await Promise.all(namespacedKeys.map((key) => this.adapter.delete(key)));
415
423
  }
424
+ totalInvalidated += options.keys.length;
416
425
  }
417
426
  if (options.tags?.length) {
427
+ console.log("[Cache] INVALIDATE by tags", { tags: options.tags });
418
428
  await Promise.all(
419
429
  options.tags.map(async (tag) => {
420
430
  const tagKey = this.tagKey(tag);
421
431
  const payload = await this.adapter.get(tagKey);
422
432
  if (!payload) {
433
+ console.log("[Cache] No entries for tag", { tag });
423
434
  return;
424
435
  }
425
436
  const keys = this.deserializeTagSet(payload);
426
437
  if (keys.length) {
438
+ console.log("[Cache] Invalidating entries for tag", { tag, count: keys.length });
427
439
  if (this.adapter.deleteMany) {
428
440
  await this.adapter.deleteMany(keys);
429
441
  } else {
430
442
  await Promise.all(keys.map((key) => this.adapter.delete(key)));
431
443
  }
444
+ totalInvalidated += keys.length;
432
445
  }
433
446
  await this.adapter.delete(tagKey);
434
447
  })
435
448
  );
436
449
  }
450
+ console.log("[Cache] \u2713 INVALIDATED", { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
437
451
  }
438
452
  namespacedKey(key) {
439
453
  return `${this.keyPrefix}:${key}`;
@@ -715,6 +729,43 @@ var AuthClient = class extends BaseClient {
715
729
  }
716
730
  };
717
731
 
732
+ // src/utils/validators.ts
733
+ var MAX_API_QUERY_LIMIT = 100;
734
+ var ALLOWED_CONTENT_TYPES = ["post", "page"];
735
+ function validateLimit(limit, context) {
736
+ if (typeof limit !== "number" || Number.isNaN(limit) || !Number.isFinite(limit)) {
737
+ throw new Error(`[PerspectAPI] ${context} limit must be a finite number.`);
738
+ }
739
+ if (limit < 1) {
740
+ throw new Error(`[PerspectAPI] ${context} limit must be at least 1.`);
741
+ }
742
+ if (limit > MAX_API_QUERY_LIMIT) {
743
+ throw new Error(
744
+ `[PerspectAPI] ${context} limit ${limit} exceeds the maximum allowed value of ${MAX_API_QUERY_LIMIT}.`
745
+ );
746
+ }
747
+ return limit;
748
+ }
749
+ function validateOptionalLimit(limit, context) {
750
+ if (limit === void 0 || limit === null) {
751
+ return void 0;
752
+ }
753
+ return validateLimit(limit, context);
754
+ }
755
+ function validateOptionalContentType(pageType, context) {
756
+ if (pageType === void 0 || pageType === null) {
757
+ return void 0;
758
+ }
759
+ if (ALLOWED_CONTENT_TYPES.includes(pageType)) {
760
+ return pageType;
761
+ }
762
+ throw new Error(
763
+ `[PerspectAPI] ${context} received unsupported page_type "${pageType}". Allowed values are ${ALLOWED_CONTENT_TYPES.join(
764
+ ", "
765
+ )}.`
766
+ );
767
+ }
768
+
718
769
  // src/client/content-client.ts
719
770
  var ContentClient = class extends BaseClient {
720
771
  constructor(http, cache) {
@@ -726,12 +777,33 @@ var ContentClient = class extends BaseClient {
726
777
  async getContent(siteName, params, cachePolicy) {
727
778
  const endpoint = this.siteScopedEndpoint(siteName);
728
779
  const path = this.buildPath(endpoint);
780
+ const normalizedParams = params ? { ...params } : void 0;
781
+ if (normalizedParams) {
782
+ const validatedLimit = validateOptionalLimit(
783
+ normalizedParams.limit,
784
+ "content query"
785
+ );
786
+ if (validatedLimit !== void 0) {
787
+ normalizedParams.limit = validatedLimit;
788
+ } else {
789
+ delete normalizedParams.limit;
790
+ }
791
+ const validatedPageType = validateOptionalContentType(
792
+ normalizedParams.page_type,
793
+ "content query"
794
+ );
795
+ if (validatedPageType !== void 0) {
796
+ normalizedParams.page_type = validatedPageType;
797
+ } else {
798
+ delete normalizedParams.page_type;
799
+ }
800
+ }
729
801
  return this.fetchWithCache(
730
802
  endpoint,
731
- params,
803
+ normalizedParams,
732
804
  this.buildContentTags(siteName),
733
805
  cachePolicy,
734
- () => this.http.get(path, params)
806
+ () => this.http.get(path, normalizedParams)
735
807
  );
736
808
  }
737
809
  /**
@@ -1093,6 +1165,15 @@ var ProductsClient = class extends BaseClient {
1093
1165
  };
1094
1166
  const normalizedParams = params ? { ...params } : void 0;
1095
1167
  if (normalizedParams) {
1168
+ const validatedLimit = validateOptionalLimit(
1169
+ normalizedParams.limit,
1170
+ "products query"
1171
+ );
1172
+ if (validatedLimit !== void 0) {
1173
+ normalizedParams.limit = validatedLimit;
1174
+ } else {
1175
+ delete normalizedParams.limit;
1176
+ }
1096
1177
  const normalizedCategories = normalizeList(normalizedParams.category);
1097
1178
  if (normalizedCategories !== void 0) {
1098
1179
  normalizedParams.category = normalizedCategories;
@@ -2308,12 +2389,16 @@ async function loadProducts(options) {
2308
2389
  siteName,
2309
2390
  logger = noopLogger,
2310
2391
  fallbackProducts,
2311
- limit = 100,
2392
+ limit: requestedLimit,
2312
2393
  offset,
2313
2394
  search,
2314
2395
  category,
2315
2396
  categoryIds
2316
2397
  } = options;
2398
+ const resolvedLimit = validateLimit(
2399
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
2400
+ "products query"
2401
+ );
2317
2402
  if (!client) {
2318
2403
  log(logger, "warn", "[PerspectAPI] No client configured, using fallback products");
2319
2404
  return resolveFallbackProducts({ siteName, fallbackProducts });
@@ -2322,7 +2407,7 @@ async function loadProducts(options) {
2322
2407
  log(logger, "info", `[PerspectAPI] Loading products for site "${siteName}"`);
2323
2408
  const queryParams = {
2324
2409
  isActive: true,
2325
- limit,
2410
+ limit: resolvedLimit,
2326
2411
  offset,
2327
2412
  search
2328
2413
  };
@@ -2382,13 +2467,20 @@ async function loadPages(options) {
2382
2467
  }
2383
2468
  try {
2384
2469
  log(logger, "info", `[PerspectAPI] Loading pages for site "${siteName}"`);
2470
+ const requestedLimit = options.limit !== void 0 ? options.limit : params?.limit;
2471
+ const resolvedLimit = validateLimit(
2472
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
2473
+ "pages query"
2474
+ );
2475
+ const requestedPageType = params?.page_type;
2476
+ const validatedPageType = validateOptionalContentType(requestedPageType, "pages query") ?? "page";
2385
2477
  const response = await client.content.getContent(
2386
2478
  siteName,
2387
2479
  {
2388
2480
  ...params,
2389
2481
  page_status: params?.page_status ?? "publish",
2390
- page_type: params?.page_type ?? "page",
2391
- limit: options.limit ?? params?.limit ?? 100
2482
+ page_type: validatedPageType,
2483
+ limit: resolvedLimit
2392
2484
  }
2393
2485
  );
2394
2486
  if (!response.data) {
@@ -2408,13 +2500,20 @@ async function loadPosts(options) {
2408
2500
  }
2409
2501
  try {
2410
2502
  log(logger, "info", `[PerspectAPI] Loading posts for site "${siteName}"`);
2503
+ const requestedLimit = options.limit !== void 0 ? options.limit : params?.limit;
2504
+ const resolvedLimit = validateLimit(
2505
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
2506
+ "posts query"
2507
+ );
2508
+ const requestedPageType = params?.page_type;
2509
+ const validatedPageType = validateOptionalContentType(requestedPageType, "posts query") ?? "post";
2411
2510
  const response = await client.content.getContent(
2412
2511
  siteName,
2413
2512
  {
2414
2513
  ...params,
2415
2514
  page_status: params?.page_status ?? "publish",
2416
- page_type: params?.page_type ?? "post",
2417
- limit: options.limit ?? params?.limit ?? 100
2515
+ page_type: validatedPageType,
2516
+ limit: resolvedLimit
2418
2517
  }
2419
2518
  );
2420
2519
  if (!response.data) {
@@ -2474,7 +2573,8 @@ async function createCheckoutSession(options) {
2474
2573
  logger = noopLogger,
2475
2574
  fallbackProducts,
2476
2575
  metadata,
2477
- priceIdResolver
2576
+ priceIdResolver,
2577
+ tax
2478
2578
  } = options;
2479
2579
  if (!client) {
2480
2580
  log(logger, "error", "[PerspectAPI] Cannot create checkout session without SDK client");
@@ -2485,7 +2585,7 @@ async function createCheckoutSession(options) {
2485
2585
  client,
2486
2586
  siteName,
2487
2587
  logger,
2488
- limit: 200,
2588
+ limit: Math.min(Math.max(items.length, 1), MAX_API_QUERY_LIMIT),
2489
2589
  fallbackProducts
2490
2590
  });
2491
2591
  const productMap = new Map(
@@ -2505,6 +2605,18 @@ async function createCheckoutSession(options) {
2505
2605
  quantity: item.quantity
2506
2606
  };
2507
2607
  });
2608
+ const resolvedTax = (() => {
2609
+ if (!tax && !customerEmail) {
2610
+ return void 0;
2611
+ }
2612
+ if (!tax) {
2613
+ return { customer_identifier: customerEmail };
2614
+ }
2615
+ return {
2616
+ ...tax,
2617
+ customer_identifier: tax.customer_identifier ?? customerEmail ?? void 0
2618
+ };
2619
+ })();
2508
2620
  const checkoutData = {
2509
2621
  line_items,
2510
2622
  successUrl,
@@ -2516,6 +2628,9 @@ async function createCheckoutSession(options) {
2516
2628
  mode: mode === "test" ? "payment" : "payment",
2517
2629
  metadata
2518
2630
  };
2631
+ if (resolvedTax) {
2632
+ checkoutData.tax = resolvedTax;
2633
+ }
2519
2634
  log(logger, "info", "[PerspectAPI] Creating checkout session");
2520
2635
  return client.checkout.createCheckoutSession(siteName, checkoutData);
2521
2636
  } catch (error) {
package/dist/index.mjs CHANGED
@@ -293,6 +293,7 @@ var CacheManager = class {
293
293
  }
294
294
  async getOrSet(key, resolveValue, policy) {
295
295
  if (!this.enabled || policy?.skipCache) {
296
+ console.log("[Cache] Cache disabled or skipped", { key, enabled: this.enabled, skipCache: policy?.skipCache });
296
297
  const value2 = await resolveValue();
297
298
  if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
298
299
  await this.set(key, value2, policy);
@@ -304,12 +305,16 @@ var CacheManager = class {
304
305
  if (cachedRaw) {
305
306
  const entry = this.deserialize(cachedRaw);
306
307
  if (!entry.expiresAt || entry.expiresAt > Date.now()) {
308
+ console.log("[Cache] \u2713 HIT", { key, tags: entry.tags });
307
309
  return entry.value;
308
310
  }
311
+ console.log("[Cache] \u2717 EXPIRED", { key, expiresAt: new Date(entry.expiresAt) });
309
312
  await this.adapter.delete(namespacedKey);
310
313
  if (entry.tags?.length) {
311
314
  await this.removeKeyFromTags(namespacedKey, entry.tags);
312
315
  }
316
+ } else {
317
+ console.log("[Cache] \u2717 MISS", { key });
313
318
  }
314
319
  const value = await resolveValue();
315
320
  await this.set(key, value, policy);
@@ -327,6 +332,7 @@ var CacheManager = class {
327
332
  tags: options?.tags,
328
333
  metadata: options?.metadata
329
334
  };
335
+ console.log("[Cache] SET", { key, ttlSeconds, tags: options?.tags });
330
336
  await this.adapter.set(namespacedKey, this.serialize(entry), {
331
337
  ttlSeconds: ttlSeconds > 0 ? ttlSeconds : void 0
332
338
  });
@@ -345,34 +351,42 @@ var CacheManager = class {
345
351
  if (!this.enabled) {
346
352
  return;
347
353
  }
354
+ let totalInvalidated = 0;
348
355
  if (options.keys?.length) {
349
356
  const namespacedKeys = options.keys.map((key) => this.namespacedKey(key));
357
+ console.log("[Cache] INVALIDATE by keys", { count: options.keys.length, keys: options.keys });
350
358
  if (this.adapter.deleteMany) {
351
359
  await this.adapter.deleteMany(namespacedKeys);
352
360
  } else {
353
361
  await Promise.all(namespacedKeys.map((key) => this.adapter.delete(key)));
354
362
  }
363
+ totalInvalidated += options.keys.length;
355
364
  }
356
365
  if (options.tags?.length) {
366
+ console.log("[Cache] INVALIDATE by tags", { tags: options.tags });
357
367
  await Promise.all(
358
368
  options.tags.map(async (tag) => {
359
369
  const tagKey = this.tagKey(tag);
360
370
  const payload = await this.adapter.get(tagKey);
361
371
  if (!payload) {
372
+ console.log("[Cache] No entries for tag", { tag });
362
373
  return;
363
374
  }
364
375
  const keys = this.deserializeTagSet(payload);
365
376
  if (keys.length) {
377
+ console.log("[Cache] Invalidating entries for tag", { tag, count: keys.length });
366
378
  if (this.adapter.deleteMany) {
367
379
  await this.adapter.deleteMany(keys);
368
380
  } else {
369
381
  await Promise.all(keys.map((key) => this.adapter.delete(key)));
370
382
  }
383
+ totalInvalidated += keys.length;
371
384
  }
372
385
  await this.adapter.delete(tagKey);
373
386
  })
374
387
  );
375
388
  }
389
+ console.log("[Cache] \u2713 INVALIDATED", { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
376
390
  }
377
391
  namespacedKey(key) {
378
392
  return `${this.keyPrefix}:${key}`;
@@ -654,6 +668,43 @@ var AuthClient = class extends BaseClient {
654
668
  }
655
669
  };
656
670
 
671
+ // src/utils/validators.ts
672
+ var MAX_API_QUERY_LIMIT = 100;
673
+ var ALLOWED_CONTENT_TYPES = ["post", "page"];
674
+ function validateLimit(limit, context) {
675
+ if (typeof limit !== "number" || Number.isNaN(limit) || !Number.isFinite(limit)) {
676
+ throw new Error(`[PerspectAPI] ${context} limit must be a finite number.`);
677
+ }
678
+ if (limit < 1) {
679
+ throw new Error(`[PerspectAPI] ${context} limit must be at least 1.`);
680
+ }
681
+ if (limit > MAX_API_QUERY_LIMIT) {
682
+ throw new Error(
683
+ `[PerspectAPI] ${context} limit ${limit} exceeds the maximum allowed value of ${MAX_API_QUERY_LIMIT}.`
684
+ );
685
+ }
686
+ return limit;
687
+ }
688
+ function validateOptionalLimit(limit, context) {
689
+ if (limit === void 0 || limit === null) {
690
+ return void 0;
691
+ }
692
+ return validateLimit(limit, context);
693
+ }
694
+ function validateOptionalContentType(pageType, context) {
695
+ if (pageType === void 0 || pageType === null) {
696
+ return void 0;
697
+ }
698
+ if (ALLOWED_CONTENT_TYPES.includes(pageType)) {
699
+ return pageType;
700
+ }
701
+ throw new Error(
702
+ `[PerspectAPI] ${context} received unsupported page_type "${pageType}". Allowed values are ${ALLOWED_CONTENT_TYPES.join(
703
+ ", "
704
+ )}.`
705
+ );
706
+ }
707
+
657
708
  // src/client/content-client.ts
658
709
  var ContentClient = class extends BaseClient {
659
710
  constructor(http, cache) {
@@ -665,12 +716,33 @@ var ContentClient = class extends BaseClient {
665
716
  async getContent(siteName, params, cachePolicy) {
666
717
  const endpoint = this.siteScopedEndpoint(siteName);
667
718
  const path = this.buildPath(endpoint);
719
+ const normalizedParams = params ? { ...params } : void 0;
720
+ if (normalizedParams) {
721
+ const validatedLimit = validateOptionalLimit(
722
+ normalizedParams.limit,
723
+ "content query"
724
+ );
725
+ if (validatedLimit !== void 0) {
726
+ normalizedParams.limit = validatedLimit;
727
+ } else {
728
+ delete normalizedParams.limit;
729
+ }
730
+ const validatedPageType = validateOptionalContentType(
731
+ normalizedParams.page_type,
732
+ "content query"
733
+ );
734
+ if (validatedPageType !== void 0) {
735
+ normalizedParams.page_type = validatedPageType;
736
+ } else {
737
+ delete normalizedParams.page_type;
738
+ }
739
+ }
668
740
  return this.fetchWithCache(
669
741
  endpoint,
670
- params,
742
+ normalizedParams,
671
743
  this.buildContentTags(siteName),
672
744
  cachePolicy,
673
- () => this.http.get(path, params)
745
+ () => this.http.get(path, normalizedParams)
674
746
  );
675
747
  }
676
748
  /**
@@ -1032,6 +1104,15 @@ var ProductsClient = class extends BaseClient {
1032
1104
  };
1033
1105
  const normalizedParams = params ? { ...params } : void 0;
1034
1106
  if (normalizedParams) {
1107
+ const validatedLimit = validateOptionalLimit(
1108
+ normalizedParams.limit,
1109
+ "products query"
1110
+ );
1111
+ if (validatedLimit !== void 0) {
1112
+ normalizedParams.limit = validatedLimit;
1113
+ } else {
1114
+ delete normalizedParams.limit;
1115
+ }
1035
1116
  const normalizedCategories = normalizeList(normalizedParams.category);
1036
1117
  if (normalizedCategories !== void 0) {
1037
1118
  normalizedParams.category = normalizedCategories;
@@ -2247,12 +2328,16 @@ async function loadProducts(options) {
2247
2328
  siteName,
2248
2329
  logger = noopLogger,
2249
2330
  fallbackProducts,
2250
- limit = 100,
2331
+ limit: requestedLimit,
2251
2332
  offset,
2252
2333
  search,
2253
2334
  category,
2254
2335
  categoryIds
2255
2336
  } = options;
2337
+ const resolvedLimit = validateLimit(
2338
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
2339
+ "products query"
2340
+ );
2256
2341
  if (!client) {
2257
2342
  log(logger, "warn", "[PerspectAPI] No client configured, using fallback products");
2258
2343
  return resolveFallbackProducts({ siteName, fallbackProducts });
@@ -2261,7 +2346,7 @@ async function loadProducts(options) {
2261
2346
  log(logger, "info", `[PerspectAPI] Loading products for site "${siteName}"`);
2262
2347
  const queryParams = {
2263
2348
  isActive: true,
2264
- limit,
2349
+ limit: resolvedLimit,
2265
2350
  offset,
2266
2351
  search
2267
2352
  };
@@ -2321,13 +2406,20 @@ async function loadPages(options) {
2321
2406
  }
2322
2407
  try {
2323
2408
  log(logger, "info", `[PerspectAPI] Loading pages for site "${siteName}"`);
2409
+ const requestedLimit = options.limit !== void 0 ? options.limit : params?.limit;
2410
+ const resolvedLimit = validateLimit(
2411
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
2412
+ "pages query"
2413
+ );
2414
+ const requestedPageType = params?.page_type;
2415
+ const validatedPageType = validateOptionalContentType(requestedPageType, "pages query") ?? "page";
2324
2416
  const response = await client.content.getContent(
2325
2417
  siteName,
2326
2418
  {
2327
2419
  ...params,
2328
2420
  page_status: params?.page_status ?? "publish",
2329
- page_type: params?.page_type ?? "page",
2330
- limit: options.limit ?? params?.limit ?? 100
2421
+ page_type: validatedPageType,
2422
+ limit: resolvedLimit
2331
2423
  }
2332
2424
  );
2333
2425
  if (!response.data) {
@@ -2347,13 +2439,20 @@ async function loadPosts(options) {
2347
2439
  }
2348
2440
  try {
2349
2441
  log(logger, "info", `[PerspectAPI] Loading posts for site "${siteName}"`);
2442
+ const requestedLimit = options.limit !== void 0 ? options.limit : params?.limit;
2443
+ const resolvedLimit = validateLimit(
2444
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
2445
+ "posts query"
2446
+ );
2447
+ const requestedPageType = params?.page_type;
2448
+ const validatedPageType = validateOptionalContentType(requestedPageType, "posts query") ?? "post";
2350
2449
  const response = await client.content.getContent(
2351
2450
  siteName,
2352
2451
  {
2353
2452
  ...params,
2354
2453
  page_status: params?.page_status ?? "publish",
2355
- page_type: params?.page_type ?? "post",
2356
- limit: options.limit ?? params?.limit ?? 100
2454
+ page_type: validatedPageType,
2455
+ limit: resolvedLimit
2357
2456
  }
2358
2457
  );
2359
2458
  if (!response.data) {
@@ -2413,7 +2512,8 @@ async function createCheckoutSession(options) {
2413
2512
  logger = noopLogger,
2414
2513
  fallbackProducts,
2415
2514
  metadata,
2416
- priceIdResolver
2515
+ priceIdResolver,
2516
+ tax
2417
2517
  } = options;
2418
2518
  if (!client) {
2419
2519
  log(logger, "error", "[PerspectAPI] Cannot create checkout session without SDK client");
@@ -2424,7 +2524,7 @@ async function createCheckoutSession(options) {
2424
2524
  client,
2425
2525
  siteName,
2426
2526
  logger,
2427
- limit: 200,
2527
+ limit: Math.min(Math.max(items.length, 1), MAX_API_QUERY_LIMIT),
2428
2528
  fallbackProducts
2429
2529
  });
2430
2530
  const productMap = new Map(
@@ -2444,6 +2544,18 @@ async function createCheckoutSession(options) {
2444
2544
  quantity: item.quantity
2445
2545
  };
2446
2546
  });
2547
+ const resolvedTax = (() => {
2548
+ if (!tax && !customerEmail) {
2549
+ return void 0;
2550
+ }
2551
+ if (!tax) {
2552
+ return { customer_identifier: customerEmail };
2553
+ }
2554
+ return {
2555
+ ...tax,
2556
+ customer_identifier: tax.customer_identifier ?? customerEmail ?? void 0
2557
+ };
2558
+ })();
2447
2559
  const checkoutData = {
2448
2560
  line_items,
2449
2561
  successUrl,
@@ -2455,6 +2567,9 @@ async function createCheckoutSession(options) {
2455
2567
  mode: mode === "test" ? "payment" : "payment",
2456
2568
  metadata
2457
2569
  };
2570
+ if (resolvedTax) {
2571
+ checkoutData.tax = resolvedTax;
2572
+ }
2458
2573
  log(logger, "info", "[PerspectAPI] Creating checkout session");
2459
2574
  return client.checkout.createCheckoutSession(siteName, checkoutData);
2460
2575
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perspectapi-ts-sdk",
3
- "version": "2.0.0",
3
+ "version": "2.2.1",
4
4
  "description": "TypeScript SDK for PerspectAPI - Cloudflare Workers compatible",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -75,6 +75,7 @@ export class CacheManager {
75
75
  policy?: CachePolicy
76
76
  ): Promise<T> {
77
77
  if (!this.enabled || policy?.skipCache) {
78
+ console.log('[Cache] Cache disabled or skipped', { key, enabled: this.enabled, skipCache: policy?.skipCache });
78
79
  const value = await resolveValue();
79
80
 
80
81
  if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
@@ -91,14 +92,18 @@ export class CacheManager {
91
92
  const entry = this.deserialize<T>(cachedRaw);
92
93
 
93
94
  if (!entry.expiresAt || entry.expiresAt > Date.now()) {
95
+ console.log('[Cache] ✓ HIT', { key, tags: entry.tags });
94
96
  return entry.value;
95
97
  }
96
98
 
97
99
  // Expired entry
100
+ console.log('[Cache] ✗ EXPIRED', { key, expiresAt: new Date(entry.expiresAt) });
98
101
  await this.adapter.delete(namespacedKey);
99
102
  if (entry.tags?.length) {
100
103
  await this.removeKeyFromTags(namespacedKey, entry.tags);
101
104
  }
105
+ } else {
106
+ console.log('[Cache] ✗ MISS', { key });
102
107
  }
103
108
 
104
109
  const value = await resolveValue();
@@ -122,6 +127,8 @@ export class CacheManager {
122
127
  metadata: options?.metadata,
123
128
  };
124
129
 
130
+ console.log('[Cache] SET', { key, ttlSeconds, tags: options?.tags });
131
+
125
132
  await this.adapter.set(namespacedKey, this.serialize(entry), {
126
133
  ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined,
127
134
  });
@@ -144,37 +151,47 @@ export class CacheManager {
144
151
  return;
145
152
  }
146
153
 
154
+ let totalInvalidated = 0;
155
+
147
156
  if (options.keys?.length) {
148
157
  const namespacedKeys = options.keys.map(key => this.namespacedKey(key));
158
+ console.log('[Cache] INVALIDATE by keys', { count: options.keys.length, keys: options.keys });
149
159
  if (this.adapter.deleteMany) {
150
160
  await this.adapter.deleteMany(namespacedKeys);
151
161
  } else {
152
162
  await Promise.all(namespacedKeys.map(key => this.adapter.delete(key)));
153
163
  }
164
+ totalInvalidated += options.keys.length;
154
165
  }
155
166
 
156
167
  if (options.tags?.length) {
168
+ console.log('[Cache] INVALIDATE by tags', { tags: options.tags });
157
169
  await Promise.all(
158
170
  options.tags.map(async tag => {
159
171
  const tagKey = this.tagKey(tag);
160
172
  const payload = await this.adapter.get(tagKey);
161
173
  if (!payload) {
174
+ console.log('[Cache] No entries for tag', { tag });
162
175
  return;
163
176
  }
164
177
 
165
178
  const keys = this.deserializeTagSet(payload);
166
179
  if (keys.length) {
180
+ console.log('[Cache] Invalidating entries for tag', { tag, count: keys.length });
167
181
  if (this.adapter.deleteMany) {
168
182
  await this.adapter.deleteMany(keys);
169
183
  } else {
170
184
  await Promise.all(keys.map(key => this.adapter.delete(key)));
171
185
  }
186
+ totalInvalidated += keys.length;
172
187
  }
173
188
 
174
189
  await this.adapter.delete(tagKey);
175
190
  })
176
191
  );
177
192
  }
193
+
194
+ console.log('[Cache] ✓ INVALIDATED', { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
178
195
  }
179
196
 
180
197
  private namespacedKey(key: string): string {
@@ -13,6 +13,7 @@ import type {
13
13
  PaginatedResponse,
14
14
  ApiResponse,
15
15
  } from '../types';
16
+ import { validateOptionalContentType, validateOptionalLimit } from '../utils/validators';
16
17
 
17
18
  export class ContentClient extends BaseClient {
18
19
  constructor(http: any, cache?: CacheManager) {
@@ -29,13 +30,38 @@ export class ContentClient extends BaseClient {
29
30
  ): Promise<PaginatedResponse<Content>> {
30
31
  const endpoint = this.siteScopedEndpoint(siteName);
31
32
  const path = this.buildPath(endpoint);
33
+ const normalizedParams: ContentQueryParams | undefined = params
34
+ ? { ...params }
35
+ : undefined;
36
+
37
+ if (normalizedParams) {
38
+ const validatedLimit = validateOptionalLimit(
39
+ normalizedParams.limit,
40
+ 'content query',
41
+ );
42
+ if (validatedLimit !== undefined) {
43
+ normalizedParams.limit = validatedLimit;
44
+ } else {
45
+ delete normalizedParams.limit;
46
+ }
47
+
48
+ const validatedPageType = validateOptionalContentType(
49
+ normalizedParams.page_type,
50
+ 'content query',
51
+ );
52
+ if (validatedPageType !== undefined) {
53
+ normalizedParams.page_type = validatedPageType;
54
+ } else {
55
+ delete normalizedParams.page_type;
56
+ }
57
+ }
32
58
 
33
59
  return this.fetchWithCache<PaginatedResponse<Content>>(
34
60
  endpoint,
35
- params,
61
+ normalizedParams,
36
62
  this.buildContentTags(siteName),
37
63
  cachePolicy,
38
- () => this.http.get(path, params) as Promise<PaginatedResponse<Content>>
64
+ () => this.http.get(path, normalizedParams) as Promise<PaginatedResponse<Content>>
39
65
  );
40
66
  }
41
67
 
@@ -12,6 +12,7 @@ import type {
12
12
  ApiResponse,
13
13
  ProductQueryParams,
14
14
  } from '../types';
15
+ import { validateOptionalLimit } from '../utils/validators';
15
16
 
16
17
  export class ProductsClient extends BaseClient {
17
18
  constructor(http: any, cache?: CacheManager) {
@@ -41,6 +42,16 @@ export class ProductsClient extends BaseClient {
41
42
  const normalizedParams: ProductQueryParams | undefined = params ? { ...params } : undefined;
42
43
 
43
44
  if (normalizedParams) {
45
+ const validatedLimit = validateOptionalLimit(
46
+ normalizedParams.limit,
47
+ 'products query',
48
+ );
49
+ if (validatedLimit !== undefined) {
50
+ normalizedParams.limit = validatedLimit;
51
+ } else {
52
+ delete normalizedParams.limit;
53
+ }
54
+
44
55
  const normalizedCategories = normalizeList(normalizedParams.category as any);
45
56
  if (normalizedCategories !== undefined) {
46
57
  normalizedParams.category = normalizedCategories;
package/src/loaders.ts CHANGED
@@ -17,8 +17,14 @@ import type {
17
17
  BlogPost,
18
18
  CreateCheckoutSessionRequest,
19
19
  CheckoutSession,
20
- CheckoutMetadata
20
+ CheckoutMetadata,
21
+ CheckoutTaxRequest
21
22
  } from './types';
23
+ import {
24
+ MAX_API_QUERY_LIMIT,
25
+ validateLimit,
26
+ validateOptionalContentType
27
+ } from './utils/validators';
22
28
 
23
29
  /**
24
30
  * Logger interface so consumers can supply custom logging behaviour (or noop).
@@ -311,13 +317,18 @@ export async function loadProducts(options: LoadProductsOptions): Promise<Produc
311
317
  siteName,
312
318
  logger = noopLogger,
313
319
  fallbackProducts,
314
- limit = 100,
320
+ limit: requestedLimit,
315
321
  offset,
316
322
  search,
317
323
  category,
318
324
  categoryIds
319
325
  } = options;
320
326
 
327
+ const resolvedLimit = validateLimit(
328
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
329
+ 'products query',
330
+ );
331
+
321
332
  if (!client) {
322
333
  log(logger, 'warn', '[PerspectAPI] No client configured, using fallback products');
323
334
  return resolveFallbackProducts({ siteName, fallbackProducts });
@@ -327,7 +338,7 @@ export async function loadProducts(options: LoadProductsOptions): Promise<Produc
327
338
  log(logger, 'info', `[PerspectAPI] Loading products for site "${siteName}"`);
328
339
  const queryParams: ProductQueryParams = {
329
340
  isActive: true,
330
- limit,
341
+ limit: resolvedLimit,
331
342
  offset,
332
343
  search
333
344
  };
@@ -427,13 +438,23 @@ export async function loadPages(
427
438
 
428
439
  try {
429
440
  log(logger, 'info', `[PerspectAPI] Loading pages for site "${siteName}"`);
441
+ const requestedLimit =
442
+ options.limit !== undefined ? options.limit : params?.limit;
443
+ const resolvedLimit = validateLimit(
444
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
445
+ 'pages query',
446
+ );
447
+ const requestedPageType = params?.page_type;
448
+ const validatedPageType =
449
+ validateOptionalContentType(requestedPageType, 'pages query') ?? 'page';
450
+
430
451
  const response: PaginatedResponse<Content> = await client.content.getContent(
431
452
  siteName,
432
453
  {
433
454
  ...params,
434
455
  page_status: (params?.page_status ?? 'publish') as ContentStatus,
435
- page_type: (params?.page_type ?? 'page') as ContentType,
436
- limit: options.limit ?? params?.limit ?? 100
456
+ page_type: validatedPageType as ContentType,
457
+ limit: resolvedLimit
437
458
  }
438
459
  );
439
460
 
@@ -463,13 +484,23 @@ export async function loadPosts(
463
484
 
464
485
  try {
465
486
  log(logger, 'info', `[PerspectAPI] Loading posts for site "${siteName}"`);
487
+ const requestedLimit =
488
+ options.limit !== undefined ? options.limit : params?.limit;
489
+ const resolvedLimit = validateLimit(
490
+ requestedLimit ?? MAX_API_QUERY_LIMIT,
491
+ 'posts query',
492
+ );
493
+ const requestedPageType = params?.page_type;
494
+ const validatedPageType =
495
+ validateOptionalContentType(requestedPageType, 'posts query') ?? 'post';
496
+
466
497
  const response: PaginatedResponse<Content> = await client.content.getContent(
467
498
  siteName,
468
499
  {
469
500
  ...params,
470
501
  page_status: (params?.page_status ?? 'publish') as ContentStatus,
471
- page_type: (params?.page_type ?? 'post') as ContentType,
472
- limit: options.limit ?? params?.limit ?? 100
502
+ page_type: validatedPageType as ContentType,
503
+ limit: resolvedLimit
473
504
  }
474
505
  );
475
506
 
@@ -558,6 +589,10 @@ export interface CheckoutSessionOptions {
558
589
  * Optional resolver to convert a product record into a Stripe price ID.
559
590
  */
560
591
  priceIdResolver?: (product: Product, mode: 'live' | 'test') => string | undefined;
592
+ /**
593
+ * Optional tax configuration that will be forwarded to the checkout API.
594
+ */
595
+ tax?: CheckoutTaxRequest;
561
596
  }
562
597
 
563
598
  /**
@@ -578,7 +613,8 @@ export async function createCheckoutSession(
578
613
  logger = noopLogger,
579
614
  fallbackProducts,
580
615
  metadata,
581
- priceIdResolver
616
+ priceIdResolver,
617
+ tax
582
618
  } = options;
583
619
 
584
620
  if (!client) {
@@ -592,7 +628,7 @@ export async function createCheckoutSession(
592
628
  client,
593
629
  siteName,
594
630
  logger,
595
- limit: 200,
631
+ limit: Math.min(Math.max(items.length, 1), MAX_API_QUERY_LIMIT),
596
632
  fallbackProducts
597
633
  });
598
634
 
@@ -623,6 +659,21 @@ export async function createCheckoutSession(
623
659
  };
624
660
  });
625
661
 
662
+ const resolvedTax: CheckoutTaxRequest | undefined = (() => {
663
+ if (!tax && !customerEmail) {
664
+ return undefined;
665
+ }
666
+
667
+ if (!tax) {
668
+ return { customer_identifier: customerEmail };
669
+ }
670
+
671
+ return {
672
+ ...tax,
673
+ customer_identifier: tax.customer_identifier ?? customerEmail ?? undefined,
674
+ };
675
+ })();
676
+
626
677
  const checkoutData: CreateCheckoutSessionRequest = {
627
678
  line_items,
628
679
  successUrl,
@@ -632,9 +683,13 @@ export async function createCheckoutSession(
632
683
  customerEmail,
633
684
  customer_email: customerEmail,
634
685
  mode: mode === 'test' ? 'payment' : 'payment',
635
- metadata
686
+ metadata,
636
687
  };
637
688
 
689
+ if (resolvedTax) {
690
+ checkoutData.tax = resolvedTax;
691
+ }
692
+
638
693
  log(logger, 'info', '[PerspectAPI] Creating checkout session');
639
694
  return client.checkout.createCheckoutSession(siteName, checkoutData);
640
695
  } catch (error) {
@@ -399,6 +399,45 @@ export type CheckoutMetadataValue =
399
399
 
400
400
  export type CheckoutMetadata = Record<string, CheckoutMetadataValue>;
401
401
 
402
+ export type CheckoutTaxStrategy =
403
+ | 'disabled'
404
+ | 'gateway_auto'
405
+ | 'manual_rates'
406
+ | 'external_service';
407
+
408
+ export type CheckoutTaxExemptionStatus =
409
+ | 'none'
410
+ | 'exempt'
411
+ | 'reverse_charge';
412
+
413
+ export interface CheckoutTaxCustomerExemptionRequest {
414
+ status?: CheckoutTaxExemptionStatus;
415
+ reason?: string;
416
+ tax_id?: string;
417
+ tax_id_type?: string;
418
+ certificate_url?: string;
419
+ metadata?: Record<string, any>;
420
+ expires_at?: string;
421
+ }
422
+
423
+ export interface CheckoutTaxRequest {
424
+ strategy?: CheckoutTaxStrategy;
425
+ customer_identifier?: string;
426
+ customer_profile_id?: string;
427
+ customer_display_name?: string;
428
+ allow_exemption?: boolean;
429
+ require_tax_id?: boolean;
430
+ save_profile?: boolean;
431
+ customer_exemption?: CheckoutTaxCustomerExemptionRequest;
432
+ manual_rate_percent?: number;
433
+ manual_rate_map?: Record<string, number>;
434
+ external_service?: {
435
+ provider: string;
436
+ config?: Record<string, any>;
437
+ };
438
+ metadata?: Record<string, any>;
439
+ }
440
+
402
441
  export interface CreateCheckoutSessionRequest {
403
442
  // Single item checkout (legacy/simple)
404
443
  priceId?: string;
@@ -437,6 +476,7 @@ export interface CreateCheckoutSessionRequest {
437
476
  allowed_countries: string[];
438
477
  };
439
478
  billing_address_collection?: 'auto' | 'required';
479
+ tax?: CheckoutTaxRequest;
440
480
  }
441
481
 
442
482
  export interface CheckoutSession {
@@ -0,0 +1,53 @@
1
+ import type { ContentType } from '../types';
2
+
3
+ export const MAX_API_QUERY_LIMIT = 100;
4
+ const ALLOWED_CONTENT_TYPES: ReadonlyArray<ContentType> = ['post', 'page'];
5
+
6
+ export function validateLimit(limit: number, context: string): number {
7
+ if (typeof limit !== 'number' || Number.isNaN(limit) || !Number.isFinite(limit)) {
8
+ throw new Error(`[PerspectAPI] ${context} limit must be a finite number.`);
9
+ }
10
+
11
+ if (limit < 1) {
12
+ throw new Error(`[PerspectAPI] ${context} limit must be at least 1.`);
13
+ }
14
+
15
+ if (limit > MAX_API_QUERY_LIMIT) {
16
+ throw new Error(
17
+ `[PerspectAPI] ${context} limit ${limit} exceeds the maximum allowed value of ${MAX_API_QUERY_LIMIT}.`,
18
+ );
19
+ }
20
+
21
+ return limit;
22
+ }
23
+
24
+ export function validateOptionalLimit(
25
+ limit: number | undefined,
26
+ context: string,
27
+ ): number | undefined {
28
+ if (limit === undefined || limit === null) {
29
+ return undefined;
30
+ }
31
+
32
+ return validateLimit(limit, context);
33
+ }
34
+
35
+ export function validateOptionalContentType(
36
+ pageType: string | undefined,
37
+ context: string,
38
+ ): ContentType | undefined {
39
+ if (pageType === undefined || pageType === null) {
40
+ return undefined;
41
+ }
42
+
43
+ if (ALLOWED_CONTENT_TYPES.includes(pageType as ContentType)) {
44
+ return pageType as ContentType;
45
+ }
46
+
47
+ throw new Error(
48
+ `[PerspectAPI] ${context} received unsupported page_type "${pageType}". Allowed values are ${ALLOWED_CONTENT_TYPES.join(
49
+ ', ',
50
+ )}.`,
51
+ );
52
+ }
53
+