@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 +54 -26
- package/dist/index.cjs +64 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +159 -5
- package/dist/index.d.ts +159 -5
- package/dist/index.js +62 -1
- package/dist/index.js.map +1 -1
- package/docs/webhooks.md +130 -51
- package/llms.txt +29 -13
- package/package.json +1 -1
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
22
|
-
|
|
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
|
|
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,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
172
|
-
|
|
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
|
|
196
|
+
## Step 2 — verify and parse the delivery
|
|
175
197
|
|
|
176
|
-
Extract the JWT from the delivery (the request
|
|
177
|
-
header with the `Bearer ` prefix stripped) and pass it —
|
|
178
|
-
secret (the same `jwtSecret` you construct
|
|
179
|
-
`
|
|
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 {
|
|
204
|
+
import { parseInstallWebhook, type InstallWebhook } from "@peektravel/app-utilities";
|
|
183
205
|
|
|
184
|
-
app.post("/webhooks/install", (req, res) => {
|
|
185
|
-
let
|
|
206
|
+
app.post("/webhooks/install", async (req, res) => {
|
|
207
|
+
let event: InstallWebhook;
|
|
186
208
|
try {
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
`
|
|
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)
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
`
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
| Field | Type | From | Notes |
|
|
212
|
-
| --- | --- | --- | --- |
|
|
213
|
-
| `installId` | `string` | `sub` | Peek-assigned install UUID |
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
216
|
-
| `
|
|
217
|
-
| `
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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`),
|
|
161
|
-
`
|
|
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
|
|
166
|
+
## Webhooks (booking + waiver + install)
|
|
165
167
|
|
|
166
|
-
Two pure-transform parsers plus one
|
|
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
|
-
- `
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
`
|
|
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
|
|