Skip to content

Commit ca5ced0

Browse files
Merge pull request #22 from peek-travel/feat/full-customer-access-option
feat: add fullCustomerAccess option gating customer PII and payments
2 parents 6cc8429 + 4b396af commit ca5ced0

22 files changed

Lines changed: 696 additions & 80 deletions

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,34 @@ add-on pages for you.
7070
| `logger` | no-op | Inject a `Logger` for diagnostics |
7171
| `fetch` | global `fetch` | Custom fetch (e.g. for tests) |
7272
| `itemOptionsPageSize` | `50` | Add-on pagination page size |
73+
| `accessOptions` | `{ fullCustomerAccess: false }` | PII exposure — see [Access options / PII](#access-options--pii) |
74+
75+
### Access options / PII
76+
77+
Every access service accepts `accessOptions?: AccessOptions` — today a single
78+
flag, `fullCustomerAccess` (default `false`). It's an object so future cross-cutting
79+
flags can be added without breaking signatures.
80+
81+
With `fullCustomerAccess` **off** (the default):
82+
83+
- **Customer PII is never requested** (the GraphQL queries omit the fields, so
84+
they come back `null`/empty): booking guest identity (name/email/phone/DOB/
85+
postal code/GDPR + custom field responses — guests keep only ids and
86+
participation/opt-in flags), booking custom question answers, the customer
87+
`portalUrl`, and review reviewer `customerName`/`customerEmail`. Waiver
88+
webhooks (a fixed payload with no query to trim) instead have `guestName` and
89+
`fileUrl` redacted at parse time.
90+
- **Payment / booking-modification operations are disabled**
91+
`getPaymentsOnFile`, `makePayment`, `refund`, `createInvoiceLink`, `addAddon`,
92+
and `removeAddon` throw `PiiAccessDisabledError`. `create` (including
93+
`markAsPaid`) and non-payment reads/mutations remain available.
94+
95+
```ts
96+
const peek = new PeekAccessService({
97+
installId, jwtSecret, issuer, appId, gatewayKey,
98+
accessOptions: { fullCustomerAccess: true }, // opt into PII + payment operations
99+
});
100+
```
73101

74102
### Errors
75103

@@ -83,6 +111,9 @@ Two kinds of failures surface as exceptions:
83111
exhausted. Carries `.statusCode === 429`.
84112
- `PeekGraphQLError` — the response contained a GraphQL `errors` array, preserved
85113
on `.graphqlErrors`.
114+
- `PiiAccessDisabledError` — a payment / booking-modification operation was
115+
called on an access service created without `fullCustomerAccess` (see [Access options
116+
/ PII](#access-options--pii)). Carries `.operation` (the blocked method name).
86117

87118
**Plain `Error` validation/precondition failures** thrown by the service layer
88119
*before* any network call — e.g. an empty config field, a `bookingId` that
@@ -216,13 +247,16 @@ app.post("/booking-webhook", (req, res) => {
216247
});
217248

218249
app.post("/waiver-webhook", (req, res) => {
219-
const waiver: Waiver = parseWaiverWebhook(req.body);
250+
// guestName/fileUrl are redacted unless you opt into PII:
251+
const waiver: Waiver = parseWaiverWebhook(req.body, { fullCustomerAccess: true });
220252
res.sendStatus(200);
221253
});
222254
```
223255

224256
Both tolerate the delivery envelope / a bare node / a JSON string and never throw
225-
on malformed input. They differ on registration: a **booking** webhook's payload
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
226260
shape is set by a GraphQL query configured **once in an external system** (the
227261
App Store `broadcast_to_url` config) — this package documents and drift-guards
228262
the exact query to paste there — whereas a **waiver** webhook has a fixed payload,

docs/internal/ARCHITECTURE.md

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ GraphQL) and gateway routing (`cng_backoffice_api-v1` /
6868
resolution).
6969
- Optional config: `mode` (`"v2"` — see below), `baseUrl`, `tokenTtlSeconds` (3600), `tokenRefreshLeewaySeconds`
7070
(60), `retryDelaysMs` (`[1000, 2000, 4000]`), `logger` (no-op default),
71-
`fetch` (global default), `itemOptionsPageSize` (50).
71+
`fetch` (global default), `itemOptionsPageSize` (50), and `accessOptions`
72+
(see "Access options / PII" below).
7273
- **v2 mode** (`mode: "v2"`): routes requests through the app-registry
7374
installations API. The endpoint URL becomes
7475
`baseUrl/appId/peek_backoffice_api-v1/endpointName` and the default `baseUrl`
@@ -229,15 +230,62 @@ Recurring patterns inside services:
229230
rejected. Validating pre-normalization is deliberate: normalization would
230231
erase the case/separator distinction the check relies on.
231232

233+
### 4b. Access options / PII
234+
`src/access-options.ts`
235+
236+
Every access service (`PeekAccessService`, `CngAccessService`, `AcmeAccessService`)
237+
accepts an optional `accessOptions` config object — the public `AccessOptions`
238+
type. Today it carries one flag, `fullCustomerAccess` (default `false`); it is an object
239+
rather than a bare boolean so future cross-cutting flags slot in without changing
240+
any downstream signatures. Each access service resolves it once
241+
(`resolveAccessOptions`, which fills defaults) and threads the resolved value
242+
into the resource services that read customer data.
243+
244+
When `fullCustomerAccess` is `false` (the default), two things happen:
245+
246+
1. **PII is never requested (filtered at the GraphQL layer, not in the
247+
converters).** The query *builders* omit the PII fields entirely, so the
248+
gateway never returns them and the pure converters map the now-absent fields
249+
to `null`/empty — the converters stay PII-agnostic. Affected:
250+
- **Bookings** (`booking-queries.ts`): `buildBookingQueryFields` /
251+
`buildBookingGuestsFields` / `buildBookingGuestsQuery` /
252+
`buildBookingsListingQuery` drop the primary-guest block
253+
(`customerName`/`email`/`phone`), the guest identity fields
254+
(name/country/DOB/email/phone/postalCode/`isGdpr`/`fieldResponses` — the
255+
guest list keeps only ids + participation/opt-in flags), the custom
256+
question answers (booking- and ticket-level), and the customer
257+
`bookingPortalUrl`. Operator-facing fields (notes, the Peek Pro deep link,
258+
money, resources) always stay.
259+
- **Reviews** (`buildReviewsQuery`): drops the reviewer `name`/`email`; the
260+
review `comment`, rating, dates, and credited guides always stay.
261+
- **Waivers** (`parseWaiverWebhook`): the webhook delivers a *fixed* payload
262+
with no GraphQL selection to trim, so this is the one place filtering is
263+
applied at parse time — the participant `guestName` and the signed-document
264+
`fileUrl` are nulled. `parseWaiverWebhook(body, options?)` takes the same
265+
`AccessOptions`; `fromWaiverNode` stays a pure full mapping.
266+
267+
2. **Payment / booking-modification operations are disabled.** `BookingService`
268+
gates the operations that touch customer financial data —
269+
`getPaymentsOnFile`, `makePayment`, `refund`, `createInvoiceLink`,
270+
`addAddon`, `removeAddon` — throwing `PiiAccessDisabledError` (an exported
271+
typed error) before any network call. Non-payment reads/mutations
272+
(`getById`, `getGuests`, `cancel`, `appendNote`, `setCheckinStatus`) and
273+
`create` (**including `markAsPaid`**) remain available.
274+
275+
The webhook **registration** query (`BOOKING_WEBHOOK_GQL_QUERY`) is deliberately
276+
unaffected — it is the maximal selection built from the full field fragments and
277+
pinned by the drift-guard test; `fullCustomerAccess` governs only the runtime read path.
278+
232279
### 5. Public API surface
233280
`src/index.ts`
234281

235282
The barrel re-exports only the public contract: `PeekAccessService` + its config,
236-
each resource service class (and the options/result types callers need), all
237-
data-model **types** (including `PeekAuthTokenClaims` and `PeekAuthTokenUser`),
238-
the `Logger` interface + `noopLogger`, and the typed error classes
239-
(`AdminAccountRequiredError`, `RateLimitError`, `PeekGraphQLError`,
240-
`CngApiError`, `AcmeApiError`). Query strings and raw response interfaces are deliberately kept
283+
the `AccessOptions` type (see §4b), each resource service class (and the
284+
options/result types callers need), all data-model **types** (including
285+
`PeekAuthTokenClaims` and `PeekAuthTokenUser`), the `Logger` interface +
286+
`noopLogger`, and the typed error classes (`AdminAccountRequiredError`,
287+
`RateLimitError`, `PeekGraphQLError`, `PiiAccessDisabledError`, `CngApiError`,
288+
`AcmeApiError`). Query strings and raw response interfaces are deliberately kept
241289
internal — including the booking-webhook registration query
242290
(`BOOKING_WEBHOOK_GQL_QUERY` stays internal, documented via `docs/webhooks.md`).
243291
The webhook-related public exports are the two parsers `parseBookingWebhook` and

docs/webhooks.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ config. Leave `output_fields_gql_query` null; the `output_format` is
112112
import { parseWaiverWebhook, type Waiver } from "@peektravel/app-utilities";
113113

114114
app.post("/waiver-webhook", (req, res) => {
115-
const waiver: Waiver = parseWaiverWebhook(req.body);
115+
// PII (guestName, fileUrl) is redacted unless you opt in:
116+
const waiver: Waiver = parseWaiverWebhook(req.body, { fullCustomerAccess: true });
116117
// waiver.bookingId, waiver.templateId, waiver.fileUrl, waiver.signedAt,
117118
// waiver.guestName, waiver.isSignedByGuardian, …
118119
res.sendStatus(200);
@@ -125,6 +126,13 @@ bare node / a JSON string, maps the raw `snake_case` payload to the clean
125126
camelCase [`Waiver`](#waiver-webhooks) model, and never throws on malformed input
126127
(missing fields become `""` / `null` / `false`).
127128

129+
**PII:** `parseWaiverWebhook(body, options?)` takes the same `AccessOptions` as
130+
the access services. By **default** (`fullCustomerAccess` unset/`false`) the participant
131+
`guestName` and the signed-document `fileUrl` are redacted (`null`/`""`); pass
132+
`{ fullCustomerAccess: true }` to keep them. The booking parser
133+
`parseBookingWebhook(body)` is unaffected — a booking webhook carries whatever
134+
its registered selection includes.
135+
128136
The resulting `Waiver` is flat:
129137

130138
| Field | Type | From |

llms.txt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,21 @@ const peek = new PeekAccessService({
2525
appId, // gateway path segment
2626
gatewayKey, // pk-api-key header
2727
// optional: baseUrl, tokenTtlSeconds, tokenRefreshLeewaySeconds,
28-
// retryDelaysMs, logger, fetch, itemOptionsPageSize
28+
// retryDelaysMs, logger, fetch, itemOptionsPageSize,
29+
// accessOptions: { fullCustomerAccess: false } // default — see "Access options / PII"
2930
});
3031
```
3132

33+
## Access options / PII
34+
35+
Every access service takes `accessOptions?: AccessOptions` (`{ fullCustomerAccess?: boolean }`,
36+
default `false`). With `fullCustomerAccess` off, customer PII is **not requested** at the
37+
GraphQL layer (booking guest identity + custom question answers + `portalUrl`,
38+
review reviewer name/email → `null`/empty), and `BookingService` **disables**
39+
`getPaymentsOnFile`/`makePayment`/`refund`/`createInvoiceLink`/`addAddon`/
40+
`removeAddon` (throw `PiiAccessDisabledError`). `create` (incl. `markAsPaid`) and
41+
non-payment reads/mutations stay available. Pass `{ fullCustomerAccess: true }` to opt in.
42+
3243
## Resources (accessor → methods)
3344

3445
- `getProductService()` — `getAllProducts()` (flat list of activities + add-ons;
@@ -64,8 +75,9 @@ const peek = new PeekAccessService({
6475
## Errors
6576

6677
- `AdminAccountRequiredError` (HTTP 418), `RateLimitError` (HTTP 429 after
67-
retries), `PeekGraphQLError` (`.graphqlErrors` holds the raw array) — all
68-
importable; branch with `instanceof`.
78+
retries), `PeekGraphQLError` (`.graphqlErrors` holds the raw array),
79+
`PiiAccessDisabledError` (a payment/booking-modification op was called without
80+
`fullCustomerAccess`) — all importable; branch with `instanceof`.
6981
- Plain `Error` for validation/precondition failures thrown before any request.
7082

7183
## Webhooks (booking + waiver)
@@ -80,9 +92,11 @@ external system** (App Store config), not from code.
8092
the canonical maximal selection into `output_fields_gql_query`. That string is
8193
documented in `docs/webhooks.md` and pinned by a drift-guard test — it is NOT a
8294
runtime export.
83-
- `parseWaiverWebhook(body): Waiver` — maps the fixed `snake_case`
95+
- `parseWaiverWebhook(body, options?): Waiver` — maps the fixed `snake_case`
8496
`waiver_webhook_data` payload to the clean `Waiver` model. **No query to
85-
register** — just subscribe to the `agreement_signature_created` event.
97+
register** — just subscribe to the `agreement_signature_created` event. Takes
98+
the same `AccessOptions`; by default (`fullCustomerAccess` off) `guestName` and
99+
`fileUrl` are redacted — pass `{ fullCustomerAccess: true }` to keep them.
86100

87101
**Full guide + booking query-to-register: `docs/webhooks.md` (shipped).**
88102

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@peektravel/app-utilities",
3-
"version": "0.4.1",
3+
"version": "0.5.0",
44
"description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
55
"license": "MIT",
66
"repository": {

src/access-options.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Cross-cutting options that govern what data an access service will read and
3+
* expose. Passed once when constructing an access service and threaded down to
4+
* the resource services (and the webhook parsers) so a single object controls
5+
* behaviour everywhere, rather than a boolean being passed hand-to-hand.
6+
*
7+
* Kept deliberately small — today it carries a single PII toggle, but new
8+
* cross-cutting flags slot in here without changing any downstream signatures.
9+
*/
10+
11+
/** Options controlling PII exposure across an access service's reads. */
12+
export interface AccessOptions {
13+
/**
14+
* When `true`, customer PII (guest names/emails/phones, custom question
15+
* answers, the customer booking-portal URL, waiver participant details, …) is
16+
* requested and returned, and payment/booking-modification operations are
17+
* available.
18+
*
19+
* When `false` (the default), those PII fields are never requested from the
20+
* gateway and come back `null`/empty, and the payment/booking-modification
21+
* operations that touch customer financial data are disabled (they throw a
22+
* {@link PiiAccessDisabledError}).
23+
*/
24+
fullCustomerAccess?: boolean;
25+
}
26+
27+
/** A fully-resolved {@link AccessOptions} with every flag defaulted. */
28+
export type ResolvedAccessOptions = Required<AccessOptions>;
29+
30+
/**
31+
* Resolves a possibly-absent {@link AccessOptions} into a concrete object with
32+
* every flag defaulted. `fullCustomerAccess` defaults to `false` (PII off).
33+
*/
34+
export function resolveAccessOptions(options?: AccessOptions): ResolvedAccessOptions {
35+
return { fullCustomerAccess: options?.fullCustomerAccess ?? false };
36+
}

src/access-service-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* plumbing is written once.
99
*/
1010
import { TokenManager } from "./internal/token-manager.js";
11+
import type { AccessOptions } from "./access-options.js";
1112
import type { Logger } from "./logger.js";
1213

1314
/** Fields common to every access service's config. */
@@ -33,6 +34,13 @@ export interface BaseAccessServiceConfig {
3334
logger?: Logger;
3435
/** Custom `fetch` implementation. Default: the global `fetch`. */
3536
fetch?: typeof fetch;
37+
38+
/**
39+
* Cross-cutting access options (PII exposure, …). When omitted, defaults are
40+
* used ({@link AccessOptions.fullCustomerAccess} `false`). Threaded down to the
41+
* resource services that read customer data.
42+
*/
43+
accessOptions?: AccessOptions;
3644
}
3745

3846
/** Default JWT lifetime (1 hour). */

src/errors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ export class PeekGraphQLError extends Error {
5151
}
5252
}
5353

54+
/**
55+
* Thrown when a payment or booking-modification operation is called on an
56+
* access service that was constructed without `fullCustomerAccess` (PII access
57+
* disabled). These operations — pulling payment sources, charging/refunding,
58+
* creating invoice links, and adding/removing add-ons — touch customer
59+
* financial data, so they are gated behind the same flag as customer PII.
60+
*/
61+
export class PiiAccessDisabledError extends Error {
62+
/** The name of the operation that was blocked (e.g. `"makePayment"`). */
63+
public readonly operation: string;
64+
65+
constructor(operation: string) {
66+
super(
67+
`"${operation}" is disabled because this access service was created ` +
68+
`without "fullCustomerAccess"; enable it to allow payment and booking-modification operations`,
69+
);
70+
this.name = "PiiAccessDisabledError";
71+
this.operation = operation;
72+
}
73+
}
74+
5475
/**
5576
* Thrown when the CNG REST gateway returns a non-2xx response that is not one
5677
* of the specifically-handled statuses (418/429). The offending status is

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
export { PeekAccessService } from "./peek-access-service.js";
99
export type { PeekAccessServiceConfig } from "./peek-access-service.js";
1010

11+
// ─── Cross-cutting access options (PII exposure, …) ──────────────────────────
12+
export type { AccessOptions } from "./access-options.js";
13+
1114
// ─── CNG (REST) — sibling accessor sharing this package's auth/transport/UI ──
1215
export { CngAccessService } from "./cng-access-service.js";
1316
export type { CngAccessServiceConfig } from "./cng-access-service.js";
@@ -141,5 +144,6 @@ export {
141144
AdminAccountRequiredError,
142145
CngApiError,
143146
PeekGraphQLError,
147+
PiiAccessDisabledError,
144148
RateLimitError,
145149
} from "./errors.js";

0 commit comments

Comments
 (0)