@productcraft/core 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -276,9 +276,18 @@ declare class Page<Item> {
276
276
  readonly has_more: boolean;
277
277
  readonly next_cursor: string | null;
278
278
  readonly lastResponse: LastResponse;
279
- constructor(body: PageResponse<Item>, fetchPage: (cursor: string) => PagePromise<Item>);
279
+ /**
280
+ * @param usedCursors The cursors already sent to reach this page (this
281
+ * page's own included). A `next_cursor` among them means the API is
282
+ * handing back a page it already returned, and following it would loop.
283
+ */
284
+ constructor(body: PageResponse<Item>, fetchPage: (cursor: string) => PagePromise<Item>, usedCursors?: Iterable<string>);
280
285
  hasNextPage(): boolean;
281
- /** Fetch the following page. Throws when there is none (check `hasNextPage()` first). */
286
+ /**
287
+ * Fetch the following page. Throws when there is none (check `hasNextPage()` first),
288
+ * and when the API returns a `next_cursor` this walk already sent: that would
289
+ * fetch the same page forever, so `for await` stops with an error instead.
290
+ */
282
291
  getNextPage(): PagePromise<Item>;
283
292
  /** The wire shape, so `JSON.stringify(page)` matches the API response. */
284
293
  toJSON(): PageResponse<Item>;
@@ -343,6 +352,36 @@ declare function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean;
343
352
  * const event = await verifyWebhook({ payload: rawBody, headers: req.headers, secret: process.env.WEBHOOK_SECRET! });
344
353
  */
345
354
  declare function verifyWebhook<T = unknown>(params: VerifyWebhookParams): Promise<T>;
355
+ interface SignWebhookParams {
356
+ /**
357
+ * The event to sign: a string or bytes are signed as-is; any other value is
358
+ * `JSON.stringify`-ed first (the returned `payload` is that exact string).
359
+ */
360
+ payload: WebhookPayload | Record<string, unknown> | unknown[];
361
+ /** The endpoint's signing secret (`whsec_…`, or a plain pre-Standard-Webhooks secret). */
362
+ secret: string;
363
+ /** `webhook-id`. Default: a random `msg_…` id. */
364
+ id?: string;
365
+ /** Unix seconds for `webhook-timestamp`. Default: now. */
366
+ timestamp?: number;
367
+ }
368
+ interface SignedWebhook {
369
+ /** The exact body that was signed: send or verify this, byte for byte. */
370
+ payload: string;
371
+ /** `webhook-id`, `webhook-timestamp`, `webhook-signature` (and `content-type`). */
372
+ headers: Record<string, string>;
373
+ }
374
+ /**
375
+ * Sign a payload the way ProductCraft signs webhooks (Standard Webhooks,
376
+ * `v1,<base64 HMAC-SHA256>` over `<id>.<timestamp>.<body>`), for testing your
377
+ * handler without a live delivery. `verifyWebhook` and `webhooks.unwrap`
378
+ * accept the result.
379
+ *
380
+ * @example
381
+ * const { payload, headers } = await signWebhook({ payload: { id: "evt_1", type: "user.signup", data: {} }, secret });
382
+ * const event = await verifyWebhook({ payload, headers, secret });
383
+ */
384
+ declare function signWebhook(params: SignWebhookParams): Promise<SignedWebhook>;
346
385
 
347
386
  /**
348
387
  * `APIClient`: the hand-written runtime every generated resource method
@@ -481,9 +520,13 @@ declare class APIClient {
481
520
  secret?: string | readonly string[];
482
521
  toleranceSeconds?: number;
483
522
  }, envVar?: string): Promise<T>;
523
+ /** Sign a test payload with `params.secret`, else `webhookSecret`, else `envVar` (`webhooks.sign`). */
524
+ signWebhook(params: Omit<SignWebhookParams, "secret"> & {
525
+ secret?: string;
526
+ }, envVar?: string): Promise<SignedWebhook>;
484
527
  }
485
528
 
486
529
  /** This package's version, injected at build time from package.json. */
487
530
  declare const CORE_VERSION: string;
488
531
 
