@peektravel/app-utilities 0.7.1 → 0.7.3

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
@@ -124,10 +124,12 @@ Two kinds of failures surface as exceptions:
124
124
  - `PiiAccessDisabledError` — a payment / booking-modification operation was
125
125
  called on an access service created without `fullCustomerAccess` (see [Access options
126
126
  / PII](#access-options--pii)). Carries `.operation` (the blocked method name).
127
- - `InvalidPeekTokenError` — `verifyPeekAuthToken` (or `parseInstallWebhook` /
128
- `verifyInstallWebhook`)
129
- received a token whose signature is valid but whose `user` block has no `id`.
130
- Carries `.field` (`"user.id"`). Thrown alongside the `jsonwebtoken` errors.
127
+
128
+ Once a token's signature verifies, claim *extraction* never throws: the whole
129
+ `user` block may be absent (`claims.user === null`) and every field inside it is
130
+ independently nullable, with a sentinel value (`""` or `"null"`) normalized to
131
+ `null`. `user.id` is the signed-in person's own id — not an account or partner
132
+ id — so its absence is not an error.
131
133
 
132
134
  **Plain `Error` validation/precondition failures** thrown by the service layer
133
135
  *before* any network call — e.g. an empty config field, a `bookingId` that
@@ -306,14 +308,22 @@ signed `app_registry_v2` JWT plus a JSON body. Peek sends the token in the
306
308
  `x-peek-auth` header — pass that value straight in (`token` accepts it with or
307
309
  without a `Bearer ` prefix). It validates the HMAC signature,
308
310
  expiry, issuer, and `"Joken"` audience (the same checks as `verifyPeekAuthToken`,
309
- throwing the underlying `jsonwebtoken` error — or `InvalidPeekTokenError` when a
310
- present `user` block has no `id`), then returns one flat, JSON-safe
311
+ throwing the underlying `jsonwebtoken` error), then returns one flat, JSON-safe
311
312
  `InstallWebhook`: `installId`, `accountId` (**also called the partner ID**),
312
- `accountName`, `platform`, `isTest`, `status`, `rawStatus`, `displayVersion`, and
313
- a **nullable** `user`. The **verified token** is authoritative for `installId` /
314
- `accountId` / `status` / `displayVersion` / `user`; the unsigned body supplies
315
- only `accountName` / `platform` / `isTest`. This webhook is the only source of
316
- `accountId` in the package, since no GraphQL read returns it. `status` and
313
+ `accountName`, `platform`, `isTest`, `timezone`, `apiUrl`, `status`, `rawStatus`,
314
+ `displayVersion`, and a **nullable** `user`. The **JSON body is the source of the
315
+ event data**; the fields it shares with the token (`installId` / `accountId` /
316
+ `status` / `displayVersion` / `user`) fall back to the **verified token** when
317
+ the body omits them. The token authenticates the whole delivery, so the body is
318
+ trusted within a verified request. This webhook is the only source of
319
+ `accountId`, `timezone`, and `apiUrl` in the package, since no GraphQL read
320
+ returns them — **persist them per install**. `apiUrl` is the install's app
321
+ endpoint: use it **as given** (don't reconstruct it from `appId`) — pass it as the
322
+ access service's `apiUrl`, or hand the whole install to
323
+ `createAccessServiceForInstall(install, { jwtSecret, issuer })`, which picks the
324
+ service by `platform` and wires the endpoint for you. Every event is a **full
325
+ snapshot** — an `update_installed` carries the same fields as the original
326
+ `installed`, so upsert by `installId` and take the latest `apiUrl`. `status` and
317
327
  `platform` are **nullable** — a value this version doesn't recognise stays `null`
318
328
  (wire value on `rawStatus`) instead of being coerced, so a newer registry can't be
319
329
  mistaken for an older one. (The older `verifyInstallWebhook(token, secret)` is
package/dist/index.cjs CHANGED
@@ -247,15 +247,6 @@ var PeekHttpError = class extends Error {
247
247
  this.body = body;
248
248
  }
249
249
  };
250
- var InvalidPeekTokenError = class extends Error {
251
- /** The token field that was missing or empty (e.g. `"user.id"`). */
252
- field;
253
- constructor(field) {
254
- super(`Peek token is missing required field "${field}"`);
255
- this.name = "InvalidPeekTokenError";
256
- this.field = field;
257
- }
258
- };
259
250
  var PiiAccessDisabledError = class extends Error {
260
251
  /** The name of the operation that was blocked (e.g. `"makePayment"`). */
261
252
  operation;
@@ -2044,7 +2035,8 @@ var GraphQLClient = class {
2044
2035
  );
2045
2036
  }
2046
2037
  endpoint(endpointName) {
2047
- const { baseUrl, appId, endpointPathPrefix } = this.options;
2038
+ const { apiUrl, baseUrl, appId, endpointPathPrefix } = this.options;
2039
+ if (apiUrl) return apiUrl;
2048
2040
  const prefix = endpointPathPrefix ? `${endpointPathPrefix}/` : "";
2049
2041
  return `${baseUrl}/${appId}/${prefix}${endpointName}`;
2050
2042
  }
@@ -2059,6 +2051,11 @@ var GraphQLClient = class {
2059
2051
  return headers;
2060
2052
  }
2061
2053
  };
2054
+
2055
+ // src/models/peek/auth-token.ts
2056
+ var PEEK_PLATFORMS = ["peek", "cng", "acme"];
2057
+
2058
+ // src/internal/peek/peek-auth-token.ts
2062
2059
  var PEEK_TOKEN_ISSUER = "app_registry_v2";
2063
2060
  var PEEK_TOKEN_AUDIENCE = "Joken";
2064
2061
  var BEARER_PREFIX = "bearer ";
@@ -2066,6 +2063,16 @@ function stripBearer(token) {
2066
2063
  const trimmed = token.trim();
2067
2064
  return trimmed.toLowerCase().startsWith(BEARER_PREFIX) ? trimmed.slice(BEARER_PREFIX.length).trim() : trimmed;
2068
2065
  }
2066
+ var NULLISH_STRINGS = /* @__PURE__ */ new Set(["", "null"]);
2067
+ function cleanTokenString(value) {
2068
+ if (typeof value !== "string") return null;
2069
+ const trimmed = value.trim();
2070
+ return NULLISH_STRINGS.has(trimmed.toLowerCase()) ? null : trimmed;
2071
+ }
2072
+ function cleanTokenPlatform(value) {
2073
+ const cleaned = cleanTokenString(value);
2074
+ return cleaned !== null && PEEK_PLATFORMS.includes(cleaned) ? cleaned : null;
2075
+ }
2069
2076
  function verifyPeekJwt(token, secret) {
2070
2077
  return jwt__namespace.verify(stripBearer(token), secret, {
2071
2078
  issuer: PEEK_TOKEN_ISSUER,
@@ -2073,16 +2080,14 @@ function verifyPeekJwt(token, secret) {
2073
2080
  });
2074
2081
  }
2075
2082
  function mapPeekTokenUser(u) {
2076
- if (!u.id) {
2077
- throw new InvalidPeekTokenError("user.id");
2078
- }
2083
+ if (!u) return null;
2079
2084
  return {
2080
- email: u.email,
2081
- id: u.id,
2082
- isAdmin: u.is_admin,
2083
- locale: u.locale,
2084
- name: u.name,
2085
- platform: u.platform
2085
+ email: cleanTokenString(u.email),
2086
+ id: cleanTokenString(u.id),
2087
+ isAdmin: typeof u.is_admin === "boolean" ? u.is_admin : null,
2088
+ locale: cleanTokenString(u.locale),
2089
+ name: cleanTokenString(u.name),
2090
+ platform: cleanTokenPlatform(u.platform)
2086
2091
  };
2087
2092
  }
2088
2093
 
@@ -3561,25 +3566,27 @@ var PeekAccessService = class {
3561
3566
  bookingService;
3562
3567
  reviewService;
3563
3568
  constructor(config) {
3569
+ const hasApiUrl = !!config.apiUrl;
3564
3570
  const isV2 = config.mode === "v2";
3565
3571
  requireNonEmpty(config.installId, "installId", "PeekAccessService");
3566
3572
  requireNonEmpty(config.jwtSecret, "jwtSecret", "PeekAccessService");
3567
3573
  requireNonEmpty(config.issuer, "issuer", "PeekAccessService");
3568
- requireNonEmpty(config.appId, "appId", "PeekAccessService");
3569
- if (!isV2) requireNonEmpty(config.gatewayKey ?? "", "gatewayKey", "PeekAccessService");
3574
+ if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "PeekAccessService");
3575
+ if (!hasApiUrl && !isV2) requireNonEmpty(config.gatewayKey ?? "", "gatewayKey", "PeekAccessService");
3570
3576
  this.jwtSecret = config.jwtSecret;
3571
3577
  const logger = config.logger ?? noopLogger;
3572
3578
  const tokens = createTokenManager(config);
3573
3579
  const defaultBaseUrl = isV2 ? DEFAULT_V2_BASE_URL : DEFAULT_BASE_URL;
3574
3580
  this.client = new GraphQLClient({
3575
- baseUrl: config.baseUrl ?? defaultBaseUrl,
3576
- appId: config.appId,
3581
+ apiUrl: config.apiUrl,
3582
+ baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? defaultBaseUrl,
3583
+ appId: hasApiUrl ? void 0 : config.appId,
3577
3584
  gatewayKey: config.gatewayKey,
3578
3585
  getToken: () => tokens.getToken(),
3579
3586
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
3580
3587
  logger,
3581
3588
  fetchFn: config.fetch ?? globalThis.fetch,
3582
- endpointPathPrefix: isV2 ? V2_EXTENDABLE_SLUG : void 0
3589
+ endpointPathPrefix: !hasApiUrl && isV2 ? V2_EXTENDABLE_SLUG : void 0
3583
3590
  });
3584
3591
  this.productServiceOptions = {
3585
3592
  itemOptionsPageSize: config.itemOptionsPageSize
@@ -3600,13 +3607,15 @@ var PeekAccessService = class {
3600
3607
  * malformed
3601
3608
  * - `TokenExpiredError` — past `exp`
3602
3609
  * - `NotBeforeError` — before `nbf`
3603
- * - `InvalidPeekTokenError` — signature valid but the `user` block is missing
3604
- * its `id`
3610
+ *
3611
+ * Claim *extraction* is defensive: once the signature verifies, a token with
3612
+ * no `user` block yields `claims.user === null`, and any individual user field
3613
+ * that is absent or a sentinel (`""`, `"null"`) is normalized to `null`. This
3614
+ * method never throws on a shape problem — only on a verification failure.
3605
3615
  *
3606
3616
  * @throws {JsonWebTokenError} signature invalid or token malformed
3607
3617
  * @throws {TokenExpiredError} token has expired
3608
3618
  * @throws {NotBeforeError} token not yet valid
3609
- * @throws {InvalidPeekTokenError} verified token has no `user.id`
3610
3619
  */
3611
3620
  verifyPeekAuthToken(token) {
3612
3621
  const payload = verifyPeekJwt(token, this.jwtSecret);
@@ -4007,7 +4016,8 @@ var RestClient = class {
4007
4016
  );
4008
4017
  }
4009
4018
  endpoint(path) {
4010
- const { baseUrl, appId, extendableSlug } = this.options;
4019
+ const { apiUrl, baseUrl, appId, extendableSlug } = this.options;
4020
+ if (apiUrl) return `${apiUrl}/${path}`;
4011
4021
  return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
4012
4022
  }
4013
4023
  buildHeaders() {
@@ -4024,15 +4034,17 @@ var CngAccessService = class {
4024
4034
  client;
4025
4035
  productService;
4026
4036
  constructor(config) {
4037
+ const hasApiUrl = !!config.apiUrl;
4027
4038
  requireNonEmpty(config.installId, "installId", "CngAccessService");
4028
4039
  requireNonEmpty(config.jwtSecret, "jwtSecret", "CngAccessService");
4029
4040
  requireNonEmpty(config.issuer, "issuer", "CngAccessService");
4030
- requireNonEmpty(config.appId, "appId", "CngAccessService");
4041
+ if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "CngAccessService");
4031
4042
  const logger = config.logger ?? noopLogger;
4032
4043
  const tokens = createTokenManager(config);
4033
4044
  this.client = new RestClient({
4034
- baseUrl: config.baseUrl ?? DEFAULT_BASE_URL2,
4035
- appId: config.appId,
4045
+ apiUrl: config.apiUrl,
4046
+ baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? DEFAULT_BASE_URL2,
4047
+ appId: hasApiUrl ? void 0 : config.appId,
4036
4048
  extendableSlug: CNG_EXTENDABLE_SLUG,
4037
4049
  getToken: () => tokens.getToken(),
4038
4050
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
@@ -4138,7 +4150,8 @@ var RestClient2 = class {
4138
4150
  );
4139
4151
  }
4140
4152
  endpoint(path) {
4141
- const { baseUrl, appId, extendableSlug } = this.options;
4153
+ const { apiUrl, baseUrl, appId, extendableSlug } = this.options;
4154
+ if (apiUrl) return `${apiUrl}/${path}`;
4142
4155
  return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
4143
4156
  }
4144
4157
  buildHeaders() {
@@ -4155,15 +4168,17 @@ var AcmeAccessService = class {
4155
4168
  client;
4156
4169
  productService;
4157
4170
  constructor(config) {
4171
+ const hasApiUrl = !!config.apiUrl;
4158
4172
  requireNonEmpty(config.installId, "installId", "AcmeAccessService");
4159
4173
  requireNonEmpty(config.jwtSecret, "jwtSecret", "AcmeAccessService");
4160
4174
  requireNonEmpty(config.issuer, "issuer", "AcmeAccessService");
4161
- requireNonEmpty(config.appId, "appId", "AcmeAccessService");
4175
+ if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "AcmeAccessService");
4162
4176
  const logger = config.logger ?? noopLogger;
4163
4177
  const tokens = createTokenManager(config);
4164
4178
  this.client = new RestClient2({
4165
- baseUrl: config.baseUrl ?? DEFAULT_BASE_URL3,
4166
- appId: config.appId,
4179
+ apiUrl: config.apiUrl,
4180
+ baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? DEFAULT_BASE_URL3,
4181
+ appId: hasApiUrl ? void 0 : config.appId,
4167
4182
  extendableSlug: ACME_EXTENDABLE_SLUG,
4168
4183
  getToken: () => tokens.getToken(),
4169
4184
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
@@ -4187,6 +4202,23 @@ var AcmeAccessService = class {
4187
4202
  }
4188
4203
  };
4189
4204
 
4205
+ // src/access-service-factory.ts
4206
+ function createAccessServiceForInstall(install, config) {
4207
+ const shared = { ...config, installId: install.installId, apiUrl: install.apiUrl };
4208
+ switch (install.platform) {
4209
+ case "peek":
4210
+ return new PeekAccessService(shared);
4211
+ case "cng":
4212
+ return new CngAccessService(shared);
4213
+ case "acme":
4214
+ return new AcmeAccessService(shared);
4215
+ default:
4216
+ throw new Error(
4217
+ `createAccessServiceForInstall: unknown or missing platform "${install.platform}" for install "${install.installId}"`
4218
+ );
4219
+ }
4220
+ }
4221
+
4190
4222
  // src/internal/peek/bookings/booking-webhook.ts
4191
4223
  var PAYLOAD_BOOKING_KEY = "booking";
4192
4224
  var VALUE_OPEN_TOKEN = "value {";
@@ -4277,9 +4309,6 @@ function extractWaiverNode(payload) {
4277
4309
  return record;
4278
4310
  }
4279
4311
 
4280
- // src/models/peek/auth-token.ts
4281
- var PEEK_PLATFORMS = ["peek", "cng", "acme"];
4282
-
4283
4312
  // src/models/peek/install.ts
4284
4313
  var INSTALL_STATUSES = [
4285
4314
  "installed",
@@ -4310,20 +4339,27 @@ function toPeekPlatform(raw) {
4310
4339
  // src/internal/peek/installs/install-webhook.ts
4311
4340
  function parseInstallWebhook(token, body, secret) {
4312
4341
  const payload = verifyPeekJwt(token, secret);
4313
- const account = extractInstallWebhookBody(body).account ?? {};
4314
- const rawStatus = payload.status ?? "";
4342
+ const parsed = extractInstallWebhookBody(body);
4343
+ const account = parsed.account ?? {};
4344
+ const rawStatus = parsed.status || payload.status || "";
4315
4345
  return {
4316
- installId: payload.sub ?? "",
4317
- accountId: payload.account?.id ?? "",
4346
+ installId: parsed.install_id || payload.sub || "",
4347
+ accountId: account.id || payload.account?.id || "",
4318
4348
  accountName: account.name || "",
4319
4349
  platform: toPeekPlatform(account.platform || ""),
4320
4350
  isTest: Boolean(account.is_test),
4351
+ timezone: account.timezone || "",
4352
+ apiUrl: parsed.api?.url || "",
4321
4353
  status: toInstallStatus(rawStatus),
4322
4354
  rawStatus,
4323
- displayVersion: payload.display_version ?? "",
4324
- user: payload.user ? mapPeekTokenUser(payload.user) : null
4355
+ displayVersion: parsed.display_version || payload.display_version || "",
4356
+ user: resolveInstallUser(parsed.modified_by, payload.user)
4325
4357
  };
4326
4358
  }
4359
+ function resolveInstallUser(bodyUser, tokenUser) {
4360
+ if (cleanTokenString(bodyUser?.id) !== null) return mapPeekTokenUser(bodyUser);
4361
+ return mapPeekTokenUser(tokenUser);
4362
+ }
4327
4363
  function verifyInstallWebhook(token, secret) {
4328
4364
  const payload = verifyPeekJwt(token, secret);
4329
4365
  return {
@@ -4331,7 +4367,7 @@ function verifyInstallWebhook(token, secret) {
4331
4367
  account: { id: payload.account?.id ?? "" },
4332
4368
  status: payload.status ?? "",
4333
4369
  displayVersion: payload.display_version ?? "",
4334
- user: payload.user ? mapPeekTokenUser(payload.user) : null
4370
+ user: mapPeekTokenUser(payload.user)
4335
4371
  };
4336
4372
  }
4337
4373
 
@@ -4349,7 +4385,6 @@ exports.CngApiError = CngApiError;
4349
4385
  exports.CngProductService = CngProductService;
4350
4386
  exports.DailyNoteService = DailyNoteService;
4351
4387
  exports.INSTALL_STATUSES = INSTALL_STATUSES;
4352
- exports.InvalidPeekTokenError = InvalidPeekTokenError;
4353
4388
  exports.MembershipService = MembershipService;
4354
4389
  exports.PeekAccessService = PeekAccessService;
4355
4390
  exports.PeekGraphQLError = PeekGraphQLError;
@@ -4364,6 +4399,7 @@ exports.ResellerService = ResellerService;
4364
4399
  exports.ResourcePoolService = ResourcePoolService;
4365
4400
  exports.ReviewService = ReviewService;
4366
4401
  exports.TimeslotService = TimeslotService;
4402
+ exports.createAccessServiceForInstall = createAccessServiceForInstall;
4367
4403
  exports.noopLogger = noopLogger;
4368
4404
  exports.parseBookingWebhook = parseBookingWebhook;
4369
4405
  exports.parseInstallWebhook = parseInstallWebhook;