@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/README.md +48 -50
- package/dist/index.cjs +33 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +93 -82
- package/dist/index.d.ts +93 -82
- package/dist/index.js +33 -42
- package/dist/index.js.map +1 -1
- package/docs/webhooks.md +103 -128
- package/llms.txt +25 -27
- package/package.json +1 -1
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`.
|
|
5
|
-
**booking**, **waiver**,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
it
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
the
|
|
31
|
-
|
|
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
|
|
166
|
+
# Install webhooks
|
|
167
167
|
|
|
168
168
|
## The problem this solves
|
|
169
169
|
|
|
170
|
-
Peek's app registry
|
|
171
|
-
uninstalled,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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 {
|
|
204
|
+
import { parseInstallWebhook, type InstallWebhook } from "@peektravel/app-utilities";
|
|
189
205
|
|
|
190
206
|
app.post("/webhooks/install", async (req, res) => {
|
|
191
|
-
|
|
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
|
|
216
|
+
await provision(event);
|
|
196
217
|
break;
|
|
197
218
|
case "uninstalled":
|
|
198
|
-
await deprovision(event
|
|
219
|
+
await deprovision(event);
|
|
199
220
|
break;
|
|
200
221
|
case "update_installed":
|
|
201
|
-
await recordVersion(event
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
|
226
|
-
|
|
|
227
|
-
| `
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
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
|
-
`
|
|
235
|
-
|
|
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
|
-
`
|
|
250
|
-
contract is Peek's own, so a
|
|
251
|
-
for. The failure that *is* real
|
|
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
|
|
166
|
+
## Webhooks (booking + waiver + install)
|
|
167
167
|
|
|
168
|
-
|
|
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
|
-
- `
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
`
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|