489
- export { APIClient, APIConnectionError, APIError, APIPromise, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, CORE_VERSION, type ClientOptions, ConfigurationError, ConflictError, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, ERROR_DOCS_URL, type ErrorParam, type HTTPMethod, IdempotencyKeyReuseError, InternalServerError, type LastResponse, type LogLevel, type Logger, MAX_RETRY_AFTER_MS, NotFoundError, type OpenEnum, type PCAuth, type PCClientConfig, PC_API_KEY_ENV, PC_AUTH_APP_ENV, PC_BASE_URL, PC_LOG_ENV, PC_WORKSPACE_ID_ENV, Page, PagePromise, type PageResponse, PermissionDeniedError, type PollConfig, type PollOptions, ProductCraftError, RateLimitError, type RateLimitInfo, type RawResult, type RequestOptions, type RequestSpec, type SDKIdentity, type ToArrayOptions, UnprocessableEntityError, type VerifyWebhookParams, type WebhookHeaders, type WebhookPayload, WebhookVerificationError, type WithResponse, _resetWorkspaceCache, attachLastResponse, authMiddleware, backoffMs, createOpenAPIClient, makeClient, normalizeHeaders, parseRateLimit, parseRetryAfterMs, readEnv, resolveAuth, timingSafeEqual, verifyWebhook };
532
+ export { APIClient, APIConnectionError, APIError, APIPromise, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, CORE_VERSION, type ClientOptions, ConfigurationError, ConflictError, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, ERROR_DOCS_URL, type ErrorParam, type HTTPMethod, IdempotencyKeyReuseError, InternalServerError, type LastResponse, type LogLevel, type Logger, MAX_RETRY_AFTER_MS, NotFoundError, type OpenEnum, type PCAuth, type PCClientConfig, PC_API_KEY_ENV, PC_AUTH_APP_ENV, PC_BASE_URL, PC_LOG_ENV, PC_WORKSPACE_ID_ENV, Page, PagePromise, type PageResponse, PermissionDeniedError, type PollConfig, type PollOptions, ProductCraftError, RateLimitError, type RateLimitInfo, type RawResult, type RequestOptions, type RequestSpec, type SDKIdentity, type SignWebhookParams, type SignedWebhook, type ToArrayOptions, UnprocessableEntityError, type VerifyWebhookParams, type WebhookHeaders, type WebhookPayload, WebhookVerificationError, type WithResponse, _resetWorkspaceCache, attachLastResponse, authMiddleware, backoffMs, createOpenAPIClient, makeClient, normalizeHeaders, parseRateLimit, parseRetryAfterMs, readEnv, resolveAuth, signWebhook, timingSafeEqual, verifyWebhook };
package/dist/index.d.ts CHANGED
@@ -276,9 +276,18 @@ declare class Page<Item> {
276
276
  readonly has_more: boolean;
277
277
  readonly next_cursor: string | null;
278
278
  readonly lastResponse: LastResponse;
279
- constructor(body: PageResponse<Item>, fetchPage: (cursor: string) => PagePromise<Item>);
279
+ /**
280
+ * @param usedCursors The cursors already sent to reach this page (this
281
+ * page's own included). A `next_cursor` among them means the API is
282
+ * handing back a page it already returned, and following it would loop.
283
+ */
284
+ constructor(body: PageResponse<Item>, fetchPage: (cursor: string) => PagePromise<Item>, usedCursors?: Iterable<string>);
280
285
  hasNextPage(): boolean;
281
- /** Fetch the following page. Throws when there is none (check `hasNextPage()` first). */
286
+ /**
287
+ * Fetch the following page. Throws when there is none (check `hasNextPage()` first),
288
+ * and when the API returns a `next_cursor` this walk already sent: that would
289
+ * fetch the same page forever, so `for await` stops with an error instead.
290
+ */
282
291
  getNextPage(): PagePromise<Item>;
283
292
  /** The wire shape, so `JSON.stringify(page)` matches the API response. */
284
293
  toJSON(): PageResponse<Item>;
@@ -343,6 +352,36 @@ declare function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean;
343
352
  * const event = await verifyWebhook({ payload: rawBody, headers: req.headers, secret: process.env.WEBHOOK_SECRET! });
344
353
  */
345
354
  declare function verifyWebhook<T = unknown>(params: VerifyWebhookParams): Promise<T>;
355
+ interface SignWebhookParams {
356
+ /**
357
+ * The event to sign: a string or bytes are signed as-is; any other value is
358
+ * `JSON.stringify`-ed first (the returned `payload` is that exact string).
359
+ */
360
+ payload: WebhookPayload | Record<string, unknown> | unknown[];
361
+ /** The endpoint's signing secret (`whsec_…`, or a plain pre-Standard-Webhooks secret). */
362
+ secret: string;
363
+ /** `webhook-id`. Default: a random `msg_…` id. */
364
+ id?: string;
365
+ /** Unix seconds for `webhook-timestamp`. Default: now. */
366
+ timestamp?: number;
367
+ }
368
+ interface SignedWebhook {
369
+ /** The exact body that was signed: send or verify this, byte for byte. */
370
+ payload: string;
371
+ /** `webhook-id`, `webhook-timestamp`, `webhook-signature` (and `content-type`). */
372
+ headers: Record<string, string>;
373
+ }
374
+ /**
375
+ * Sign a payload the way ProductCraft signs webhooks (Standard Webhooks,
376
+ * `v1,<base64 HMAC-SHA256>` over `<id>.<timestamp>.<body>`), for testing your
377
+ * handler without a live delivery. `verifyWebhook` and `webhooks.unwrap`
378
+ * accept the result.
379
+ *
380
+ * @example
381
+ * const { payload, headers } = await signWebhook({ payload: { id: "evt_1", type: "user.signup", data: {} }, secret });
382
+ * const event = await verifyWebhook({ payload, headers, secret });
383
+ */
384
+ declare function signWebhook(params: SignWebhookParams): Promise<SignedWebhook>;
346
385
 
347
386
  /**
348
387
  * `APIClient`: the hand-written runtime every generated resource method
@@ -481,9 +520,13 @@ declare class APIClient {
481
520
  secret?: string | readonly string[];
482
521
  toleranceSeconds?: number;
483
522
  }, envVar?: string): Promise<T>;
523
+ /** Sign a test payload with `params.secret`, else `webhookSecret`, else `envVar` (`webhooks.sign`). */
524
+ signWebhook(params: Omit<SignWebhookParams, "secret"> & {
525
+ secret?: string;
526
+ }, envVar?: string): Promise<SignedWebhook>;
484
527
  }
