@peektravel/app-utilities 0.6.0 → 0.7.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/README.md CHANGED
@@ -15,6 +15,10 @@ npm install @peektravel/app-utilities
15
15
  npm update @peektravel/app-utilities
16
16
  ```
17
17
 
18
+ Before updating to a new version, consult [`changelog.md`](./changelog.md) —
19
+ it records every caller-visible change grouped by version, including any
20
+ breaking changes and the concrete action you need to take to migrate.
21
+
18
22
  See [Releasing](#releasing) for how new versions are published.
19
23
 
20
24
  ## Usage
@@ -120,6 +124,10 @@ Two kinds of failures surface as exceptions:
120
124
  - `PiiAccessDisabledError` — a payment / booking-modification operation was
121
125
  called on an access service created without `fullCustomerAccess` (see [Access options
122
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.
123
131
 
124
132
  **Plain `Error` validation/precondition failures** thrown by the service layer
125
133
  *before* any network call — e.g. an empty config field, a `bookingId` that
@@ -238,20 +246,20 @@ Functions runtime) resolve correctly. Its only runtime dependency is
238
246
 
239
247
  ## Webhooks
240
248
 
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:
249
+ Receiver apps can consume Peek **booking**, **waiver**, and **install** webhooks
250
+ without hand-writing the payload handling. The booking and waiver webhooks have a
251
+ pure parser (construct nothing — no auth/network) that returns a clean model; the
252
+ install webhook delivers a signed token alongside a JSON body, so its helper
253
+ **verifies** the token before merging the two:
246
254
 
247
255
  ```ts
248
256
  import {
249
257
  parseBookingWebhook,
258
+ parseInstallWebhook,
250
259
  parseWaiverWebhook,
251
- verifyInstallWebhook,
252
260
  type Booking,
261
+ type InstallWebhook,
253
262
  type Waiver,
254
- type InstallWebhookClaims,
255
263
  } from "@peektravel/app-utilities";
256
264
 
