@peektravel/app-utilities 0.6.1 → 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/docs/webhooks.md CHANGED
@@ -1,34 +1,34 @@
1
1
  # Webhooks
2
2
 
3
3
  A guide for wiring a receiver app up to Peek "backoffice" webhooks using
4
- `@peektravel/app-utilities`. Four webhook types are supported today —
5
- **booking**, **waiver**, **install-event**, and **install-status** — and each has
6
- a helper that turns the delivered payload into a clean data model. They differ in
7
- one important way: a booking webhook is configured with a GraphQL query (so the
8
- package documents the exact query to register), waiver and install-event webhooks
9
- have fixed payloads (so there's nothing to register beyond subscribing to the
10
- event), and an install-status webhook delivers a **signed token** (so the helper
11
- *verifies* it rather than just parsing it).
4
+ `@peektravel/app-utilities`. Three webhook types are supported today —
5
+ **booking**, **waiver**, and **install** — and each has a helper that turns the
6
+ delivered payload into a clean data model. They differ in one important way: a
7
+ booking webhook is configured with a GraphQL query (so the package documents the
8
+ exact query to register), a waiver webhook has a fixed payload (so there's nothing
9
+ to register beyond subscribing to the event), and an install webhook delivers a
10
+ **signed token alongside a JSON body** (so the helper *verifies* the token before
11
+ merging the two).
12
12
 
13
13
  | Webhook | Register | Handle the delivery |
14
14
  | --- | --- | --- |
15
15
  | Booking | paste a GraphQL query into the external config (below) | `parseBookingWebhook(body)` → `Booking` |
16
16
  | Waiver | subscribe to the event — **no query needed** | `parseWaiverWebhook(body)` → `Waiver` |
17
- | Install-event | subscribe to the event — **no query needed** | `parseInstallEvent(body)` → `InstallEvent` |
18
- | Install-status | subscribe to the event — **no query needed** | `verifyInstallWebhook(token, secret)` → `InstallWebhookClaims` |
19
-
20
- The booking, waiver, and install-event parsers are pure transforms — no auth, no
21
- network, construct nothing; **authenticating those deliveries is the receiver's
22
- job**. The install-status webhook is different: its payload *is* a signed JWT, so
23
- `verifyInstallWebhook` checks the signature for you.
24
-
25
- **Install-event and install-status are two channels for the same lifecycle
26
- event.** Install-event arrives as a plain JSON body and is the richer of the two —
27
- it is the only delivery that reports the account's **name**, **platform**, and
28
- **test** flag, which makes it the authoritative source of `InstallIdentity`.
29
- Install-status arrives as a signed JWT and carries only the account **id**, plus
30
- the acting `user`. Use install-event to record who an install is; use
31
- install-status when you need the delivery to authenticate itself.
17
+ | Install | subscribe to the event — **no query needed** | `parseInstallWebhook(token, body, secret)` → `InstallWebhook` |
18
+
19
+ The booking and waiver parsers are pure transforms — no auth, no network,
20
+ construct nothing; **authenticating those deliveries is the receiver's job**. The
21
+ install webhook is different: it carries a signed JWT, so `parseInstallWebhook`
22
+ checks the signature for you before returning the merged event.
23
+
24
+ **The install webhook delivers two payloads at once.** A signed
25
+ `app_registry_v2` JWT authenticates the notification and carries the account
26
+ **id**, install id, status, version, and the acting `user`; a plain JSON body
27
+ alongside it adds the account's **name**, **platform**, and **test** flag.
28
+ `parseInstallWebhook` verifies the token and merges both into one flat
29
+ `InstallWebhook`, so you make a single call. The verified token is authoritative
30
+ for the fields it carries; the unsigned body is trusted only for name/platform/
31
+ test.
32
32
 
33
33
  # Booking webhooks
34
34
 
@@ -163,42 +163,63 @@ The resulting `Waiver` is flat:
163
163
 
164
164
  Authenticating the delivery is the receiver's responsibility, as with bookings.
165
165
 
166
- # Install-event webhooks
166
+ # Install webhooks
167
167
 
168
168
  ## The problem this solves
169
169
 
