@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/docs/webhooks.md CHANGED
@@ -2,24 +2,33 @@
2
2
 
3
3
  A guide for wiring a receiver app up to Peek "backoffice" webhooks using
4
4
  `@peektravel/app-utilities`. Three webhook types are supported today —
5
- **booking**, **waiver**, and **install-status** — and each has a helper that
6
- turns the delivered payload into a clean data model. They differ in one important
7
- way: a booking webhook is configured with a GraphQL query (so the package
8
- documents the exact query to register), a waiver webhook has a fixed payload (so
9
- there's nothing to register beyond subscribing to the event), and an
10
- install-status webhook delivers a **signed token** (so the helper *verifies* it
11
- rather than just parsing it).
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-status | subscribe to the event — **no query needed** | `verifyInstallWebhook(token, secret)` → `InstallWebhookClaims` |
17
+ | Install | subscribe to the event — **no query needed** | `parseInstallWebhook(token, body, secret)` → `InstallWebhook` |
18
18
 
19
19
  The booking and waiver parsers are pure transforms — no auth, no network,
20
20
  construct nothing; **authenticating those deliveries is the receiver's job**. The
21
- install-status webhook is different: its payload *is* a signed JWT, so
22
- `verifyInstallWebhook` checks the signature for you.
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.
23
32
 
24
33
  # Booking webhooks
25
34
 
@@ -154,67 +163,137 @@ The resulting `Waiver` is flat:
154
163
 
155
164
  Authenticating the delivery is the receiver's responsibility, as with bookings.
156
165
 
157
- # Install-status webhooks
166
+ # Install webhooks
158
167
 
159
168
  ## The problem this solves
160
169
 
161
170
  Peek's app registry notifies your app of install lifecycle changes — installed,
162
- uninstalled, status changed — by POSTing a **signed `app_registry_v2` JWT**.
163
- Unlike a booking or waiver webhook, the whole payload *is* the token, so
164
- verifying its signature is the point: it proves the notification really came from
165
- Peek before your app acts on it (for example, de-provisioning an uninstall).
166
- Hand-rolling that check (issuer, audience, expiry, HMAC) is exactly the
167
- security-critical boilerplate `verifyInstallWebhook` removes.
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.
168
190
 
169
191
  ## Step 1 — subscribe to the event (external, one-time)
170
192
 
171
- Nothing to register in code: subscribe your endpoint to the install-status event
172
- in the App Store config. There is no GraphQL selection to paste.
193
+ Nothing to register in code: subscribe your endpoint to the install event in the
194
+ App Store config. There is no GraphQL selection to paste.
173
195
 
174
- ## Step 2 — verify the delivered token
196
+ ## Step 2 — verify and parse the delivery
175
197
 
176
- Extract the JWT from the delivery (the request body, or the `Authorization`
177
- header with the `Bearer ` prefix stripped) and pass it — plus your app's signing
178
- secret (the same `jwtSecret` you construct `PeekAccessService` with) — to
179
- `verifyInstallWebhook`:
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`:
180
202
 
181
203
  ```ts
182
- import { verifyInstallWebhook, type InstallWebhookClaims } from "@peektravel/app-utilities";
204
+ import { parseInstallWebhook, type InstallWebhook } from "@peektravel/app-utilities";
183
205
 
184
- app.post("/webhooks/install", (req, res) => {
185
- let claims: InstallWebhookClaims;
206
+ app.post("/webhooks/install", async (req, res) => {
207
+ let event: InstallWebhook;
186
208
  try {
187
- claims = verifyInstallWebhook(req.body /* raw JWT string */, process.env.PEEK_INTERNAL_SECRET!);
209
+ event = parseInstallWebhook(req.token, req.body, process.env.PEEK_INTERNAL_SECRET!);
188
210
  } catch {
189
211
  return res.sendStatus(401); // signature/issuer/audience/expiry failed
190
212
  }
191
213
 
192
- if (claims.status === "uninstalled") {
193
- // tear down this install's resources
214
+ switch (event.status) {
215
+ case "installed":
216
+ await provision(event);
217
+ break;
218
+ case "uninstalled":
219
+ await deprovision(event);
220
+ break;
221
+ case "update_installed":
222
+ await recordVersion(event);
223
+ break;
224
+ default:
225
+ // Peek sent a status this package version does not know. Fail loudly —
226
+ // a 2xx here is a silently dropped lifecycle transition.
227
+ return res.status(500).json({ error: `unknown status: ${event.rawStatus}` });
194
228
  }
229
+
195
230
  res.sendStatus(200);
196
231
  });
197
232
  ```
198
233
 
199
- `verifyInstallWebhook` validates the HMAC signature, the token expiry, the
234
+ `parseInstallWebhook` validates the HMAC signature, the token expiry, the
200
235
  `"app_registry_v2"` issuer, and the `"Joken"` audience — the same checks as
201
- [`PeekAccessService.verifyPeekAuthToken`](../README.md#access-options--pii) — then
202
- maps the payload to the clean `InstallWebhookClaims`. It throws the underlying
203
- `jsonwebtoken` error (`JsonWebTokenError` / `TokenExpiredError` /
204
- `NotBeforeError`) on any verification failure, so a single `try/catch` → `401`
205
- covers every bad-token case.
206
-
207
- The signature checks are strict (they are the security boundary); claim
208
- *extraction* is defensive, so a validly-signed token that omits an optional field
209
- yields an empty value rather than throwing. The resulting `InstallWebhookClaims`:
210
-
211
- | Field | Type | From | Notes |
212
- | --- | --- | --- | --- |
213
- | `installId` | `string` | `sub` | Peek-assigned install UUID |
214
- | `account.id` | `string` | `account.id` | account that owns the install |
215
- | `status` | `string` | `status` | e.g. `"installed"`, `"uninstalled"` |
216
- | `displayVersion` | `string` | `display_version` | `""` when absent |
217
- | `user` | `PeekAuthTokenUser \| null` | `user` | `null` for system-initiated events |
218
-
219
- Unlike `verifyPeekAuthToken`, `user` is nullable: install lifecycle events are
220
- often system-initiated (no acting user), so the verifier tolerates `user: null`.
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`.
271
+
272
+ ## Persist `platform` per install
273
+
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:
276
+
277
+ | `platform` | Access service | Gateway |
278
+ | --- | --- | --- |
279
+ | `"peek"` | `PeekAccessService` | Peek backoffice GraphQL |
280
+ | `"cng"` | `CngAccessService` | CNG backoffice REST |
281
+ | `"acme"` | `AcmeAccessService` | ACME backoffice REST |
282
+
283
+ Two installs of the same app can sit on different platforms, so this cannot be
284
+ inferred from app-level config — it is per install, it has no source other than
285
+ this event, and it must be stored alongside `installId` and `accountId`.
286
+
287
+ ## Why `status` and `platform` can be `null`
288
+
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.
293
+
294
+ Both are therefore reported as `null` rather than coerced into a known value,
295
+ with the wire value preserved on `rawStatus`. Coercing would be actively
296
+ dangerous: Peek treats any 2xx as delivered and does not redeliver, so quietly
297
+ mapping an unknown status onto a no-op loses a lifecycle transition permanently,
298
+ and defaulting an unknown platform would point that install at the wrong gateway.
299
+ Give both a `default:` branch that fails loudly.
package/llms.txt CHANGED
@@ -157,14 +157,16 @@ stay internal.
157
157
  418/429 with no GraphQL `errors` — e.g. 401/404/5xx; carries `.statusCode`,
158
158
  `.url`, and raw `.body`; surfaced before JSON parsing so non-JSON error pages
159
159
  keep their status), `PiiAccessDisabledError` (a payment/booking-modification op
160
- was called without `fullCustomerAccess`), and the REST-sibling `CngApiError` /
161
- `AcmeApiError` — all importable; branch with `instanceof`.
160
+ was called without `fullCustomerAccess`), `InvalidPeekTokenError` (a
161
+ signature-valid `app_registry_v2` token whose `user` block has no `id`;
162
+ carries `.field`), and the REST-sibling `CngApiError` / `AcmeApiError` — all
163
+ importable; branch with `instanceof`.
162
164
  - Plain `Error` for validation/precondition failures thrown before any request.
163
165
 
164
- ## Webhooks (booking + waiver + install-status)
166
+ ## Webhooks (booking + waiver + install)
165
167
 
166
- Two pure-transform parsers plus one signature-verifier. The parsers need no
167
- 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,
168
170
  or a JSON string; never throw on malformed input. Registration is done **once in
169
171
  an external system** (App Store config), not from code.
170
172
 
@@ -178,15 +180,29 @@ an external system** (App Store config), not from code.
178
180
  register** — just subscribe to the `agreement_signature_created` event. Takes
179
181
  the same `AccessOptions`; by default (`fullCustomerAccess` off) `guestName` and
180
182
  `fileUrl` are redacted — pass `{ fullCustomerAccess: true }` to keep them.
181
- - `verifyInstallWebhook(token, secret): InstallWebhookClaims` — for install
182
- lifecycle events (installed/uninstalled/status changed). The payload *is* a
183
- 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 +
184
186
  `app_registry_v2` issuer + `Joken` audience — same checks as
185
- `verifyPeekAuthToken`) rather than just parsing. Returns clean claims
186
- (`installId`, `account.id`, `status`, `displayVersion`, and `user` which is
187
- **nullable** for system-initiated events). Throws the underlying `jsonwebtoken`
188
- error on any failure — one `try/catch` → `401`. `secret` is the app's
189
- `jwtSecret`. No query to register; just subscribe to the event.
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`.
190
206
 
191
207
  **Full guide + booking query-to-register: `docs/webhooks.md` (shipped).**
192
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peektravel/app-utilities",
3
- "version": "0.6.0",
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": {