Skip to content

Commit 25da131

Browse files
Merge pull request #26 from peek-travel/feat/typed-http-errors-install-webhook-react19
Typed HTTP errors, install-webhook verifier, React 19-safe UI props
2 parents 6e56039 + 77bbcf1 commit 25da131

31 files changed

Lines changed: 793 additions & 121 deletions

README.md

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,14 @@ Two kinds of failures surface as exceptions:
109109
rights). Carries `.statusCode === 418`.
110110
- `RateLimitError` — HTTP 429 after the configured `retryDelaysMs` backoff was
111111
exhausted. Carries `.statusCode === 429`.
112-
- `PeekGraphQLError` — the response contained a GraphQL `errors` array, preserved
113-
on `.graphqlErrors`.
112+
- `PeekGraphQLError` — the response contained a GraphQL `errors` array (a
113+
resolver-level failure), preserved on `.graphqlErrors`.
114+
- `PeekHttpError` — the gateway returned a non-2xx HTTP status (other than
115+
418/429) with no GraphQL `errors` array — a transport-level failure such as
116+
`401` (auth/secret wrong), `404` (wrong app id / not provisioned), or `5xx`.
117+
Carries `.statusCode`, `.url`, and the raw `.body` (parsed JSON when possible,
118+
otherwise the response text). Surfaced *before* the body is parsed as JSON, so
119+
a non-JSON error page reports its real status instead of a JSON parse error.
114120
- `PiiAccessDisabledError` — a payment / booking-modification operation was
115121
called on an access service created without `fullCustomerAccess` (see [Access options
116122
/ PII](#access-options--pii)). Carries `.operation` (the blocked method name).
@@ -127,6 +133,7 @@ import {
127133
RateLimitError,
128134
AdminAccountRequiredError,
129135
PeekGraphQLError,
136+
PeekHttpError,
130137
} from '@peektravel/app-utilities';
131138

132139
try {
@@ -136,6 +143,8 @@ try {
136143
// back off and retry later
137144
} else if (err instanceof AdminAccountRequiredError) {
138145
// this install can't perform admin-only operations
146+
} else if (err instanceof PeekHttpError) {
147+
console.error(err.statusCode, err.url, err.body); // which config is wrong
139148
} else if (err instanceof PeekGraphQLError) {
140149
console.error(err.graphqlErrors); // raw gateway errors
141150
} else {
@@ -229,16 +238,20 @@ Functions runtime) resolve correctly. Its only runtime dependency is
229238

230239
## Webhooks
231240

232-
Receiver apps can consume Peek **booking** and **waiver** webhooks without
233-
hand-writing a payload parser. Each has a pure parser (construct nothing — no
234-
auth/network) that returns a clean model:
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:
235246

236247
```ts
237248
import {
238249
parseBookingWebhook,
239250
parseWaiverWebhook,
251+
verifyInstallWebhook,
240252
type Booking,
241253
type Waiver,
254+
type InstallWebhookClaims,
242255
} from "@peektravel/app-utilities";
243256

244257
app.post("/booking-webhook", (req, res) => {
@@ -251,17 +264,38 @@ app.post("/waiver-webhook", (req, res) => {
251264
const waiver: Waiver = parseWaiverWebhook(req.body, { fullCustomerAccess: true });
252265
res.sendStatus(200);
253266
});
267+
268+
app.post("/install-webhook", (req, res) => {
269+
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);
276+
} catch {
277+
res.sendStatus(401); // bad signature / issuer / audience / expired
278+
}
279+
});
254280
```
255281

256-
Both tolerate the delivery envelope / a bare node / a JSON string and never throw
257-
on malformed input. `parseWaiverWebhook` also takes an optional `AccessOptions`
258-
(`{ fullCustomerAccess }`) and redacts the participant `guestName` + document `fileUrl`
259-
by default — see [Access options / PII](#access-options--pii) below. They differ on registration: a **booking** webhook's payload
260-
shape is set by a GraphQL query configured **once in an external system** (the
261-
App Store `broadcast_to_url` config) — this package documents and drift-guards
262-
the exact query to paste there — whereas a **waiver** webhook has a fixed payload,
263-
so you just subscribe to its event with no query. **The query to register and the
264-
full guide: [`docs/webhooks.md`](docs/webhooks.md) (shipped).**
282+
The booking and waiver parsers tolerate the delivery envelope / a bare node / a
283+
JSON string and never throw on malformed input; **authenticating those deliveries
284+
is the receiver's job**. `parseWaiverWebhook` also takes an optional
285+
`AccessOptions` (`{ fullCustomerAccess }`) and redacts the participant `guestName`
286+
+ 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:
298+
[`docs/webhooks.md`](docs/webhooks.md) (shipped).**
265299

266300
## UI components (`/ui`)
267301

@@ -299,6 +333,27 @@ attribute. Exported classes/types and helpers (`iconSvg`, `registerIcon`,
299333
`portal`, `position`, `toast`) are available from `@peektravel/app-utilities/ui`
300334
for subclassing or typing.
301335

336+
### Using the components from React 19
337+
338+
React 19 sets JSX props on a custom element as DOM **properties**
339+
(`el.searchable = true`), not attributes. That's handled for you: every
340+
reflected/read-only accessor accepts assignment, so `<ody-dropdown-single
341+
searchable options={items} />` and friends work without the
342+
`Cannot set property … which has only a getter` crash that a getter-only
343+
property would otherwise cause. Two things worth knowing:
344+
345+
- **Boolean and data props reflect to the attribute**, so they take effect as
346+
expected. Pass booleans as real booleans (`searchable={true}`) and rich data
347+
as values (`options={items}`) — the object is reflected as a JSON attribute.
348+
- **Read-only state accessors are inert to assignment.** Props that mirror live
349+
internal state (`isOpen`, `isVisible`) ignore writes — drive them through the
350+
imperative methods on a `ref` (`ref.current.openPopover()`, `.show()`) and read
351+
them back through the component's `CustomEvent`s, not by setting the prop.
352+
353+
TypeScript still types these accessors as read-only, so a direct
354+
`el.isOpen = true` in TS is flagged — the setters are a runtime safety net for
355+
framework-driven assignment, not an invitation to write to derived state.
356+
302357
**Try the gallery:** `npm run sample` builds the package and serves
303358
`examples/ui-gallery.html`, which shows every component with its variants.
304359

docs/internal/ARCHITECTURE.md

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,13 @@ GraphQL) and gateway routing (`cng_backoffice_api-v1` /
5757
- Exposes **top-level short-form methods** that delegate directly to the
5858
underlying service, e.g. `peek.getAllProducts()``peek.getProductService().getAllProducts()`. Every public service method has a named proxy on `PeekAccessService`; the names are prefixed with the resource noun where disambiguation is needed (e.g. `getBookingById`, `getTimeslotById`).
5959
- Exposes `verifyPeekAuthToken(token)` to verify HMAC-signed JWTs issued by
60-
the Peek app registry (`iss: "app_registry_v2"`), returning
60+
the Peek app registry (`iss: "app_registry_v2"`, `aud: "Joken"`), returning
6161
a fully typed `PeekAuthTokenClaims` (including the nested `PeekAuthTokenUser`
6262
object). Throws `JsonWebTokenError` / `TokenExpiredError` / `NotBeforeError`
63-
from `jsonwebtoken` on failure.
63+
from `jsonwebtoken` on failure. The signature-check core (issuer + audience +
64+
user mapping) lives in the internal `peek-auth-token.ts` module, shared with
65+
the standalone `verifyInstallWebhook` (§ install webhooks) so the two can't
66+
drift apart.
6467
- Composes dependencies between services where needed:
6568
- `TimeslotService` receives the resource-pool and account-user services (for
6669
guide resolution).
@@ -104,12 +107,16 @@ Responsibilities:
104107
- Collapses query whitespace (`\s+` → single space) before sending.
105108
- Retries HTTP 429 using the configured backoff delays, then throws
106109
`RateLimitError`.
110+
- Reads the response body via the shared `parseBody` helper (`text()` → try
111+
`JSON.parse`, falling back to raw text) *before* branching on status, so a
112+
non-JSON error page never throws a `SyntaxError` that hides the real status.
107113
- Maps known failures to typed errors:
108114
- HTTP 418 → `AdminAccountRequiredError`
109115
- HTTP 429 (after retries) → `RateLimitError`
110116
- GraphQL `errors` array present → `PeekGraphQLError` (raw errors preserved on
111117
`.graphqlErrors`)
112-
- other non-2xx → generic `Error` with the status.
118+
- other non-2xx → `PeekHttpError` (carries `.statusCode`, `.url`, and raw
119+
`.body`).
113120

114121
### 4. Per-resource services
115122
`src/internal/peek/<resource>/`
@@ -213,7 +220,20 @@ pure `fromWaiverNode` converter, which maps the fixed `snake_case` payload to th
213220
flat clean `Waiver` model (defaulting missing fields to `""`/`null`/`false`, so
214221
it never throws). Same standalone-pure-function rationale as bookings. Because
215222
there are no reads, `waivers` carries no queries/service triad — just the
216-
webhook module and the model. The detailed `AddonItem`
223+
webhook module and the model.
224+
225+
The **install-status** webhook (`installs/install-webhook.ts`) is the third and
226+
most distinct: its payload is not a data node but a **signed `app_registry_v2`
227+
JWT**, so `verifyInstallWebhook(token, secret)` *verifies* it (signature +
228+
expiry + issuer + `Joken` audience, via the shared `peek-auth-token.ts` core)
229+
before mapping to the clean `InstallWebhookClaims` (`installId`, `account.id`,
230+
`status`, `displayVersion`, and a **nullable** `user` — install lifecycle events
231+
are often system-initiated). Standalone-function rationale as above, with an
232+
extra reason: the receiver has no per-install service to inherit a secret from
233+
yet (the webhook can precede the first session or describe a tear-down), so the
234+
app secret is passed directly. Like `waivers`, `installs` carries no
235+
queries/service triad — just the verifier and the (shared `auth-token.ts`) model.
236+
The detailed `AddonItem`
217237
model (refids + reservation statuses) is **internal only** — consumers see just
218238
the grouped `BookingAddons`; the internal model exists solely so add/remove can
219239
build their mutation payloads.
@@ -315,14 +335,16 @@ pinned by the drift-guard test; `fullCustomerAccess` governs only the runtime re
315335
The barrel re-exports only the public contract: `PeekAccessService` + its config,
316336
the `AccessOptions` type (see §4b), each resource service class (and the
317337
options/result types callers need), all data-model **types** (including
318-
`PeekAuthTokenClaims` and `PeekAuthTokenUser`), the `Logger` interface +
338+
`PeekAuthTokenClaims`, `PeekAuthTokenUser`, and the install-webhook
339+
`InstallWebhookClaims`/`InstallWebhookAccount`), the `Logger` interface +
319340
`noopLogger`, and the typed error classes (`AdminAccountRequiredError`,
320-
`RateLimitError`, `PeekGraphQLError`, `PiiAccessDisabledError`, `CngApiError`,
321-
`AcmeApiError`). Query strings and raw response interfaces are deliberately kept
341+
`RateLimitError`, `PeekGraphQLError`, `PeekHttpError`, `PiiAccessDisabledError`,
342+
`CngApiError`, `AcmeApiError`). Query strings and raw response interfaces are deliberately kept
322343
internal — including the booking-webhook registration query
323344
(`BOOKING_WEBHOOK_GQL_QUERY` stays internal, documented via `docs/webhooks.md`).
324345
The webhook-related public exports are the two parsers `parseBookingWebhook` and
325-
`parseWaiverWebhook` (plus the `Waiver` model type; see the webhook notes above).
346+
`parseWaiverWebhook` plus the install-status verifier `verifyInstallWebhook`
347+
(and the `Waiver` / `InstallWebhookClaims` model types; see the webhook notes above).
326348

327349
### 5b. CNG accessor (REST)
328350
`src/cng-access-service.ts`, `src/internal/cng/`, `src/models/cng/product.ts`
@@ -340,8 +362,10 @@ plumbing rather than forking the package.
340362
`GraphQLClient`. Builds `${baseUrl}/${appId}/${extendableSlug}/${path}` with
341363
`extendableSlug = cng_backoffice_api-v1`, GETs it with `X-Peek-Auth: Bearer`
342364
(no `pk-api-key`, no `{query,variables}` body), and runs through the shared
343-
`requestWithRetry` loop. Parses the body as JSON, falling back to raw text
344-
when unparseable; non-2xx (other than 418/429) → `CngApiError` (status + body).
365+
`requestWithRetry` loop. Reads the body with the shared `parseBody` helper
366+
(`http-transport.ts`) — JSON with a raw-text fallback when unparseable — the
367+
same helper the Peek `GraphQLClient` and ACME `RestClient` use; non-2xx (other
368+
than 418/429) → `CngApiError` (status + body).
345369
- **Products triad** (`src/internal/cng/products/`) — same shape as every Peek
346370
resource: `product-queries.ts` (raw REST `ProductNode`/`ProductsResponse`
347371
interfaces, internal), `product-converter.ts` (pure `fromProductNodes`
@@ -465,6 +489,17 @@ Load-bearing rules:
465489
component file). `package.json` `"sideEffects"` is therefore an allow-list
466490
(`**/ui/**`, `**/*.css`) rather than `false`, so bundlers don't tree-shake the
467491
registrations away.
492+
- **React 19 safety at registration.** `define()` (in `base.ts`) runs
493+
`addReactSafeSetters` on the class before `customElements.define`: it walks the
494+
component's own prototypes (up to `OdyElement`) and gives every getter-only
495+
accessor a setter, so React 19 — which assigns JSX props as DOM *properties*
496+
(`el.searchable = true`) — can't throw `only a getter`. When the property name
497+
maps to an `observedAttribute` the setter **reflects** the value onto that
498+
attribute (booleans as presence, objects as JSON, scalars as strings) so the
499+
prop takes effect; otherwise it is a no-op for derived/imperative state
500+
(`isOpen`). The static `.d.ts` types keep these accessors read-only — the
501+
setters are a runtime-only safety net. Documented for consumers in `docs/ui.md`
502+
§3.5 and the README.
468503
- **Dependency-free & token-based.** No `ember-power-select`/`-calendar`,
469504
`svg-jar`, or bootstrap. Colours/spacing reference the `tokens.css` custom
470505
properties; icons are inlined; button variant colours (which live in a

docs/ui.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ placeholder. So the element's children become its body/label content:
109109
**Vanilla HTML / JS** — declarative; set rich data and add listeners via the
110110
element reference.
111111

112-
**React** (especially < 19) does not set DOM properties or bind custom events
113-
from JSX attributes. Use lowercase tag names, a `ref` to set properties, and
112+
**React < 19** does not set DOM properties or bind custom events from JSX
113+
attributes. Use lowercase tag names, a `ref` to set properties, and
114114
`addEventListener` for events:
115115

116116
```jsx
@@ -128,6 +128,30 @@ function Guests({ columns, rows, onSort }) {
128128
}
129129
```
130130

131+
**React 19** sets JSX props on a custom element as DOM **properties**
132+
(`el.searchable = true`), not attributes. The components are built for this: every
133+
reflected/read-only accessor accepts assignment, so passing props directly works
134+
and does **not** throw the `Cannot set property … which has only a getter` error
135+
a getter-only property would otherwise raise. Concretely:
136+
137+
- **Reflected props (booleans, and rich data) take effect.** `searchable`,
138+
`disabled`, `options`, etc. reflect the assigned value onto the backing
139+
attribute — booleans as presence, objects/arrays as a JSON string:
140+
141+
```jsx
142+
<ody-dropdown-single searchable options={items} value={value} />
143+
```
144+
145+
- **Read-only state accessors ignore assignment.** Props that mirror live
146+
internal state (`isOpen`, `isVisible`) are inert to writes — they are not
147+
controlled by re-render. Drive them imperatively through a `ref`
148+
(`ref.current.openPopover()` / `.show()`) and observe changes via the
149+
component's `CustomEvent`s.
150+
151+
TypeScript still types these accessors as read-only (the setters are a runtime
152+
safety net for framework-driven assignment), so `el.isOpen = true` in TS code is
153+
flagged — assign only via JSX props / refs as above.
154+
131155
**Vue / Angular** support custom elements natively: bind properties with
132156
`:prop` / `[prop]` and events with `@event` / `(event)`. (Configure Vue's
133157
`compilerOptions.isCustomElement` / Angular's `CUSTOM_ELEMENTS_SCHEMA`.)

0 commit comments

Comments
 (0)