170
- Peek's app registry POSTs a plain JSON body when an app is installed,
171
- uninstalled, or updated. That body is where an app first learns **who the
172
- install belongs to** — and it is the only place it ever learns it: no GraphQL
173
- read and no peek-auth token returns the account id, so whatever you persist from
174
- this delivery is all you will ever have.
175
-
176
- `parseInstallEvent` turns the snake_case body into a clean `InstallEvent` whose
177
- `identity` is a flat, JSON-safe object built to be stored as a unit and handed
178
- straight back to an access service later.
170
+ Peek's app registry notifies your app of install lifecycle changes — installed,
171
+ uninstalled, updated — with a single POST that carries **two payloads at once**:
172
+ a **signed `app_registry_v2` JWT** and a plain **JSON body**.
173
+
174
+ - The **JWT** is the security boundary. Verifying its signature proves the
175
+ notification really came from Peek before your app acts on it (for example,
176
+ de-provisioning an uninstall). It carries the install id, account **id**,
177
+ status, version, and the acting `user`.
178
+ - The **JSON body** is the enrichment channel. It adds the account's **name**,
179
+ **platform**, and **test** flag — the fields the token does not carry.
180
+
181
+ This webhook is also where an app first learns **who the install belongs to** —
182
+ and the only place it ever learns it: no GraphQL read returns the account id, so
183
+ whatever you persist from this delivery is all you will ever have.
184
+
185
+ `parseInstallWebhook(token, body, secret)` verifies the token (issuer, audience,
186
+ expiry, HMAC — the security-critical boilerplate you'd otherwise hand-roll) and
187
+ merges both payloads into one flat, JSON-safe `InstallWebhook` you store as a
188
+ unit. The **verified token** is authoritative for the fields it carries; the
189
+ unsigned body is trusted only for name/platform/test.
179
190
 
180
191
  ## Step 1 — subscribe to the event (external, one-time)
181
192
 
182
193
  Nothing to register in code: subscribe your endpoint to the install event in the
183
194
  App Store config. There is no GraphQL selection to paste.
184
195
 
185
- ## Step 2 — parse the delivered body
196
+ ## Step 2 — verify and parse the delivery
197
+
198
+ Extract the JWT from the delivery (the request token, or the `Authorization`
199
+ header with the `Bearer ` prefix stripped) and pass it — along with the JSON body
200
+ and your app's signing secret (the same `jwtSecret` you construct
201
+ `PeekAccessService` with) — to `parseInstallWebhook`:
186
202
 
187
203
  ```ts
188
- import { parseInstallEvent } from "@peektravel/app-utilities";
204
+ import { parseInstallWebhook, type InstallWebhook } from "@peektravel/app-utilities";
189
205
 
190
206
  app.post("/webhooks/install", async (req, res) => {
191
- const event = parseInstallEvent(req.body);
207
+ let event: InstallWebhook;
208
+ try {
209
+ event = parseInstallWebhook(req.token, req.body, process.env.PEEK_INTERNAL_SECRET!);
210
+ } catch {
211
+ return res.sendStatus(401); // signature/issuer/audience/expiry failed
212
+ }
192
213
 
193
214
  switch (event.status) {
194
215
  case "installed":
195
- await provision(event.identity, event.displayVersion);
216
+ await provision(event);
196
217
  break;
197
218
  case "uninstalled":
198
- await deprovision(event.identity);
219
+ await deprovision(event);
199
220
  break;
200
221
  case "update_installed":
201
- await recordVersion(event.identity, event.displayVersion);
222
+ await recordVersion(event);
202
223
  break;
203
224
  default:
204
225
  // Peek sent a status this package version does not know. Fail loudly —
@@ -210,29 +231,48 @@ app.post("/webhooks/install", async (req, res) => {
210
231
  });
211
232
  ```
212
233
 
