Skip to content

Commit f25b0bb

Browse files
authored
Merge pull request #18 from peek-travel/feat/cng-accessor
feat: add CNG REST accessor alongside Peek, share transport/auth/config
1 parent ddd4885 commit f25b0bb

103 files changed

Lines changed: 1098 additions & 291 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/internal/ARCHITECTURE.md

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ call typed methods like `peek.getProductService().getAllProducts()` or directly
1111
via the top-level short-forms like `peek.getAllProducts()` and
1212
`peek.getAllActivities()`.
1313

14+
The package also ships a **sibling accessor for the CNG backoffice**,
15+
`CngAccessService` (REST, not GraphQL). It reuses this package's auth
16+
(`TokenManager`), retry/backoff loop, `Logger`, base error types, tooling, and
17+
the Odyssey UI — differing only in transport (REST vs GraphQL) and gateway
18+
routing (`cng_backoffice_api-v1` vs `peek_backoffice_api-v1`). See
19+
"CNG accessor" below.
20+
1421
## Layers
1522

1623
```
@@ -76,9 +83,14 @@ via the top-level short-forms like `peek.getAllProducts()` and
7683
- Caches the token and re-mints it once it is within `leewaySeconds` of expiry.
7784

7885
### 3. `GraphQLClient` — transport
79-
`src/internal/graphql-client.ts`
86+
`src/internal/peek/graphql-client.ts`
8087

81-
The only place that touches the network. Responsibilities:
88+
The place that touches the network for Peek. The retry/backoff loop and 418/429
89+
mapping are **shared** with the CNG transport in
90+
`src/internal/http-transport.ts` (`requestWithRetry`): both clients build their
91+
own `url`/`init`, log their own "Making … request" line, and pass a per-response
92+
callback that handles the transport-specific success/error parsing.
93+
Responsibilities:
8294

8395
- Builds the endpoint URL as `${baseUrl}/${appId}/${endpointName}`, or
8496
`${baseUrl}/${appId}/${endpointPathPrefix}/${endpointName}` when an
@@ -98,7 +110,11 @@ The only place that touches the network. Responsibilities:
98110
- other non-2xx → generic `Error` with the status.
99111

100112
### 4. Per-resource services
101-
`src/internal/<resource>/`
113+
`src/internal/peek/<resource>/`
114+
115+
Every Peek resource lives under `src/internal/peek/` (mirrored by the CNG
116+
resources under `src/internal/cng/` — see §5b); the shared plumbing
117+
(`token-manager.ts`, `http-transport.ts`) stays at `src/internal/`.
102118

103119
Each resource follows the same **three-file triad**:
104120

@@ -110,15 +126,16 @@ Each resource follows the same **three-file triad**:
110126

111127
Resources: `products`, `account-users`, `resource-pools`, `timeslots`,
112128
`resellers`, `promo-codes`, `daily-notes`, `availability`, `memberships`,
113-
`bookings`, `reviews`. Clean data shapes live in `src/models/`.
129+
`bookings`, `reviews`. Clean data shapes are split by brand: Peek models in
130+
`src/models/peek/`, CNG models in `src/models/cng/`.
114131

115132
`ProductService` exposes three top-level product filters in addition to the combined `getAllProducts()`:
116133
- `getAllActivities()` — fetches only the `activities` connection (one request, no add-on pagination).
117134
- `getAllAddons()` — fetches only the `itemOptions` connection, paginated.
118135

119136
`waivers` is a **webhook-only resource**: it has no GraphQL reads (so no
120-
queries/service/converter triad), just `src/internal/waivers/waiver-webhook.ts`
121-
and the `src/models/waiver.ts` model. See the webhook notes below.
137+
queries/service/converter triad), just `src/internal/peek/waivers/waiver-webhook.ts`
138+
and the `src/models/peek/waiver.ts` model. See the webhook notes below.
122139

123140
A resource may split into more than one triad when it carries a distinct
124141
sub-domain. `bookings` does: alongside `booking-queries`/`booking-converter`,
@@ -223,6 +240,53 @@ internal — including the booking-webhook registration query
223240
The webhook-related public exports are the two parsers `parseBookingWebhook` and
224241
`parseWaiverWebhook` (plus the `Waiver` model type; see the webhook notes above).
225242

243+
### 5b. CNG accessor (REST)
244+
`src/cng-access-service.ts`, `src/internal/cng/`, `src/models/cng/product.ts`
245+
246+
A second, brand-parallel accessor for the **CNG** backoffice — REST, not
247+
GraphQL. Deliberately low-churn: it sits alongside the Peek code and shares the
248+
plumbing rather than forking the package.
249+
250+
- **`CngAccessService`** — validates four config fields (`installId`,
251+
`jwtSecret`, `issuer`, `appId`; **no `gatewayKey`** — the CNG gateway needs no
252+
`pk-api-key`). Builds the shared `TokenManager` and a `RestClient`, defaults
253+
the base URL to the app-registry installations API, and exposes
254+
`getProductService()` + the short-form `getAllActivities()`.
255+
- **`RestClient`** (`src/internal/cng/rest-client.ts`) — the REST sibling of
256+
`GraphQLClient`. Builds `${baseUrl}/${appId}/${extendableSlug}/${path}` with
257+
`extendableSlug = cng_backoffice_api-v1`, GETs it with `X-Peek-Auth: Bearer`
258+
(no `pk-api-key`, no `{query,variables}` body), and runs through the shared
259+
`requestWithRetry` loop. Parses the body as JSON, falling back to raw text
260+
when unparseable; non-2xx (other than 418/429) → `CngApiError` (status + body).
261+
- **Products triad** (`src/internal/cng/products/`) — same shape as every Peek
262+
resource: `product-queries.ts` (raw REST `ProductNode`/`ProductsResponse`
263+
interfaces, internal), `product-converter.ts` (pure `fromProductNodes`
264+
`Activity`), `product-service.ts` (`CngProductService.getAllActivities()`,
265+
tolerating a `{ products: [...] }` envelope or a bare array). Endpoint segments
266+
live in `src/internal/cng/endpoints.ts`.
267+
- **Model** `src/models/cng/product.ts``Activity`/`ActivityTicket`, mirroring
268+
the Peek `Product` shape so both brands read uniformly.
269+
- **Shared, not duplicated:** the config contract (`BaseAccessServiceConfig` +
270+
the `createTokenManager`/`requireNonEmpty` helpers and shared TTL/leeway/retry
271+
defaults, all in `src/access-service-config.ts`), `TokenManager`,
272+
`Logger`/`noopLogger`, the `AdminAccountRequiredError`/`RateLimitError` base
273+
errors, the `requestWithRetry` transport core, the build/test tooling, and the
274+
Odyssey UI. Each accessor's config just extends the base: `PeekAccessServiceConfig`
275+
adds `gatewayKey`/`mode`/`itemOptionsPageSize`; `CngAccessServiceConfig` adds
276+
nothing. So the only real per-accessor difference is the transport built and the
277+
services exposed.
278+
- **Public exports:** `CngAccessService` + `CngAccessServiceConfig`,
279+
`CngProductService`, the `Activity`/`ActivityTicket` types, and `CngApiError`
280+
(added to the errors export). REST paths and raw response interfaces stay
281+
internal.
282+
283+
> ⚠️ **Guessed response shape.** The real `commerce-config/products` payload is
284+
> not yet confirmed. `ProductNode`, the converter mapping, and the `Activity`
285+
> field set are best-guess placeholders (snake_case REST fields, defensive
286+
> defaults). Confirm against a live sample and adjust — touch only
287+
> `cng/products/product-queries.ts`, `product-converter.ts`, and
288+
> `models/cng/product.ts`.
289+
226290
### 6. UI components — the `./ui` subpath
227291
`src/ui/`
228292

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.2.10",
3+
"version": "0.3.0",
44
"description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
55
"license": "MIT",
66
"repository": {

src/access-service-config.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Config shared by every access service in this package (Peek, CNG, …).
3+
*
4+
* Each gateway's access service extends {@link BaseAccessServiceConfig} with its
5+
* own extras (e.g. Peek adds `gatewayKey`/`mode`), so the only real difference
6+
* between accessors is the transport they build and the services they expose.
7+
* The shared defaults and the token-manager builder live here too, so that
8+
* plumbing is written once.
9+
*/
10+
import { TokenManager } from "./internal/token-manager.js";
11+
import type { Logger } from "./logger.js";
12+
13+
/** Fields common to every access service's config. */
14+
export interface BaseAccessServiceConfig {
15+
/** Install ID. Becomes the JWT subject. */
16+
installId: string;
17+
/** HMAC secret used to sign the JWT. */
18+
jwtSecret: string;
19+
/** JWT issuer — the app name / app ID. */
20+
issuer: string;
21+
/** App ID, used in the gateway endpoint path. */
22+
appId: string;
23+
24+
/** Override the gateway base URL. Default: per-service. */
25+
baseUrl?: string;
26+
/** JWT lifetime in seconds. Default: 3600. */
27+
tokenTtlSeconds?: number;
28+
/** Re-mint the cached token this many seconds before expiry. Default: 60. */
29+
tokenRefreshLeewaySeconds?: number;
30+
/** Backoff delays (ms) for HTTP 429 retries. Default: [1000, 2000, 4000]. */
31+
retryDelaysMs?: number[];
32+
/** Optional logger. Default: no-op (silent). */
33+
logger?: Logger;
34+
/** Custom `fetch` implementation. Default: the global `fetch`. */
35+
fetch?: typeof fetch;
36+
}
37+
38+
/** Default JWT lifetime (1 hour). */
39+
export const DEFAULT_TOKEN_TTL_SECONDS = 3600;
40+
/** Default leeway before expiry at which a cached token is re-minted. */
41+
export const DEFAULT_TOKEN_REFRESH_LEEWAY_SECONDS = 60;
42+
/** Default HTTP 429 retry backoff. */
43+
export const DEFAULT_RETRY_DELAYS_MS = [1000, 2000, 4000];
44+
45+
/**
46+
* Throws when a required config field is empty, prefixing the message with the
47+
* concrete service name (e.g. `PeekAccessService: "installId" is required`).
48+
*/
49+
export function requireNonEmpty(
50+
value: string,
51+
name: string,
52+
serviceName: string,
53+
): void {
54+
if (!value) {
55+
throw new Error(`${serviceName}: "${name}" is required`);
56+
}
57+
}
58+
59+
/**
60+
* Builds the shared {@link TokenManager} from the common config fields, applying
61+
* the shared TTL/leeway defaults. Used by every access service.
62+
*/
63+
export function createTokenManager(config: BaseAccessServiceConfig): TokenManager {
64+
return new TokenManager({
65+
secret: config.jwtSecret,
66+
issuer: config.issuer,
67+
installId: config.installId,
68+
ttlSeconds: config.tokenTtlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
69+
leewaySeconds:
70+
config.tokenRefreshLeewaySeconds ?? DEFAULT_TOKEN_REFRESH_LEEWAY_SECONDS,
71+
});
72+
}

src/cng-access-service.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Authenticated root entry point to the CNG backoffice REST gateway.
3+
*
4+
* The CNG sibling of {@link PeekAccessService}. Configure one instance per
5+
* install; it owns the shared, authenticated transport (minting/caching tokens
6+
* on demand) and hands out the {@link CngProductService}.
7+
*
8+
* Auth and transport mirror the Peek access service: the same app JWT
9+
* (`X-Peek-Auth: Bearer`) minted from the Peek app credentials via the shared
10+
* {@link TokenManager}, routed through the app-registry installations API. The
11+
* only differences are the extendable slug (`cng_backoffice_api-v1`), REST
12+
* rather than GraphQL, and no `pk-api-key` header.
13+
*/
14+
import {
15+
createTokenManager,
16+
requireNonEmpty,
17+
DEFAULT_RETRY_DELAYS_MS,
18+
type BaseAccessServiceConfig,
19+
} from "./access-service-config.js";
20+
import { CNG_EXTENDABLE_SLUG } from "./internal/cng/endpoints.js";
21+
import { CngProductService } from "./internal/cng/products/product-service.js";
22+
import { RestClient } from "./internal/cng/rest-client.js";
23+
import { noopLogger } from "./logger.js";
24+
25+
/** Default gateway base URL — the app-registry installations API. */
26+
const DEFAULT_BASE_URL = "https://app-registry.peeklabs.com/installations-api";
27+
28+
/**
29+
* Configuration for a {@link CngAccessService} instance. CNG adds no fields
30+
* beyond {@link BaseAccessServiceConfig} — it authenticates on the app JWT
31+
* alone (no `pk-api-key`, so no `gatewayKey`).
32+
*/
33+
export type CngAccessServiceConfig = BaseAccessServiceConfig;
34+
35+
/**
36+
* Authenticated root entry point to the CNG backoffice REST gateway.
37+
*
38+
* @example
39+
* ```ts
40+
* import { CngAccessService, type Activity } from "@peektravel/app-utilities";
41+
*
42+
* const cng = new CngAccessService({
43+
* installId: "install-123",
44+
* jwtSecret: process.env.PEEK_APP_SECRET!,
45+
* issuer: process.env.PEEK_APP_ID!,
46+
* appId: process.env.PEEK_APP_ID!,
47+
* });
48+
*
49+
* const activities: Activity[] = await cng.getAllActivities();
50+
* ```
51+
*
52+
* @throws {Error} from the constructor when any required config field
53+
* (`installId`, `jwtSecret`, `issuer`, `appId`) is empty.
54+
*/
55+
export class CngAccessService {
56+
private readonly client: RestClient;
57+
private productService?: CngProductService;
58+
59+
constructor(config: CngAccessServiceConfig) {
60+
requireNonEmpty(config.installId, "installId", "CngAccessService");
61+
requireNonEmpty(config.jwtSecret, "jwtSecret", "CngAccessService");
62+
requireNonEmpty(config.issuer, "issuer", "CngAccessService");
63+
requireNonEmpty(config.appId, "appId", "CngAccessService");
64+
65+
const logger = config.logger ?? noopLogger;
66+
const tokens = createTokenManager(config);
67+
68+
this.client = new RestClient({
69+
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
70+
appId: config.appId,
71+
extendableSlug: CNG_EXTENDABLE_SLUG,
72+
getToken: () => tokens.getToken(),
73+
retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
74+
logger,
75+
fetchFn: config.fetch ?? globalThis.fetch,
76+
});
77+
}
78+
79+
/**
80+
* Returns the {@link CngProductService} for this install, bound to the shared
81+
* authenticated transport. The instance is created lazily and reused.
82+
*/
83+
getProductService(): CngProductService {
84+
if (!this.productService) {
85+
this.productService = new CngProductService(this.client);
86+
}
87+
return this.productService;
88+
}
89+
90+
/** All activities. Delegates to {@link CngProductService.getAllActivities}. */
91+
getAllActivities() {
92+
return this.getProductService().getAllActivities();
93+
}
94+
}

src/errors.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
/**
22
* Typed errors thrown by the package. Each mirrors a failure mode of the Peek
3-
* GraphQL gateway so callers can branch on the error type rather than parsing
4-
* messages.
3+
* GraphQL gateway (or the CNG REST gateway) so callers can branch on the error
4+
* type rather than parsing messages.
5+
*
6+
* `AdminAccountRequiredError` and `RateLimitError` are shared by both gateways.
7+
* `PeekGraphQLError` is Peek-only; `CngApiError` is CNG-only.
58
*/
69

710
/**
@@ -46,3 +49,23 @@ export class PeekGraphQLError extends Error {
4649
this.graphqlErrors = graphqlErrors;
4750
}
4851
}
52+
53+
/**
54+
* Thrown when the CNG REST gateway returns a non-2xx response that is not one
55+
* of the specifically-handled statuses (418/429). The offending status is
56+
* preserved on {@link CngApiError.statusCode}, and the raw response body (parsed
57+
* JSON when possible, otherwise the raw text) on {@link CngApiError.body}.
58+
*/
59+
export class CngApiError extends Error {
60+
/** The HTTP status that triggered this error. */
61+
public readonly statusCode: number;
62+
/** The raw response body (parsed JSON when possible, otherwise text). */
63+
public readonly body: unknown;
64+
65+
constructor(statusCode: number, body: unknown, message?: string) {
66+
super(message ?? `CNG request failed with HTTP ${statusCode}`);
67+
this.name = "CngApiError";
68+
this.statusCode = statusCode;
69+
this.body = body;
70+
}
71+
}

0 commit comments

Comments
 (0)