485
528
 
486
529
  /** This package's version, injected at build time from package.json. */
487
530
  declare const CORE_VERSION: string;
488
531
 
489
- export { APIClient, APIConnectionError, APIError, APIPromise, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, CORE_VERSION, type ClientOptions, ConfigurationError, ConflictError, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, ERROR_DOCS_URL, type ErrorParam, type HTTPMethod, IdempotencyKeyReuseError, InternalServerError, type LastResponse, type LogLevel, type Logger, MAX_RETRY_AFTER_MS, NotFoundError, type OpenEnum, type PCAuth, type PCClientConfig, PC_API_KEY_ENV, PC_AUTH_APP_ENV, PC_BASE_URL, PC_LOG_ENV, PC_WORKSPACE_ID_ENV, Page, PagePromise, type PageResponse, PermissionDeniedError, type PollConfig, type PollOptions, ProductCraftError, RateLimitError, type RateLimitInfo, type RawResult, type RequestOptions, type RequestSpec, type SDKIdentity, type ToArrayOptions, UnprocessableEntityError, type VerifyWebhookParams, type WebhookHeaders, type WebhookPayload, WebhookVerificationError, type WithResponse, _resetWorkspaceCache, attachLastResponse, authMiddleware, backoffMs, createOpenAPIClient, makeClient, normalizeHeaders, parseRateLimit, parseRetryAfterMs, readEnv, resolveAuth, timingSafeEqual, verifyWebhook };
532
+ export { APIClient, APIConnectionError, APIError, APIPromise, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, CORE_VERSION, type ClientOptions, ConfigurationError, ConflictError, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, ERROR_DOCS_URL, type ErrorParam, type HTTPMethod, IdempotencyKeyReuseError, InternalServerError, type LastResponse, type LogLevel, type Logger, MAX_RETRY_AFTER_MS, NotFoundError, type OpenEnum, type PCAuth, type PCClientConfig, PC_API_KEY_ENV, PC_AUTH_APP_ENV, PC_BASE_URL, PC_LOG_ENV, PC_WORKSPACE_ID_ENV, Page, PagePromise, type PageResponse, PermissionDeniedError, type PollConfig, type PollOptions, ProductCraftError, RateLimitError, type RateLimitInfo, type RawResult, type RequestOptions, type RequestSpec, type SDKIdentity, type SignWebhookParams, type SignedWebhook, type ToArrayOptions, UnprocessableEntityError, type VerifyWebhookParams, type WebhookHeaders, type WebhookPayload, WebhookVerificationError, type WithResponse, _resetWorkspaceCache, attachLastResponse, authMiddleware, backoffMs, createOpenAPIClient, makeClient, normalizeHeaders, parseRateLimit, parseRetryAfterMs, readEnv, resolveAuth, signWebhook, timingSafeEqual, verifyWebhook };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import createClient from 'openapi-fetch';
3
3
  // src/legacy.ts
