@peektravel/app-utilities 0.5.2 → 0.5.4

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
@@ -109,8 +109,14 @@ Two kinds of failures surface as exceptions:
109
109
  rights). Carries `.statusCode === 418`.
110
110
  - `RateLimitError` — HTTP 429 after the configured `retryDelaysMs` backoff was
111
111
  exhausted. Carries `.statusCode === 429`.
112
- - `PeekGraphQLError` — the response contained a GraphQL `errors` array, preserved
113
- on `.graphqlErrors`.
112
+ - `PeekGraphQLError` — the response contained a GraphQL `errors` array (a
113
+ resolver-level failure), preserved on `.graphqlErrors`.
114
+ - `PeekHttpError` — the gateway returned a non-2xx HTTP status (other than
115
+ 418/429) with no GraphQL `errors` array — a transport-level failure such as
116
+ `401` (auth/secret wrong), `404` (wrong app id / not provisioned), or `5xx`.
117
+ Carries `.statusCode`, `.url`, and the raw `.body` (parsed JSON when possible,
118
+ otherwise the response text). Surfaced *before* the body is parsed as JSON, so
119
+ a non-JSON error page reports its real status instead of a JSON parse error.
114
120
  - `PiiAccessDisabledError` — a payment / booking-modification operation was
115
121
  called on an access service created without `fullCustomerAccess` (see [Access options
116
122
  / PII](#access-options--pii)). Carries `.operation` (the blocked method name).
@@ -127,6 +133,7 @@ import {
127
133
  RateLimitError,
128
134
  AdminAccountRequiredError,
129
135
  PeekGraphQLError,
136
+ PeekHttpError,
130
137
  } from '@peektravel/app-utilities';
131
138
 
132
139
  try {
@@ -136,6 +143,8 @@ try {
136
143
  // back off and retry later
137
144
  } else if (err instanceof AdminAccountRequiredError) {
138
145
  // this install can't perform admin-only operations
146
+ } else if (err instanceof PeekHttpError) {
147
+ console.error(err.statusCode, err.url, err.body); // which config is wrong
139
148
  } else if (err instanceof PeekGraphQLError) {
140
149
  console.error(err.graphqlErrors); // raw gateway errors
141
150
  } else {
@@ -229,16 +238,20 @@ Functions runtime) resolve correctly. Its only runtime dependency is
229
238
 
230
239
  ## Webhooks
231
240
 
232
- Receiver apps can consume Peek **booking** and **waiver** webhooks without
233
- hand-writing a payload parser. Each has a pure parser (construct nothing — no
234
- auth/network) that returns a clean model:
241
+ Receiver apps can consume Peek **booking**, **waiver**, and **install-status**
242
+ webhooks without hand-writing the payload handling. The booking and waiver
243
+ webhooks have a pure parser (construct nothing — no auth/network) that returns a
244
+ clean model; the install-status webhook delivers a signed token, so its helper
245
+ **verifies** it:
235
246
 
236
247
  ```ts
237
248
  import {
238
249
  parseBookingWebhook,
239
250
  parseWaiverWebhook,
251
+ verifyInstallWebhook,
240
252
  type Booking,
241
253
  type Waiver,
254
+ type InstallWebhookClaims,
242
255
  } from "@peektravel/app-utilities";
243
256
 
244
257
  app.post("/booking-webhook", (req, res) => {
@@ -251,17 +264,38 @@ app.post("/waiver-webhook", (req, res) => {
251
264
  const waiver: Waiver = parseWaiverWebhook(req.body, { fullCustomerAccess: true });
252
265
  res.sendStatus(200);
253
266
  });
267
+
268
+ app.post("/install-webhook", (req, res) => {
269
+ try {
270
+ // The payload IS a signed JWT — verify signature/issuer/audience/expiry:
271
+ const claims: InstallWebhookClaims = verifyInstallWebhook(req.body, process.env.PEEK_INTERNAL_SECRET!);
272
+ if (claims.status === "uninstalled") {
273
+ /* tear down this install */
274
+ }
275
+ res.sendStatus(200);
276
+ } catch {
277
+ res.sendStatus(401); // bad signature / issuer / audience / expired
278
+ }
279
+ });
254
280
  ```
255
281
 
256
- Both tolerate the delivery envelope / a bare node / a JSON string and never throw
257
- on malformed input. `parseWaiverWebhook` also takes an optional `AccessOptions`
258
- (`{ fullCustomerAccess }`) and redacts the participant `guestName` + document `fileUrl`
259
- by default — see [Access options / PII](#access-options--pii) below. They differ on registration: a **booking** webhook's payload
260
- shape is set by a GraphQL query configured **once in an external system** (the
261
- App Store `broadcast_to_url` config) — this package documents and drift-guards
262
- the exact query to paste there — whereas a **waiver** webhook has a fixed payload,
263
- so you just subscribe to its event with no query. **The query to register and the
264
- full guide: [`docs/webhooks.md`](docs/webhooks.md) (shipped).**
282
+ The booking and waiver parsers tolerate the delivery envelope / a bare node / a
283
+ JSON string and never throw on malformed input; **authenticating those deliveries
284
+ is the receiver's job**. `parseWaiverWebhook` also takes an optional
285
+ `AccessOptions` (`{ fullCustomerAccess }`) and redacts the participant `guestName`
286
+ + document `fileUrl` by default — see [Access options / PII](#access-options--pii)
287
+ below. `verifyInstallWebhook(token, secret)` is different: the payload is a signed
288
+ `app_registry_v2` JWT, so it validates the HMAC signature, expiry, issuer, and
289
+ `"Joken"` audience (the same checks as `verifyPeekAuthToken`) and returns typed
290
+ claims (`installId`, `account.id`, `status`, `displayVersion`, and a **nullable**
291
+ `user` for system-initiated events), throwing the underlying `jsonwebtoken` error
292
+ on any failure. The booking and waiver webhooks differ on registration: a
293
+ **booking** webhook's payload shape is set by a GraphQL query configured **once in
294
+ an external system** (the App Store `broadcast_to_url` config) — this package
295
+ documents and drift-guards the exact query to paste there — whereas the **waiver**
296
+ and **install-status** webhooks have fixed payloads, so you just subscribe to
297
+ their event with no query. **The query to register and the full guide:
298
+ [`docs/webhooks.md`](docs/webhooks.md) (shipped).**
265
299
 
266
300
  ## UI components (`/ui`)
267
301
 
@@ -299,6 +333,27 @@ attribute. Exported classes/types and helpers (`iconSvg`, `registerIcon`,
299
333
  `portal`, `position`, `toast`) are available from `@peektravel/app-utilities/ui`
300
334
  for subclassing or typing.
301
335
 
336
+ ### Using the components from React 19
337
+
338
+ React 19 sets JSX props on a custom element as DOM **properties**
339
+ (`el.searchable = true`), not attributes. That's handled for you: every
340
+ reflected/read-only accessor accepts assignment, so `<ody-dropdown-single
341
+ searchable options={items} />` and friends work without the
342
+ `Cannot set property … which has only a getter` crash that a getter-only
343
+ property would otherwise cause. Two things worth knowing:
344
+
345
+ - **Boolean and data props reflect to the attribute**, so they take effect as
346
+ expected. Pass booleans as real booleans (`searchable={true}`) and rich data
347
+ as values (`options={items}`) — the object is reflected as a JSON attribute.
348
+ - **Read-only state accessors are inert to assignment.** Props that mirror live
349
+ internal state (`isOpen`, `isVisible`) ignore writes — drive them through the
350
+ imperative methods on a `ref` (`ref.current.openPopover()`, `.show()`) and read
351
+ them back through the component's `CustomEvent`s, not by setting the prop.
352
+
353
+ TypeScript still types these accessors as read-only, so a direct
354
+ `el.isOpen = true` in TS is flagged — the setters are a runtime safety net for
355
+ framework-driven assignment, not an invitation to write to derived state.
356
+
302
357
  **Try the gallery:** `npm run sample` builds the package and serves
303
358
  `examples/ui-gallery.html`, which shows every component with its variants.
304
359
 
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var jwt = require('jsonwebtoken');
4
3
  var crypto = require('crypto');
4
+ var jwt = require('jsonwebtoken');
5
5
 
6
6
  function _interopNamespace(e) {
7
7
  if (e && e.__esModule) return e;
@@ -23,8 +23,6 @@ function _interopNamespace(e) {
23
23
 
24
24
  var jwt__namespace = /*#__PURE__*/_interopNamespace(jwt);
25
25
 
26
- // src/peek-access-service.ts
27
-
28
26
  // src/internal/peek/gateway-endpoints.ts
29
27
  var SALES_ENDPOINT = "sales";
30
28
  var V2_EXTENDABLE_SLUG = "peek_backoffice_api-v1";
@@ -234,6 +232,21 @@ var PeekGraphQLError = class extends Error {
234
232
  this.graphqlErrors = graphqlErrors;
235
233
  }
236
234
  };
235
+ var PeekHttpError = class extends Error {
236
+ /** The HTTP status that triggered this error. */
237
+ statusCode;
238
+ /** The request URL that produced the failing response. */
239
+ url;
240
+ /** The raw response body (parsed JSON when possible, otherwise text). */
241
+ body;
242
+ constructor(statusCode, url, body, message) {
243
+ super(message ?? `Peek request failed with HTTP ${statusCode}`);
244
+ this.name = "PeekHttpError";
245
+ this.statusCode = statusCode;
246
+ this.url = url;
247
+ this.body = body;
248
+ }
249
+ };
237
250
  var PiiAccessDisabledError = class extends Error {
238
251
  /** The name of the operation that was blocked (e.g. `"makePayment"`). */
239
252
  operation;
@@ -1944,6 +1957,14 @@ function createTokenManager(config) {
1944
1957
  var RATE_LIMIT_STATUS = 429;
1945
1958
  var ADMIN_ACCOUNT_REQUIRED_STATUS = 418;
1946
1959
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1960
+ async function parseBody(response) {
1961
+ const text = await response.text();
1962
+ try {
1963
+ return JSON.parse(text);
1964
+ } catch {
1965
+ return text;
1966
+ }
1967
+ }
1947
1968
  async function requestWithRetry(http, url, init, label, onResponse) {
1948
1969
  const { retryDelaysMs, logger, fetchFn } = http;
1949
1970
  for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
@@ -1970,6 +1991,7 @@ async function requestWithRetry(http, url, init, label, onResponse) {
1970
1991
  }
1971
1992
 
1972
1993
  // src/internal/peek/graphql-client.ts
1994
+ var isObject = (value) => typeof value === "object" && value !== null;
1973
1995
  var GraphQLClient = class {
1974
1996
  constructor(options) {
1975
1997
  this.options = options;
@@ -1995,7 +2017,8 @@ var GraphQLClient = class {
1995
2017
  },
1996
2018
  endpointName,
1997
2019
  async (response) => {
1998
- const body = await response.json();
2020
+ const raw = await parseBody(response);
2021
+ const body = isObject(raw) ? raw : {};
1999
2022
  if (body.errors) {
2000
2023
  logger.error(`GraphQL errors for ${endpointName}`, {
2001
2024
  url,
@@ -2005,7 +2028,7 @@ var GraphQLClient = class {
2005
2028
  }
2006
2029
  if (!response.ok) {
2007
2030
  logger.error(`GraphQL request failed with HTTP ${response.status}`, { url });
2008
- throw new Error(`GraphQL request failed with HTTP ${response.status}`);
2031
+ throw new PeekHttpError(response.status, url, raw);
2009
2032
  }
2010
2033
  return body;
2011
2034
  }
@@ -2027,6 +2050,24 @@ var GraphQLClient = class {
2027
2050
  return headers;
2028
2051
  }
2029
2052
  };
2053
+ var PEEK_TOKEN_ISSUER = "app_registry_v2";
2054
+ var PEEK_TOKEN_AUDIENCE = "Joken";
2055
+ function verifyPeekJwt(token, secret) {
2056
+ return jwt__namespace.verify(token, secret, {
2057
+ issuer: PEEK_TOKEN_ISSUER,
2058
+ audience: PEEK_TOKEN_AUDIENCE
2059
+ });
2060
+ }
2061
+ function mapPeekTokenUser(u) {
2062
+ return {
2063
+ email: u.email,
2064
+ id: u.id,
2065
+ isAdmin: u.is_admin,
2066
+ locale: u.locale,
2067
+ name: u.name,
2068
+ platform: u.platform
2069
+ };
2070
+ }
2030
2071
 
2031
2072
  // src/internal/peek/memberships/membership-converter.ts
2032
2073
  function fromMembershipsResponse(response) {
@@ -2495,10 +2536,17 @@ function fromTimeslotNode(node, productId) {
2495
2536
  durationMin: node.minuteLength ?? 0,
2496
2537
  date: node.date || "",
2497
2538
  startTime: node.start ?? null,
2498
- assignedResources: mapAssignedResources(node.inheritedResourceAllocations)
2539
+ assignedResources: mapAssignedResources(selectResourceAllocations(node))
2499
2540
  };
2500
2541
  }
2501
2542
  var ACTIVE_ALLOCATION_STATUS = "ACTIVE";
2543
+ function selectResourceAllocations(node) {
2544
+ const inherited = node.inheritedResourceAllocations;
2545
+ if (Array.isArray(inherited) && inherited.length > 0) {
2546
+ return inherited;
2547
+ }
2548
+ return node.resourceAllocations;
2549
+ }
2502
2550
  function mapAssignedResources(allocations) {
2503
2551
  if (!Array.isArray(allocations) || allocations.length === 0) {
2504
2552
  return [];
@@ -2585,9 +2633,24 @@ var TIMESLOTS_QUERY = `
2585
2633
  status
2586
2634
  date
2587
2635
  inheritedResourceAllocations {
2636
+ id
2637
+ status
2638
+ quantity
2639
+ resourcePool {
2640
+ id
2641
+ name
2642
+ category
2643
+ capacity
2644
+ accountUser {
2645
+ id
2646
+ }
2647
+ }
2648
+ }
2649
+ resourceAllocations {
2588
2650
  quantity
2589
2651
  status
2590
2652
  resourcePool {
2653
+ id
2591
2654
  name
2592
2655
  category
2593
2656
  capacity
@@ -2623,9 +2686,24 @@ var TIMESLOT_BY_ID_QUERY = `
2623
2686
  status
2624
2687
  date
2625
2688
  inheritedResourceAllocations {
2689
+ id
2690
+ status
2691
+ quantity
2692
+ resourcePool {
2693
+ id
2694
+ name
2695
+ category
2696
+ capacity
2697
+ accountUser {
2698
+ id
2699
+ }
2700
+ }
2701
+ }
2702
+ resourceAllocations {
2626
2703
  quantity
2627
2704
  status
2628
2705
  resourcePool {
2706
+ id
2629
2707
  name
2630
2708
  category
2631
2709
  capacity
@@ -3430,7 +3508,6 @@ var noopLogger = {
3430
3508
  // src/peek-access-service.ts
3431
3509
  var DEFAULT_BASE_URL = "https://apps.peekapis.com/backoffice-gql";
3432
3510
  var DEFAULT_V2_BASE_URL = "https://app-registry.peeklabs.com/installations-api";
3433
- var PEEK_TOKEN_ISSUER = "app_registry_v2";
3434
3511
  var PeekAccessService = class {
3435
3512
  client;
3436
3513
  productServiceOptions;
@@ -3493,21 +3570,11 @@ var PeekAccessService = class {
3493
3570
  * @throws {NotBeforeError} token not yet valid
3494
3571
  */
3495
3572
  verifyPeekAuthToken(token) {
3496
- const payload = jwt__namespace.verify(token, this.jwtSecret, {
3497
- issuer: PEEK_TOKEN_ISSUER
3498
- });
3499
- const { user: u } = payload;
3573
+ const payload = verifyPeekJwt(token, this.jwtSecret);
3500
3574
  return {
3501
3575
  installId: payload.sub,
3502
3576
  displayVersion: payload.display_version,
3503
- user: {
3504
- email: u.email,
3505
- id: u.id,
3506
- isAdmin: u.is_admin,
3507
- locale: u.locale,
3508
- name: u.name,
3509
- platform: u.platform
3510
- }
3577
+ user: mapPeekTokenUser(payload.user)
3511
3578
  };
3512
3579
  }
3513
3580
  /**
@@ -3891,7 +3958,7 @@ var RestClient = class {
3891
3958
  { method: "GET", headers: this.buildHeaders() },
3892
3959
  path,
3893
3960
  async (response) => {
3894
- const body = await this.parseBody(response);
3961
+ const body = await parseBody(response);
3895
3962
  if (!response.ok) {
3896
3963
  logger.error(`CNG request failed with HTTP ${response.status}`, { url });
3897
3964
  throw new CngApiError(response.status, body);
@@ -3900,15 +3967,6 @@ var RestClient = class {
3900
3967
  }
3901
3968
  );
3902
3969
  }
3903
- /** Reads the response body as JSON, falling back to raw text when unparseable. */
3904
- async parseBody(response) {
3905
- const text = await response.text();
3906
- try {
3907
- return JSON.parse(text);
3908
- } catch {
3909
- return text;
3910
- }
3911
- }
3912
3970
  endpoint(path) {
3913
3971
  const { baseUrl, appId, extendableSlug } = this.options;
3914
3972
  return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
@@ -4031,7 +4089,7 @@ var RestClient2 = class {
4031
4089
  { method: "GET", headers: this.buildHeaders() },
4032
4090
  path,
4033
4091
  async (response) => {
4034
- const body = await this.parseBody(response);
4092
+ const body = await parseBody(response);
4035
4093
  if (!response.ok) {
4036
4094
  logger.error(`ACME request failed with HTTP ${response.status}`, { url });
4037
4095
  throw new AcmeApiError(response.status, body);
@@ -4040,15 +4098,6 @@ var RestClient2 = class {
4040
4098
  }
4041
4099
  );
4042
4100
  }
4043
- /** Reads the response body as JSON, falling back to raw text when unparseable. */
4044
- async parseBody(response) {
4045
- const text = await response.text();
4046
- try {
4047
- return JSON.parse(text);
4048
- } catch {
4049
- return text;
4050
- }
4051
- }
4052
4101
  endpoint(path) {
4053
4102
  const { baseUrl, appId, extendableSlug } = this.options;
4054
4103
  return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
@@ -4189,6 +4238,18 @@ function extractWaiverNode(payload) {
4189
4238
  return record;
4190
4239
  }
4191
4240
 
4241
+ // src/internal/peek/installs/install-webhook.ts
4242
+ function verifyInstallWebhook(token, secret) {
4243
+ const payload = verifyPeekJwt(token, secret);
4244
+ return {
4245
+ installId: payload.sub ?? "",
4246
+ account: { id: payload.account?.id ?? "" },
4247
+ status: payload.status ?? "",
4248
+ displayVersion: payload.display_version ?? "",
4249
+ user: payload.user ? mapPeekTokenUser(payload.user) : null
4250
+ };
4251
+ }
4252
+
4192
4253
  exports.ACTIVITY_PRODUCT_TYPE = ACTIVITY_PRODUCT_TYPE;
4193
4254
  exports.ADD_ON_PRODUCT_TYPE = ADD_ON_PRODUCT_TYPE;
4194
4255
  exports.AccountUserService = AccountUserService;
@@ -4205,6 +4266,7 @@ exports.DailyNoteService = DailyNoteService;
4205
4266
  exports.MembershipService = MembershipService;
4206
4267
  exports.PeekAccessService = PeekAccessService;
4207
4268
  exports.PeekGraphQLError = PeekGraphQLError;
4269
+ exports.PeekHttpError = PeekHttpError;
4208
4270
  exports.PiiAccessDisabledError = PiiAccessDisabledError;
4209
4271
  exports.PricingService = PricingService;
4210
4272
  exports.ProductService = ProductService;
@@ -4218,5 +4280,6 @@ exports.TimeslotService = TimeslotService;
4218
4280
  exports.noopLogger = noopLogger;
4219
4281
  exports.parseBookingWebhook = parseBookingWebhook;
4220
4282
  exports.parseWaiverWebhook = parseWaiverWebhook;
4283
+ exports.verifyInstallWebhook = verifyInstallWebhook;
4221
4284
  //# sourceMappingURL=index.cjs.map
4222
4285
  //# sourceMappingURL=index.cjs.map