257
265
  app.post("/booking-webhook", (req, res) => {
@@ -265,17 +273,23 @@ app.post("/waiver-webhook", (req, res) => {
265
273
  res.sendStatus(200);
266
274
  });
267
275
 
268
- app.post("/install-webhook", (req, res) => {
276
+ app.post("/install-webhook", async (req, res) => {
277
+ let event: InstallWebhook;
269
278
  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);
279
+ // Verifies the signed token, then merges in the JSON body:
280
+ event = parseInstallWebhook(req.token, req.body, process.env.PEEK_INTERNAL_SECRET!);
276
281
  } catch {
277
- res.sendStatus(401); // bad signature / issuer / audience / expired
282
+ return res.sendStatus(401); // bad signature / issuer / audience / expired
283
+ }
284
+ switch (event.status) {
285
+ case "installed": await provision(event); break;
286
+ case "uninstalled": await deprovision(event); break;
287
+ case "update_installed": await recordVersion(event); break;
288
+ // `status` is null when Peek sends one this version doesn't know. Peek
289
+ // doesn't redeliver a 2xx, so fail loudly rather than silently no-op.
290
+ default: return res.status(500).json({ error: `unknown status: ${event.rawStatus}` });
278
291
  }
292
+ res.sendStatus(200);
279
293
  });
280
294
  ```
281
295
 
@@ -284,17 +298,31 @@ JSON string and never throw on malformed input; **authenticating those deliverie
284
298
  is the receiver's job**. `parseWaiverWebhook` also takes an optional
285
299
  `AccessOptions` (`{ fullCustomerAccess }`) and redacts the participant `guestName`
286
300
  + 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:
301
+ below.
302
+
303
+ `parseInstallWebhook(token, body, secret)` is different: the delivery carries a
304
+ signed `app_registry_v2` JWT plus a JSON body. It validates the HMAC signature,
305
+ expiry, issuer, and `"Joken"` audience (the same checks as `verifyPeekAuthToken`,
306
+ throwing the underlying `jsonwebtoken` error — or `InvalidPeekTokenError` when a
307
+ present `user` block has no `id`), then returns one flat, JSON-safe
308
+ `InstallWebhook`: `installId`, `accountId` (**also called the partner ID**),
309
+ `accountName`, `platform`, `isTest`, `status`, `rawStatus`, `displayVersion`, and
310
+ a **nullable** `user`. The **verified token** is authoritative for `installId` /
311
+ `accountId` / `status` / `displayVersion` / `user`; the unsigned body supplies
312
+ only `accountName` / `platform` / `isTest`. This webhook is the only source of
313
+ `accountId` in the package, since no GraphQL read returns it. `status` and
314
+ `platform` are **nullable** — a value this version doesn't recognise stays `null`
315
+ (wire value on `rawStatus`) instead of being coerced, so a newer registry can't be
316
+ mistaken for an older one. (The older `verifyInstallWebhook(token, secret)` is
317
+ still exported but **`@deprecated`** — it reads only the token, missing the
318
+ account name/platform/test flag; `parseInstallEvent` has been **removed**.)
319
+
320
+ The booking and waiver webhooks differ on registration: a **booking** webhook's
321
+ payload shape is set by a GraphQL query configured **once in an external system**
322
+ (the App Store `broadcast_to_url` config) — this package documents and
323
+ drift-guards the exact query to paste there — whereas the **waiver** and
324
+ **install** webhooks have fixed payloads, so you just subscribe to their event
325
+ with no query. **The query to register and the full guide:
298
326
  [`docs/webhooks.md`](docs/webhooks.md) (shipped).**
299
327
 
300
328
  ## UI components (`/ui`)
package/dist/index.cjs CHANGED
@@ -247,6 +247,15 @@ 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
+ };
250
259
  var PiiAccessDisabledError = class extends Error {
251
260
  /** The name of the operation that was blocked (e.g. `"makePayment"`). */
252
261
  operation;
@@ -2059,6 +2068,9 @@ function verifyPeekJwt(token, secret) {
2059
2068
  });
2060
2069
  }
2061
2070
  function mapPeekTokenUser(u) {
2071
+ if (!u.id) {
2072
+ throw new InvalidPeekTokenError("user.id");
2073
+ }
2062
2074
  return {
2063
2075
  email: u.email,
2064
2076
  id: u.id,
@@ -3582,10 +3594,13 @@ var PeekAccessService = class {
3582
3594
  * malformed
3583
3595
  * - `TokenExpiredError` — past `exp`
3584
3596
  * - `NotBeforeError` — before `nbf`
3597
+ * - `InvalidPeekTokenError` — signature valid but the `user` block is missing
3598
+ * its `id`
3585
3599
  *
3586
3600
  * @throws {JsonWebTokenError} signature invalid or token malformed
3587
3601
  * @throws {TokenExpiredError} token has expired
3588
3602
  * @throws {NotBeforeError} token not yet valid
3603
+ * @throws {InvalidPeekTokenError} verified token has no `user.id`
3589
3604
  */
3590
3605
  verifyPeekAuthToken(token) {
3591
3606
  const payload = verifyPeekJwt(token, this.jwtSecret);
@@ -4256,7 +4271,53 @@ function extractWaiverNode(payload) {
4256
4271
  return record;
4257
4272
  }
4258
4273
 
4274
+ // src/models/peek/auth-token.ts
4275
+ var PEEK_PLATFORMS = ["peek", "cng", "acme"];
4276
+
4277
+ // src/models/peek/install.ts
4278
+ var INSTALL_STATUSES = [
4279
+ "installed",
4280
+ "uninstalled",
4281
+ "update_installed"
4282
+ ];
4283
+
4284
+ // src/internal/peek/installs/install-converter.ts
4285
+ function extractInstallWebhookBody(payload) {
4286
+ let body = payload;
4287
+ if (typeof body === "string") {
4288
+ try {
4289
+ body = JSON.parse(body);
4290
+ } catch {
4291
+ return {};
4292
+ }
4293
+ }
4294
+ if (!body || typeof body !== "object") return {};
4295
+ return body;
4296
+ }
4297
+ function toInstallStatus(raw) {
4298
+ return INSTALL_STATUSES.includes(raw) ? raw : null;
4299
+ }
4300
+ function toPeekPlatform(raw) {
4301
+ return PEEK_PLATFORMS.includes(raw) ? raw : null;
4302
+ }
4303
+
4259
4304
  // src/internal/peek/installs/install-webhook.ts
4305
+ function parseInstallWebhook(token, body, secret) {
4306
+ const payload = verifyPeekJwt(token, secret);
4307
+ const account = extractInstallWebhookBody(body).account ?? {};
4308
+ const rawStatus = payload.status ?? "";
4309
+ return {
4310
+ installId: payload.sub ?? "",
4311
+ accountId: payload.account?.id ?? "",
4312
+ accountName: account.name || "",
4313
+ platform: toPeekPlatform(account.platform || ""),
4314
+ isTest: Boolean(account.is_test),
4315
+ status: toInstallStatus(rawStatus),
4316
+ rawStatus,
4317
+ displayVersion: payload.display_version ?? "",
4318
+ user: payload.user ? mapPeekTokenUser(payload.user) : null
4319
+ };
4320
+ }
4260
4321
  function verifyInstallWebhook(token, secret) {
4261
4322
  const payload = verifyPeekJwt(token, secret);
4262
4323
  return {
@@ -4281,6 +4342,8 @@ exports.CngAccessService = CngAccessService;
4281
4342
  exports.CngApiError = CngApiError;
4282
4343
  exports.CngProductService = CngProductService;
4283
4344
  exports.DailyNoteService = DailyNoteService;
4345
+ exports.INSTALL_STATUSES = INSTALL_STATUSES;
4346
+ exports.InvalidPeekTokenError = InvalidPeekTokenError;
4284
4347
  exports.MembershipService = MembershipService;
4285
4348
  exports.PeekAccessService = PeekAccessService;
4286
4349
  exports.PeekGraphQLError = PeekGraphQLError;
@@ -4297,6 +4360,7 @@ exports.ReviewService = ReviewService;
4297
4360
  exports.TimeslotService = TimeslotService;
4298
4361
  exports.noopLogger = noopLogger;
4299
4362
  exports.parseBookingWebhook = parseBookingWebhook;
4363
+ exports.parseInstallWebhook = parseInstallWebhook;
4300
4364
  exports.parseWaiverWebhook = parseWaiverWebhook;
4301
4365
  exports.verifyInstallWebhook = verifyInstallWebhook;
4302
4366
  //# sourceMappingURL=index.cjs.map