213
- The resulting `InstallEvent`:
214
-
215
- | Field | Type | From | Notes |
216
- | --- | --- | --- | --- |
217
- | `status` | `InstallStatus \| null` | `status` | `null` when unrecognised — see below |
218
- | `rawStatus` | `string` | `status` | always the wire value, for logging |
219
- | `displayVersion` | `string` | `display_version` | `""` when absent |
220
- | `identity` | `InstallIdentity` | `install_id` + `account` | the core account data |
221
-
222
- …and the `InstallIdentity` inside it:
223
-
224
- | Field | Type | From | Notes |
225
- | --- | --- | --- | --- |
226
- | `installId` | `string` | `install_id` | Peek-assigned install UUID |
227
- | `accountId` | `string` | `account.id` | **also called the partner ID** |
228
- | `accountName` | `string` | `account.name` | partner display name |
229
- | `platform` | `PeekPlatform \| null` | `account.platform` | **persist per install** — see below; `null` when unrecognised |
230
- | `isTest` | `boolean` | `account.is_test` | defaults to `false` |
234
+ `parseInstallWebhook` validates the HMAC signature, the token expiry, the
235
+ `"app_registry_v2"` issuer, and the `"Joken"` audience — the same checks as
236
+ [`PeekAccessService.verifyPeekAuthToken`](../README.md#access-options--pii). It
237
+ throws the underlying `jsonwebtoken` error (`JsonWebTokenError` /
238
+ `TokenExpiredError` / `NotBeforeError`) on any verification failure, so a single
239
+ `try/catch` → `401` covers every bad-token case. The signature checks are strict
240
+ (they are the security boundary); claim *extraction* is defensive, so a
241
+ validly-signed token or a missing/garbled body yields empty fields rather than
242
+ throwing.
243
+
244
+ The resulting `InstallWebhook` is flat:
245
+
246
+ | Field | Type | Source | From | Notes |
247
+ | --- | --- | --- | --- | --- |
248
+ | `installId` | `string` | token | `sub` | Peek-assigned install UUID |
249
+ | `accountId` | `string` | token | `account.id` | **also called the partner ID** |
250
+ | `accountName` | `string` | body | `account.name` | partner display name; `""` when absent |
251
+ | `platform` | `PeekPlatform \| null` | body | `account.platform` | **persist per install** — see below; `null` when unrecognised |
252
+ | `isTest` | `boolean` | body | `account.is_test` | defaults to `false` |
253
+ | `status` | `InstallStatus \| null` | token | `status` | `null` when unrecognised — see below |
254
+ | `rawStatus` | `string` | token | `status` | always the wire value, for logging |
255
+ | `displayVersion` | `string` | token | `display_version` | `""` when absent |
256
+ | `user` | `PeekAuthTokenUser \| null` | token | `user` | `null` for system-initiated events |
257
+
258
+ Because the four fields present in **both** payloads (`installId`, `accountId`,
259
+ `status`, `displayVersion`) are read from the **verified token**, a mismatched or
260
+ forged body can never override an authenticated identity. Unlike
261
+ `verifyPeekAuthToken`, `user` is nullable: install lifecycle events are often
262
+ system-initiated (no acting user), so the parser tolerates `user: null`.
263
+
264
+ > **Migrating from `verifyInstallWebhook` / `parseInstallEvent`?**
265
+ > `verifyInstallWebhook(token, secret)` still works but is `@deprecated` — it
266
+ > reads only the token, so it can't report the account name/platform/test flag.
267
+ > `parseInstallEvent(body)` has been **removed**. Replace both with
268
+ > `parseInstallWebhook(token, body, secret)`; the return is now flat, so read
269
+ > `event.accountId` directly instead of `event.identity.accountId` or
270
+ > `claims.account.id`.
231
271
 
232
272
  ## Persist `platform` per install
233
273
 
234
- `identity.platform` is not cosmetic. It decides which APIs the install is served
235
- by, and therefore which access service you construct for it:
274
+ `event.platform` is not cosmetic. It decides which APIs the install is served by,
275
+ and therefore which access service you construct for it:
236
276
 
237
277
  | `platform` | Access service | Gateway |
238
278
  | --- | --- | --- |
@@ -246,10 +286,10 @@ this event, and it must be stored alongside `installId` and `accountId`.
246
286
 
247
287
  ## Why `status` and `platform` can be `null`
248
288
 
249
- `parseInstallEvent` never throws. The sender is first-party and the payload
250
- contract is Peek's own, so a malformed body is not the failure worth designing
251
- for. The failure that *is* real is the contract **growing** — a new status, or a
252
- fourth platform.
289
+ `parseInstallWebhook` never throws on the *body* (only token verification can
290
+ throw). The sender is first-party and the payload contract is Peek's own, so a
291
+ malformed body is not the failure worth designing for. The failure that *is* real
292
+ is the contract **growing** — a new status, or a fourth platform.
253
293
 
254
294
  Both are therefore reported as `null` rather than coerced into a known value,
255
295
  with the wire value preserved on `rawStatus`. Coercing would be actively
@@ -257,68 +297,3 @@ dangerous: Peek treats any 2xx as delivered and does not redeliver, so quietly
257
297
  mapping an unknown status onto a no-op loses a lifecycle transition permanently,
258
298
  and defaulting an unknown platform would point that install at the wrong gateway.
259
299
  Give both a `default:` branch that fails loudly.
260
-
261
- # Install-status webhooks
262
-
263
- ## The problem this solves
264
-
265
- Peek's app registry notifies your app of install lifecycle changes — installed,
266
- uninstalled, status changed — by POSTing a **signed `app_registry_v2` JWT**.
267
- Unlike a booking or waiver webhook, the whole payload *is* the token, so
268
- verifying its signature is the point: it proves the notification really came from
269
- Peek before your app acts on it (for example, de-provisioning an uninstall).
270
- Hand-rolling that check (issuer, audience, expiry, HMAC) is exactly the
271
- security-critical boilerplate `verifyInstallWebhook` removes.
272
-
273
- ## Step 1 — subscribe to the event (external, one-time)
274
-
275
- Nothing to register in code: subscribe your endpoint to the install-status event
276
- in the App Store config. There is no GraphQL selection to paste.
277
-
278
- ## Step 2 — verify the delivered token
279
-
280
- Extract the JWT from the delivery (the request body, or the `Authorization`
281
- header with the `Bearer ` prefix stripped) and pass it — plus your app's signing
282
- secret (the same `jwtSecret` you construct `PeekAccessService` with) — to
283
- `verifyInstallWebhook`:
284
-
285
- ```ts
286
- import { verifyInstallWebhook, type InstallWebhookClaims } from "@peektravel/app-utilities";
287
-
288
- app.post("/webhooks/install", (req, res) => {
289
- let claims: InstallWebhookClaims;
290
- try {
291
- claims = verifyInstallWebhook(req.body /* raw JWT string */, process.env.PEEK_INTERNAL_SECRET!);
292
- } catch {
293
- return res.sendStatus(401); // signature/issuer/audience/expiry failed
294
- }
295
-
296
- if (claims.status === "uninstalled") {
297
- // tear down this install's resources
298
- }
299
- res.sendStatus(200);
300
- });
301
- ```
302
-
303
- `verifyInstallWebhook` validates the HMAC signature, the token expiry, the
304
- `"app_registry_v2"` issuer, and the `"Joken"` audience — the same checks as
305
- [`PeekAccessService.verifyPeekAuthToken`](../README.md#access-options--pii) — then
306
- maps the payload to the clean `InstallWebhookClaims`. It throws the underlying
307
- `jsonwebtoken` error (`JsonWebTokenError` / `TokenExpiredError` /
308
- `NotBeforeError`) on any verification failure, so a single `try/catch` → `401`
309
- covers every bad-token case.
310
-
311
- The signature checks are strict (they are the security boundary); claim
312
- *extraction* is defensive, so a validly-signed token that omits an optional field
313
- yields an empty value rather than throwing. The resulting `InstallWebhookClaims`:
314
-
315
- | Field | Type | From | Notes |
316
- | --- | --- | --- | --- |
317
- | `installId` | `string` | `sub` | Peek-assigned install UUID |
318
- | `account.id` | `string` | `account.id` | account that owns the install |
319
- | `status` | `string` | `status` | e.g. `"installed"`, `"uninstalled"` |
320
- | `displayVersion` | `string` | `display_version` | `""` when absent |
321
- | `user` | `PeekAuthTokenUser \| null` | `user` | `null` for system-initiated events |
322
-
323
- Unlike `verifyPeekAuthToken`, `user` is nullable: install lifecycle events are
324
- often system-initiated (no acting user), so the verifier tolerates `user: null`.
package/llms.txt CHANGED
@@ -163,10 +163,10 @@ stay internal.
163
163
  importable; branch with `instanceof`.
164
164
  - Plain `Error` for validation/precondition failures thrown before any request.
165
165
 
166
- ## Webhooks (booking + waiver + install-event + install-status)
166
+ ## Webhooks (booking + waiver + install)
167
167
 
168
- Three pure-transform parsers plus one signature-verifier. The parsers need no
169
- auth/network/`PeekAccessService`; accept the `{<key>:…}` envelope, a bare node,
168
+ Two pure-transform parsers plus one verify-and-parse call. The pure parsers need
169
+ no auth/network/`PeekAccessService`; accept the `{<key>:…}` envelope, a bare node,
170
170
  or a JSON string; never throw on malformed input. Registration is done **once in
171
171
  an external system** (App Store config), not from code.
172
172
 
@@ -180,31 +180,29 @@ an external system** (App Store config), not from code.
180
180
  register** — just subscribe to the `agreement_signature_created` event. Takes
181
181
  the same `AccessOptions`; by default (`fullCustomerAccess` off) `guestName` and
182
182
  `fileUrl` are redacted — pass `{ fullCustomerAccess: true }` to keep them.
183
- - `parseInstallEvent(body): InstallEvent` — maps the fixed `snake_case` install
184
- lifecycle body (`status`, `install_id`, `display_version`, `account`) to
185
- `{ status, rawStatus, displayVersion, identity }`. `identity` is the clean
186
- `InstallIdentity` (`installId`, `accountId` — **also called the partner ID** —
187
- `accountName`, `platform`, `isTest`): flat, JSON-safe, meant to be persisted as
188
- a unit. This body is the **only** source of `accountId` in the package — no
189
- GraphQL read and no token returns it. `status` and `platform` are **nullable**:
190
- a value this version does not recognise maps to `null` (wire value kept on
191
- `rawStatus`) rather than being coerced, because Peek does not redeliver a 2xx
192
- and a coerced status silently drops a lifecycle transition. Give both a
193
- `default:` branch that fails loudly. No query to register; just subscribe.
194
- - `verifyInstallWebhook(token, secret): InstallWebhookClaims` — for install
195
- lifecycle events (installed/uninstalled/status changed). The payload *is* a
196
- signed `app_registry_v2` JWT, so this **verifies** it (HMAC signature + expiry +
183
+ - `parseInstallWebhook(token, body, secret): InstallWebhook` — the install
184
+ webhook delivers **two payloads at once**: a signed `app_registry_v2` JWT and a
185
+ JSON body. This **verifies** the token (HMAC signature + expiry +
197
186
  `app_registry_v2` issuer + `Joken` audience — same checks as
198
- `verifyPeekAuthToken`) rather than just parsing. Returns clean claims
199
- (`installId`, `account.id`, `status`, `displayVersion`, and `user` which is
200
- **nullable** for system-initiated events). Throws the underlying `jsonwebtoken`
201
- error on any failure — one `try/catch` → `401`. `secret` is the app's
202
- `jwtSecret`. No query to register; just subscribe to the event. This and
203
- `parseInstallEvent` are **two channels for the same lifecycle event**: this one
204
- authenticates itself but carries only `account.id`; `parseInstallEvent` carries
205
- the account name/platform/test flag and is the authoritative `InstallIdentity`
206
- source. The app secret is app-wide, so either channel verifies/parses fine on a
207
- brand-new install, before any record exists.
187
+ `verifyPeekAuthToken`, throwing the underlying `jsonwebtoken` error on failure,
188
+ one `try/catch` → `401`) and merges both into one flat, JSON-safe
189
+ `InstallWebhook`: `installId`, `accountId` (**also called the partner ID**),
190
+ `accountName`, `platform`, `isTest`, `status`, `rawStatus`, `displayVersion`,
191
+ `user` (nullable, for system-initiated events). The **verified token** is
192
+ authoritative for `installId`/`accountId`/`status`/`displayVersion`/`user`; the
193
+ unsigned body supplies only `accountName`/`platform`/`isTest`, so a forged body
194
+ can't override an authenticated field. This webhook is the **only** source of
195
+ `accountId` in the package — no GraphQL read returns it. `status` and `platform`
196
+ are **nullable**: a value this version does not recognise maps to `null` (wire
197
+ value kept on `rawStatus`) rather than being coerced, because Peek does not
198
+ redeliver a 2xx and a coerced status silently drops a lifecycle transition. Give
199
+ both a `default:` branch that fails loudly. `secret` is the app's `jwtSecret`,
200
+ app-wide, so it verifies fine on a brand-new install before any record exists.
201
+ No query to register; just subscribe to the event.
202
+ - **Deprecated / removed:** `verifyInstallWebhook(token, secret):
203
+ InstallWebhookClaims` still verifies the token but is `@deprecated` — it reads
204
+ only the token, missing the account name/platform/test flag. `parseInstallEvent`
205
+ (the JSON-body-only parser) has been **removed**. Use `parseInstallWebhook`.
208
206
 
209
207
  **Full guide + booking query-to-register: `docs/webhooks.md` (shipped).**
210
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peektravel/app-utilities",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
5
5
  "license": "MIT",
6
6
  "repository": {