4
4
 
5
5
  // src/version.ts
6
- var CORE_VERSION = "1.0.1" ;
6
+ var CORE_VERSION = "1.1.0" ;
7
7
 
8
8
  // src/legacy.ts
9
9
  var PC_BASE_URL = {
@@ -329,20 +329,37 @@ var Page = class {
329
329
  has_more;
330
330
  next_cursor;
331
331
  #fetchPage;
332
- constructor(body, fetchPage) {
332
+ #usedCursors;
333
+ /**
334
+ * @param usedCursors The cursors already sent to reach this page (this
335
+ * page's own included). A `next_cursor` among them means the API is
336
+ * handing back a page it already returned, and following it would loop.
337
+ */
338
+ constructor(body, fetchPage, usedCursors = []) {
333
339
  this.data = Array.isArray(body?.data) ? body.data : [];
334
340
  this.next_cursor = body?.pagination?.next_cursor ?? null;
335
341
  this.has_more = Boolean(body?.pagination?.has_more) && this.next_cursor !== null;
336
342
  this.#fetchPage = fetchPage;
343
+ this.#usedCursors = new Set(usedCursors);
337
344
  }
338
345
  hasNextPage() {
339
346
  return this.has_more;
340
347
  }
341
- /** Fetch the following page. Throws when there is none (check `hasNextPage()` first). */
348
+ /**
349
+ * Fetch the following page. Throws when there is none (check `hasNextPage()` first),
350
+ * and when the API returns a `next_cursor` this walk already sent: that would
351
+ * fetch the same page forever, so `for await` stops with an error instead.
352
+ */
342
353
  getNextPage() {
343
354
  if (!this.has_more || this.next_cursor === null) {
344
355
  throw new ProductCraftError("No next page; check hasNextPage() before calling getNextPage().");
345
356
  }
357
+ if (this.#usedCursors.has(this.next_cursor)) {
358
+ const requestId = this.lastResponse?.requestId;
359
+ throw new ProductCraftError(
360
+ "The API returned a next_cursor this list already used, so the next page would repeat an earlier one. Stopped instead of looping; this is an API bug" + (requestId ? ` (request id ${requestId}).` : ".")
361
+ );
362
+ }
346
363
  return this.#fetchPage(this.next_cursor);
347
364
  }
348
365
  /** The wire shape, so `JSON.stringify(page)` matches the API response. */
@@ -529,6 +546,34 @@ async function verifyWebhook(params) {
529
546
  throw new WebhookVerificationError("Webhook payload is not valid JSON.", { cause });
530
547
  }
531
548
  }
549
+ async function signWebhook(params) {
550
+ const { secret } = params;
551
+ if (typeof secret !== "string" || secret === "") throw new WebhookVerificationError("No webhook secret was given.");
552
+ const raw = params.payload;
553
+ const payload = typeof raw === "string" ? raw : raw instanceof Uint8Array || raw instanceof ArrayBuffer ? decoder.decode(raw) : JSON.stringify(raw);
554
+ const id = params.id ?? `msg_${randomHex(16)}`;
555
+ const timestamp = Math.floor(params.timestamp ?? Date.now() / 1e3);
556
+ const mac = await hmacSha256(standardKey(secret), encoder.encode(`${id}.${timestamp}.${payload}`));
557
+ return {
558
+ payload,
559
+ headers: {
560
+ "content-type": "application/json",
561
+ "webhook-id": id,
562
+ "webhook-timestamp": String(timestamp),
563
+ "webhook-signature": `v1,${bytesToBase64(mac)}`
564
+ }
565
+ };
566
+ }
567
+ function bytesToBase64(bytes) {
568
+ let bin = "";
569
+ for (const b of bytes) bin += String.fromCharCode(b);
570
+ return btoa(bin);
571
+ }
572
+ function randomHex(bytes) {
573
+ const buf = new Uint8Array(bytes);
574
+ globalThis.crypto.getRandomValues(buf);
575
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
576
+ }
532
577
 
533
578
  // src/client.ts
534
579
  var DEFAULT_TIMEOUT_MS = 6e4;
@@ -643,15 +688,16 @@ var APIClient = class {
643
688
  }
644
689
  /** A paginated list call. `await` for one page, `for await` for every item. */
645
690
  list(spec, options = {}) {
646
- const at = (cursor) => {
691
+ const at = (cursor, used) => {
647
692
  const pageSpec = cursor === void 0 ? spec : { ...spec, query: { ...spec.query, cursor } };
648
693
  return new PagePromise(this.#send(pageSpec, options), async (raw) => {
649
694
  const body = await this.#parseBody(pageSpec, raw);
650
- const page = new Page(body, (next) => at(next));
695
+ const page = new Page(body, (next) => at(next, [...used, next]), used);
651
696
  return attachLastResponse(page, this.#meta(raw));
652
697
  });
653
698
  };
654
- return at();
699
+ const first = spec.query?.cursor;
700
+ return at(void 0, typeof first === "string" && first !== "" ? [first] : []);
655
701
  }
656
702
  /** Retrieve repeatedly until `config.field` reaches a terminal value. */
657
703
  async poll(spec, config, options = {}) {
@@ -692,6 +738,17 @@ var APIClient = class {
692
738
  }
693
739
  return verifyWebhook({ ...params, secret });
694
740
  }
741
+ /** Sign a test payload with `params.secret`, else `webhookSecret`, else `envVar` (`webhooks.sign`). */
742
+ async signWebhook(params, envVar) {
743
+ const fallback = Array.isArray(this.webhookSecret) ? this.webhookSecret[0] : this.webhookSecret;
744
+ const secret = params.secret ?? fallback ?? (envVar ? readEnv(envVar) : void 0);
745
+ if (!secret) {
746
+ throw new ConfigurationError(
747
+ `No webhook secret: pass { secret }, set webhookSecret on the client${envVar ? ` or ${envVar}` : ""}.`
748
+ );
749
+ }
750
+ return signWebhook({ ...params, secret });
751
+ }
695
752
  #meta(raw) {
696
753
  return { requestId: raw.requestId, status: raw.response.status, headers: raw.response.headers };
697
754
  }
@@ -852,6 +909,6 @@ var APIClient = class {
852
909
  }
853
910
  };
854
911
 
855
- export { APIClient, APIConnectionError, APIError, APIPromise, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, CORE_VERSION, ConfigurationError, ConflictError, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, ERROR_DOCS_URL, IdempotencyKeyReuseError, InternalServerError, MAX_RETRY_AFTER_MS, NotFoundError, PC_API_KEY_ENV, PC_AUTH_APP_ENV, PC_BASE_URL, PC_LOG_ENV, PC_WORKSPACE_ID_ENV, Page, PagePromise, PermissionDeniedError, ProductCraftError, RateLimitError, UnprocessableEntityError, WebhookVerificationError, _resetWorkspaceCache, attachLastResponse, authMiddleware, backoffMs, createOpenAPIClient, makeClient, normalizeHeaders, parseRateLimit, parseRetryAfterMs, readEnv, resolveAuth, timingSafeEqual, verifyWebhook };
912
+ export { APIClient, APIConnectionError, APIError, APIPromise, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, CORE_VERSION, ConfigurationError, ConflictError, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, ERROR_DOCS_URL, IdempotencyKeyReuseError, InternalServerError, MAX_RETRY_AFTER_MS, NotFoundError, PC_API_KEY_ENV, PC_AUTH_APP_ENV, PC_BASE_URL, PC_LOG_ENV, PC_WORKSPACE_ID_ENV, Page, PagePromise, PermissionDeniedError, ProductCraftError, RateLimitError, UnprocessableEntityError, WebhookVerificationError, _resetWorkspaceCache, attachLastResponse, authMiddleware, backoffMs, createOpenAPIClient, makeClient, normalizeHeaders, parseRateLimit, parseRetryAfterMs, readEnv, resolveAuth, signWebhook, timingSafeEqual, verifyWebhook };
856
913
  //# sourceMappingURL=index.js.map
857
914
  //# sourceMappingURL=index.js.map