@hiclimba/client 0.0.3 → 0.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hiclimba/client
2
2
 
3
- Toasty API SDK for product backends and customer-scoped integrations.
3
+ Climba API SDK for product backends and customer-scoped integrations.
4
4
 
5
5
  ```bash
6
6
  npm install @hiclimba/client
@@ -8,18 +8,18 @@ npm install @hiclimba/client
8
8
 
9
9
  ## Server Mode
10
10
 
11
- Server mode uses Toasty app HMAC credentials. Keep it on the product backend.
11
+ Server mode uses Climba app HMAC credentials. Keep it on the product backend.
12
12
 
13
13
  ```ts
14
- import { ToastyClient } from '@hiclimba/client';
14
+ import { ClimbaClient } from '@hiclimba/client';
15
15
 
16
- const toasty = new ToastyClient({
16
+ const climba = new ClimbaClient({
17
17
  appId: process.env.TOASTY_APP_ID!,
18
18
  keyId: process.env.TOASTY_KEY_ID!,
19
19
  secret: process.env.TOASTY_SECRET!,
20
20
  });
21
21
 
22
- const identified = await toasty.identify({
22
+ const identified = await climba.identify({
23
23
  shop: 'merchant.myshopify.com',
24
24
  shopifyShopGid: 'gid://shopify/Shop/123',
25
25
  });
@@ -28,15 +28,15 @@ const identified = await toasty.identify({
28
28
  The default API URL is `https://api.hiclimba.com`. Pass `apiUrl` only when you
29
29
  need to target a local, staging, or private API deployment.
30
30
 
31
- `identify()` returns `customerApiToken`. It is a random Toasty app-installation
31
+ `identify()` returns `customerApiToken`. It is a random Climba app-installation
32
32
  lookup token. It is not signed, encrypted, expiring, rotatable, privileged app
33
33
  auth, or any kind of Shopify token.
34
34
 
35
35
  If your backend already has a Shopify Admin delegate token for a known
36
- installation, server mode can push it to Toasty:
36
+ installation, server mode can push it to Climba:
37
37
 
38
38
  ```ts
39
- await toasty.pushShopifyAdminDelegateToken({
39
+ await climba.pushShopifyAdminDelegateToken({
40
40
  shop: 'merchant.myshopify.com',
41
41
  delegateToken: '<shopify-admin-delegate-token>',
42
42
  expiresAt: '2026-07-02T12:00:00.000Z',
@@ -44,24 +44,48 @@ await toasty.pushShopifyAdminDelegateToken({
44
44
  });
45
45
  ```
46
46
 
47
- Toasty validates the token against Shopify with a minimal Admin GraphQL request
47
+ Climba validates the token against Shopify with a minimal Admin GraphQL request
48
48
  before storing it encrypted on the existing installation. The pushed token is
49
49
  server-side only and must never be sent from browser code.
50
50
 
51
+ ## Delegate Token Provider Verification
52
+
53
+ If Climba pulls delegate tokens from your backend, verify those requests with
54
+ the server-only verifier subpath:
55
+
56
+ ```ts
57
+ import { verifyClimbaDelegateTokenProviderRequest } from '@hiclimba/client/delegate-token-provider';
58
+
59
+ const result = await verifyClimbaDelegateTokenProviderRequest({
60
+ rawBody,
61
+ headers,
62
+ shopifyAppClientSecret: process.env.SHOPIFY_API_SECRET!,
63
+ expectedAppId: process.env.TOASTY_APP_ID!,
64
+ consumeReplayKey: async ({ key, ttlSeconds }) => {
65
+ return await storeReplayKeyOnce(key, ttlSeconds);
66
+ },
67
+ });
68
+ ```
69
+
70
+ Climba signs the exact raw JSON body bytes with the Shopify app client secret
71
+ using standard Base64 HMAC-SHA256 and sends `x-climba-*` headers. Use the
72
+ signed body timestamp and request ID for freshness and replay checks. The
73
+ headers are mirrored for routing and logging only.
74
+
51
75
  ## Customer Mode
52
76
 
53
- Customer mode uses the Toasty app ID plus the `customerApiToken` returned by
77
+ Customer mode uses the Climba app ID plus the `customerApiToken` returned by
54
78
  `identify()`.
55
79
 
56
80
  ```ts
57
- const toasty = new ToastyClient({
81
+ const climba = new ClimbaClient({
58
82
  appId: '<toasty-app-id>',
59
83
  customerApiToken: '<customer-api-token>',
60
84
  });
61
85
 
62
- const customer = await toasty.getCustomer();
63
- const enabled = await toasty.isFeatureEnabled('api_access');
64
- await toasty.sendUsageEvent({
86
+ const customer = await climba.getCustomer();
87
+ const enabled = await climba.isFeatureEnabled('api_access');
88
+ await climba.sendUsageEvent({
65
89
  eventName: 'order_processed',
66
90
  idempotencyKey: 'order_123',
67
91
  });
@@ -0,0 +1 @@
1
+ export * from './lib/delegate-token-provider.js';
@@ -0,0 +1 @@
1
+ export * from './lib/delegate-token-provider.js';
package/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export * from './lib/toasty-client.js';
2
- export type * from './lib/toasty-client.types.js';
1
+ export * from './lib/climba-client.js';
2
+ export type * from './lib/climba-client.types.js';
package/index.js CHANGED
@@ -1 +1 @@
1
- export * from './lib/toasty-client.js';
1
+ export * from './lib/climba-client.js';
@@ -1,4 +1,4 @@
1
- import type { ICancelSubscriptionInput, ICancelSubscriptionResponse, ICreateOneTimeChargeInput, ICreateOneTimeChargeResponse, ICreateSubscriptionInput, ICreateSubscriptionResponse, IGetCustomerResponse, IGetUsageMetricReportInput, IGetUsageMetricReportResponse, IIdentifyInput, IIdentifyResponse, IPublicCustomerDto, IPublicCustomerFeatureDto, IPublicCustomerPlanDto, IPublicCustomerSubscriptionDto, IPublicCustomerUsageMetricDto, ITrackUsageEventBatchInput, ITrackUsageEventBatchResponse, ITrackUsageEventInput, ITrackUsageEventResponse, IUpdateSubscriptionInput, IUpdateSubscriptionResponse, IPushShopifyAdminDelegateTokenInput, IPushShopifyAdminDelegateTokenResponse, IPlanDiscount } from './toasty-client.types.js';
1
+ import type { ICancelSubscriptionInput, ICancelSubscriptionResponse, ICreateOneTimeChargeInput, ICreateOneTimeChargeResponse, ICreateSubscriptionInput, ICreateSubscriptionResponse, IGetCustomerResponse, IGetUsageMetricReportInput, IGetUsageMetricReportResponse, IIdentifyInput, IIdentifyResponse, IPlanDiscount, IPublicCustomerDto, IPublicCustomerFeatureDto, IPublicCustomerPlanDto, IPublicCustomerSubscriptionDto, IPublicCustomerUsageMetricDto, IPushShopifyAdminDelegateTokenInput, IPushShopifyAdminDelegateTokenResponse, ITrackUsageEventBatchInput, ITrackUsageEventBatchResponse, ITrackUsageEventInput, ITrackUsageEventResponse, IUpdateSubscriptionInput, IUpdateSubscriptionResponse } from './climba-client.types.js';
2
2
  export declare const DEFAULT_API_URL = "https://api.hiclimba.com";
3
3
  export type Customer = IPublicCustomerDto;
4
4
  export type Plan = IPublicCustomerPlanDto;
@@ -10,7 +10,7 @@ export type Discount = IPlanDiscount;
10
10
  export type AppliedDiscount = IPlanDiscount;
11
11
  export type UsageEvent = ITrackUsageEventResponse;
12
12
  export type SubscribeParams = ICreateSubscriptionInput;
13
- export interface IToastyServerClientConfig {
13
+ export interface IClimbaServerClientConfig {
14
14
  appId: string;
15
15
  keyId: string;
16
16
  secret: string;
@@ -18,14 +18,14 @@ export interface IToastyServerClientConfig {
18
18
  fetch?: typeof fetch;
19
19
  allowBrowserServerCredentials?: boolean;
20
20
  }
21
- export interface IToastyCustomerClientConfig {
21
+ export interface IClimbaCustomerClientConfig {
22
22
  appId: string;
23
23
  customerApiToken: string;
24
24
  apiUrl?: string;
25
25
  fetch?: typeof fetch;
26
26
  }
27
- export type IToastyClientConfig = IToastyServerClientConfig | IToastyCustomerClientConfig;
28
- export interface ICreateToastyHmacHeadersInput {
27
+ export type IClimbaClientConfig = IClimbaServerClientConfig | IClimbaCustomerClientConfig;
28
+ export interface ICreateClimbaHmacHeadersInput {
29
29
  appId: string;
30
30
  keyId: string;
31
31
  secret: string;
@@ -34,55 +34,55 @@ export interface ICreateToastyHmacHeadersInput {
34
34
  timestamp?: string;
35
35
  rawBody?: Buffer;
36
36
  }
37
- export interface ICreateToastyHmacHeadersResult {
37
+ export interface ICreateClimbaHmacHeadersResult {
38
38
  canonicalString: string;
39
39
  headers: Record<string, string>;
40
40
  }
41
- export interface IToastyError {
42
- name: 'ToastyError';
41
+ export interface IClimbaError {
42
+ name: 'ClimbaError';
43
43
  status: number;
44
44
  code: string;
45
45
  message: string;
46
46
  responseBody?: unknown;
47
47
  }
48
- export type ToastyError = IToastyError;
49
- type ToastyResult<T> = Promise<T | ToastyError>;
48
+ export type ClimbaError = IClimbaError;
49
+ type ClimbaResult<T> = Promise<T | ClimbaError>;
50
50
  export declare function sha256HexRawBody(rawBody?: Buffer): string;
51
- export declare function buildToastyHmacCanonicalString(input: {
51
+ export declare function buildClimbaHmacCanonicalString(input: {
52
52
  method: string;
53
53
  pathWithQuery: string;
54
54
  timestamp: string;
55
55
  rawBody?: Buffer;
56
56
  }): string;
57
- export declare function deriveToastyCredentialSigningKey(input: {
57
+ export declare function deriveClimbaCredentialSigningKey(input: {
58
58
  rawSecret: string;
59
59
  appPublicId: string;
60
60
  keyId: string;
61
61
  }): Buffer;
62
- export declare function signToastyHmacCanonicalString(canonicalString: string, signingKey: Buffer): string;
63
- export declare function createToastyHmacHeaders(input: ICreateToastyHmacHeadersInput): ICreateToastyHmacHeadersResult;
62
+ export declare function signClimbaHmacCanonicalString(canonicalString: string, signingKey: Buffer): string;
63
+ export declare function createClimbaHmacHeaders(input: ICreateClimbaHmacHeadersInput): ICreateClimbaHmacHeadersResult;
64
64
  export declare function getPathWithQuery(url: URL): string;
65
- export declare function isToastyError(value: unknown): value is ToastyError;
66
- export declare class ToastyClient {
65
+ export declare function isClimbaError(value: unknown): value is ClimbaError;
66
+ export declare class ClimbaClient {
67
67
  private readonly config;
68
68
  private readonly apiUrl;
69
69
  private readonly fetchImpl;
70
- constructor(config: IToastyClientConfig);
71
- identify(input: IIdentifyInput): ToastyResult<IIdentifyResponse>;
72
- pushShopifyAdminDelegateToken(input: IPushShopifyAdminDelegateTokenInput): ToastyResult<IPushShopifyAdminDelegateTokenResponse>;
70
+ constructor(config: IClimbaClientConfig);
71
+ identify(input: IIdentifyInput): ClimbaResult<IIdentifyResponse>;
72
+ pushShopifyAdminDelegateToken(input: IPushShopifyAdminDelegateTokenInput): ClimbaResult<IPushShopifyAdminDelegateTokenResponse>;
73
73
  getCustomer(input?: {
74
74
  customerId?: string;
75
- }): ToastyResult<IGetCustomerResponse>;
76
- evaluateFeature(featureKey: string): ToastyResult<Feature | null>;
77
- isFeatureEnabled(featureKey: string): ToastyResult<boolean>;
78
- limitForFeature(featureKey: string): ToastyResult<number | null>;
79
- subscribe(input: ICreateSubscriptionInput): ToastyResult<ICreateSubscriptionResponse>;
80
- cancelSubscription(input: ICancelSubscriptionInput): ToastyResult<ICancelSubscriptionResponse>;
81
- updateSubscription(input: IUpdateSubscriptionInput): ToastyResult<IUpdateSubscriptionResponse>;
82
- createOneTimeCharge(input: ICreateOneTimeChargeInput): ToastyResult<ICreateOneTimeChargeResponse>;
83
- sendUsageEvent(input: ITrackUsageEventInput): ToastyResult<ITrackUsageEventResponse>;
84
- sendUsageEvents(input: ITrackUsageEventBatchInput): ToastyResult<ITrackUsageEventBatchResponse>;
85
- getUsageMetricReport(input: IGetUsageMetricReportInput): ToastyResult<IGetUsageMetricReportResponse>;
75
+ }): ClimbaResult<IGetCustomerResponse>;
76
+ evaluateFeature(featureKey: string): ClimbaResult<Feature | null>;
77
+ isFeatureEnabled(featureKey: string): ClimbaResult<boolean>;
78
+ limitForFeature(featureKey: string): ClimbaResult<number | null>;
79
+ subscribe(input: ICreateSubscriptionInput): ClimbaResult<ICreateSubscriptionResponse>;
80
+ cancelSubscription(input: ICancelSubscriptionInput): ClimbaResult<ICancelSubscriptionResponse>;
81
+ updateSubscription(input: IUpdateSubscriptionInput): ClimbaResult<IUpdateSubscriptionResponse>;
82
+ createOneTimeCharge(input: ICreateOneTimeChargeInput): ClimbaResult<ICreateOneTimeChargeResponse>;
83
+ sendUsageEvent(input: ITrackUsageEventInput): ClimbaResult<ITrackUsageEventResponse>;
84
+ sendUsageEvents(input: ITrackUsageEventBatchInput): ClimbaResult<ITrackUsageEventBatchResponse>;
85
+ getUsageMetricReport(input: IGetUsageMetricReportInput): ClimbaResult<IGetUsageMetricReportResponse>;
86
86
  private get;
87
87
  private post;
88
88
  private requestJson;
@@ -7,32 +7,25 @@ export function sha256HexRawBody(rawBody) {
7
7
  .update(rawBody ?? Buffer.alloc(0))
8
8
  .digest('hex');
9
9
  }
10
- export function buildToastyHmacCanonicalString(input) {
11
- return [
12
- input.method.toUpperCase(),
13
- input.pathWithQuery,
14
- input.timestamp,
15
- sha256HexRawBody(input.rawBody),
16
- ].join('\n');
10
+ export function buildClimbaHmacCanonicalString(input) {
11
+ return [input.method.toUpperCase(), input.pathWithQuery, input.timestamp, sha256HexRawBody(input.rawBody)].join('\n');
17
12
  }
18
- export function deriveToastyCredentialSigningKey(input) {
13
+ export function deriveClimbaCredentialSigningKey(input) {
19
14
  return Buffer.from(hkdfSync('sha256', Buffer.from(input.rawSecret, 'utf8'), Buffer.from(`${input.appPublicId}:${input.keyId}`, 'utf8'), Buffer.from(SIGNING_KEY_INFO, 'utf8'), 32));
20
15
  }
21
- export function signToastyHmacCanonicalString(canonicalString, signingKey) {
22
- const digest = createHmac('sha256', signingKey)
23
- .update(canonicalString)
24
- .digest('hex');
16
+ export function signClimbaHmacCanonicalString(canonicalString, signingKey) {
17
+ const digest = createHmac('sha256', signingKey).update(canonicalString).digest('hex');
25
18
  return `${HMAC_SIGNATURE_PREFIX}${digest}`;
26
19
  }
27
- export function createToastyHmacHeaders(input) {
20
+ export function createClimbaHmacHeaders(input) {
28
21
  const timestamp = input.timestamp ?? new Date().toISOString();
29
- const canonicalString = buildToastyHmacCanonicalString({
22
+ const canonicalString = buildClimbaHmacCanonicalString({
30
23
  method: input.method,
31
24
  pathWithQuery: input.pathWithQuery,
32
25
  timestamp,
33
26
  ...(input.rawBody ? { rawBody: input.rawBody } : {}),
34
27
  });
35
- const signingKey = deriveToastyCredentialSigningKey({
28
+ const signingKey = deriveClimbaCredentialSigningKey({
36
29
  rawSecret: input.secret,
37
30
  appPublicId: input.appId,
38
31
  keyId: input.keyId,
@@ -43,19 +36,17 @@ export function createToastyHmacHeaders(input) {
43
36
  'x-toasty-app-id': input.appId,
44
37
  'x-toasty-key-id': input.keyId,
45
38
  'x-toasty-timestamp': timestamp,
46
- 'x-toasty-signature': signToastyHmacCanonicalString(canonicalString, signingKey),
39
+ 'x-toasty-signature': signClimbaHmacCanonicalString(canonicalString, signingKey),
47
40
  },
48
41
  };
49
42
  }
50
43
  export function getPathWithQuery(url) {
51
44
  return `${url.pathname}${url.search}`;
52
45
  }
53
- export function isToastyError(value) {
54
- return (typeof value === 'object' &&
55
- value !== null &&
56
- value.name === 'ToastyError');
46
+ export function isClimbaError(value) {
47
+ return typeof value === 'object' && value !== null && value.name === 'ClimbaError';
57
48
  }
58
- export class ToastyClient {
49
+ export class ClimbaClient {
59
50
  config;
60
51
  apiUrl;
61
52
  fetchImpl;
@@ -98,14 +89,14 @@ export class ToastyClient {
98
89
  async evaluateFeature(featureKey) {
99
90
  assertConfigValue('featureKey', featureKey);
100
91
  const response = await this.getCustomer();
101
- if (isToastyError(response)) {
92
+ if (isClimbaError(response)) {
102
93
  return response;
103
94
  }
104
95
  return response.customer.features[featureKey] ?? null;
105
96
  }
106
97
  async isFeatureEnabled(featureKey) {
107
98
  const feature = await this.evaluateFeature(featureKey);
108
- if (isToastyError(feature)) {
99
+ if (isClimbaError(feature)) {
109
100
  return feature;
110
101
  }
111
102
  if (!feature) {
@@ -115,7 +106,7 @@ export class ToastyClient {
115
106
  }
116
107
  async limitForFeature(featureKey) {
117
108
  const feature = await this.evaluateFeature(featureKey);
118
- if (isToastyError(feature)) {
109
+ if (isClimbaError(feature)) {
119
110
  return feature;
120
111
  }
121
112
  if (!feature) {
@@ -169,13 +160,13 @@ export class ToastyClient {
169
160
  });
170
161
  const responseBody = await readResponseBody(response);
171
162
  if (!response.ok) {
172
- return toToastyError(response.status, responseBody);
163
+ return toClimbaError(response.status, responseBody);
173
164
  }
174
165
  return responseBody;
175
166
  }
176
167
  createAuthHeaders(method, pathWithQuery, rawBody) {
177
168
  if (isServerConfig(this.config)) {
178
- return createToastyHmacHeaders({
169
+ return createClimbaHmacHeaders({
179
170
  appId: this.config.appId,
180
171
  keyId: this.config.keyId,
181
172
  secret: this.config.secret,
@@ -236,17 +227,11 @@ async function readResponseBody(response) {
236
227
  return text;
237
228
  }
238
229
  }
239
- function toToastyError(status, responseBody) {
240
- const body = typeof responseBody === 'object' && responseBody !== null
241
- ? responseBody
242
- : {};
243
- const code = typeof body.error === 'string'
244
- ? body.error
245
- : typeof body.code === 'string'
246
- ? body.code
247
- : 'toasty_api_error';
230
+ function toClimbaError(status, responseBody) {
231
+ const body = typeof responseBody === 'object' && responseBody !== null ? responseBody : {};
232
+ const code = typeof body.error === 'string' ? body.error : typeof body.code === 'string' ? body.code : 'toasty_api_error';
248
233
  return {
249
- name: 'ToastyError',
234
+ name: 'ClimbaError',
250
235
  status,
251
236
  code,
252
237
  message: code,
@@ -254,9 +239,7 @@ function toToastyError(status, responseBody) {
254
239
  };
255
240
  }
256
241
  function isBrowserRuntime() {
257
- return (typeof globalThis === 'object' &&
258
- 'window' in globalThis &&
259
- 'document' in globalThis);
242
+ return typeof globalThis === 'object' && 'window' in globalThis && 'document' in globalThis;
260
243
  }
261
244
  function assertNoShopifyTokenFields(input) {
262
245
  const value = input;
@@ -283,8 +266,7 @@ function featureValueEnabled(value) {
283
266
  return value.value !== 0;
284
267
  }
285
268
  function featureLimit(value) {
286
- if (value.type === 'limit' ||
287
- value.type === 'limit_with_overage') {
269
+ if (value.type === 'limit' || value.type === 'limit_with_overage') {
288
270
  return value.value;
289
271
  }
290
272
  return null;
@@ -95,7 +95,7 @@ export interface IUsageBillingCap {
95
95
  cappedAmount: number;
96
96
  }
97
97
  export type PublicPlanUsageBillingConfig = IUsageBillingConfig;
98
- export type ToastyBillingStatus = 'none' | 'pending_approval' | 'trialing' | 'active' | 'inactive';
98
+ export type ClimbaBillingStatus = 'none' | 'pending_approval' | 'trialing' | 'active' | 'inactive';
99
99
  export interface IPublicCustomerFeatureDto {
100
100
  id: string;
101
101
  key: string;
@@ -153,7 +153,7 @@ export interface IPublicCustomerDto {
153
153
  myshopifyDomain: string;
154
154
  displayName?: string;
155
155
  shopDomain?: string;
156
- billingStatus: ToastyBillingStatus;
156
+ billingStatus: ClimbaBillingStatus;
157
157
  plans: IPublicCustomerPlanDto[];
158
158
  subscription: IPublicCustomerSubscriptionDto | null;
159
159
  features: Record<string, IPublicCustomerFeatureDto>;
@@ -0,0 +1,37 @@
1
+ export type ClimbaDelegateTokenProviderHeaderName = 'x-climba-hmac-sha256' | 'x-climba-timestamp' | 'x-climba-request-id' | 'x-climba-app-id';
2
+ export type ClimbaDelegateTokenProviderVerificationFailureCode = 'missing_header' | 'duplicate_header' | 'invalid_hmac' | 'malformed_json' | 'header_body_mismatch' | 'stale_timestamp' | 'replayed_request' | 'invalid_body';
3
+ export interface IClimbaDelegateTokenProviderRequestBody {
4
+ app_id: string;
5
+ installation_id: string;
6
+ customer_id: string;
7
+ myshopify_domain: string;
8
+ shopify_shop_gid?: string;
9
+ timestamp: string;
10
+ request_id: string;
11
+ }
12
+ export interface IVerifyClimbaDelegateTokenProviderRequestInput {
13
+ rawBody: Buffer | Uint8Array | string;
14
+ headers: Headers | Record<string, string | string[] | undefined>;
15
+ shopifyAppClientSecret: string;
16
+ expectedAppId?: string;
17
+ now?: Date;
18
+ toleranceSeconds?: number;
19
+ consumeReplayKey?: (input: {
20
+ key: string;
21
+ appId: string;
22
+ requestId: string;
23
+ ttlSeconds: number;
24
+ }) => boolean | Promise<boolean>;
25
+ }
26
+ export interface IVerifyClimbaDelegateTokenProviderRequestSuccess {
27
+ ok: true;
28
+ body: IClimbaDelegateTokenProviderRequestBody;
29
+ replayKey: string;
30
+ replayChecked: boolean;
31
+ }
32
+ export interface IVerifyClimbaDelegateTokenProviderRequestFailure {
33
+ ok: false;
34
+ code: ClimbaDelegateTokenProviderVerificationFailureCode;
35
+ }
36
+ export type VerifyClimbaDelegateTokenProviderRequestResult = IVerifyClimbaDelegateTokenProviderRequestSuccess | IVerifyClimbaDelegateTokenProviderRequestFailure;
37
+ export declare function verifyClimbaDelegateTokenProviderRequest(input: IVerifyClimbaDelegateTokenProviderRequestInput): Promise<VerifyClimbaDelegateTokenProviderRequestResult>;
@@ -0,0 +1,185 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ const DEFAULT_TOLERANCE_SECONDS = 300;
3
+ const SIGNATURE_HEADER = 'x-climba-hmac-sha256';
4
+ const TIMESTAMP_HEADER = 'x-climba-timestamp';
5
+ const REQUEST_ID_HEADER = 'x-climba-request-id';
6
+ const APP_ID_HEADER = 'x-climba-app-id';
7
+ const REQUIRED_HEADERS = [
8
+ SIGNATURE_HEADER,
9
+ TIMESTAMP_HEADER,
10
+ REQUEST_ID_HEADER,
11
+ APP_ID_HEADER,
12
+ ];
13
+ export async function verifyClimbaDelegateTokenProviderRequest(input) {
14
+ const headers = extractRequiredHeaders(input.headers);
15
+ if (!headers.ok) {
16
+ return headers;
17
+ }
18
+ const rawBodyBytes = normalizeRawBody(input.rawBody);
19
+ if (!verifyBase64Hmac({
20
+ rawBodyBytes,
21
+ providedSignature: headers.values[SIGNATURE_HEADER],
22
+ shopifyAppClientSecret: input.shopifyAppClientSecret,
23
+ })) {
24
+ return { ok: false, code: 'invalid_hmac' };
25
+ }
26
+ const parsedBody = parseJsonBody(rawBodyBytes);
27
+ if (!parsedBody.ok) {
28
+ return { ok: false, code: 'malformed_json' };
29
+ }
30
+ const body = validateBody(parsedBody.value);
31
+ if (!body) {
32
+ return { ok: false, code: 'invalid_body' };
33
+ }
34
+ if (body.app_id !== headers.values[APP_ID_HEADER] ||
35
+ body.timestamp !== headers.values[TIMESTAMP_HEADER] ||
36
+ body.request_id !== headers.values[REQUEST_ID_HEADER]) {
37
+ return { ok: false, code: 'header_body_mismatch' };
38
+ }
39
+ if (input.expectedAppId && body.app_id !== input.expectedAppId) {
40
+ return { ok: false, code: 'header_body_mismatch' };
41
+ }
42
+ if (input.expectedAppId && headers.values[APP_ID_HEADER] !== input.expectedAppId) {
43
+ return { ok: false, code: 'header_body_mismatch' };
44
+ }
45
+ const toleranceSeconds = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
46
+ if (!isFreshTimestamp(body.timestamp, input.now ?? new Date(), toleranceSeconds)) {
47
+ return { ok: false, code: 'stale_timestamp' };
48
+ }
49
+ const replayKey = `${body.app_id}:${body.request_id}`;
50
+ if (input.consumeReplayKey) {
51
+ const replayAccepted = await input.consumeReplayKey({
52
+ key: replayKey,
53
+ appId: body.app_id,
54
+ requestId: body.request_id,
55
+ ttlSeconds: toleranceSeconds,
56
+ });
57
+ if (!replayAccepted) {
58
+ return { ok: false, code: 'replayed_request' };
59
+ }
60
+ }
61
+ return {
62
+ ok: true,
63
+ body,
64
+ replayKey,
65
+ replayChecked: Boolean(input.consumeReplayKey),
66
+ };
67
+ }
68
+ function extractRequiredHeaders(headers) {
69
+ const normalized = new Map();
70
+ if (isHeaders(headers)) {
71
+ for (const name of REQUIRED_HEADERS) {
72
+ const value = headers.get(name);
73
+ if (value !== null) {
74
+ normalized.set(name, value);
75
+ }
76
+ }
77
+ }
78
+ else {
79
+ for (const [name, value] of Object.entries(headers)) {
80
+ const lowerName = name.toLowerCase();
81
+ if (!isRequiredHeader(lowerName) || value === undefined) {
82
+ continue;
83
+ }
84
+ if (normalized.has(lowerName)) {
85
+ return { ok: false, code: 'duplicate_header' };
86
+ }
87
+ normalized.set(lowerName, value);
88
+ }
89
+ }
90
+ const values = {};
91
+ for (const name of REQUIRED_HEADERS) {
92
+ const value = normalized.get(name);
93
+ if (value === undefined) {
94
+ return { ok: false, code: 'missing_header' };
95
+ }
96
+ if (Array.isArray(value) || value.includes(',')) {
97
+ return { ok: false, code: 'duplicate_header' };
98
+ }
99
+ if (!value.trim()) {
100
+ return { ok: false, code: 'missing_header' };
101
+ }
102
+ values[name] = value.trim();
103
+ }
104
+ return { ok: true, values };
105
+ }
106
+ function isHeaders(value) {
107
+ return typeof value.get === 'function';
108
+ }
109
+ function isRequiredHeader(value) {
110
+ return REQUIRED_HEADERS.includes(value);
111
+ }
112
+ function normalizeRawBody(rawBody) {
113
+ if (typeof rawBody === 'string') {
114
+ return Buffer.from(rawBody, 'utf8');
115
+ }
116
+ return Buffer.from(rawBody);
117
+ }
118
+ function verifyBase64Hmac(input) {
119
+ if (!isStandardSha256Base64(input.providedSignature)) {
120
+ return false;
121
+ }
122
+ const expectedSignature = createHmac('sha256', Buffer.from(input.shopifyAppClientSecret, 'utf8'))
123
+ .update(input.rawBodyBytes)
124
+ .digest('base64');
125
+ const expected = Buffer.from(expectedSignature, 'utf8');
126
+ const provided = Buffer.from(input.providedSignature, 'utf8');
127
+ return expected.length === provided.length && timingSafeEqual(expected, provided);
128
+ }
129
+ function isStandardSha256Base64(value) {
130
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(value)) {
131
+ return false;
132
+ }
133
+ try {
134
+ return Buffer.from(value, 'base64').length === 32;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ function parseJsonBody(rawBodyBytes) {
141
+ try {
142
+ return { ok: true, value: JSON.parse(rawBodyBytes.toString('utf8')) };
143
+ }
144
+ catch {
145
+ return { ok: false };
146
+ }
147
+ }
148
+ function validateBody(parsed) {
149
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
150
+ return null;
151
+ }
152
+ const body = parsed;
153
+ if (!isNonEmptyString(body.app_id) ||
154
+ !isNonEmptyString(body.installation_id) ||
155
+ !isNonEmptyString(body.customer_id) ||
156
+ !isNonEmptyString(body.myshopify_domain) ||
157
+ !isNonEmptyString(body.timestamp) ||
158
+ !isNonEmptyString(body.request_id)) {
159
+ return null;
160
+ }
161
+ if (body.shopify_shop_gid !== undefined &&
162
+ typeof body.shopify_shop_gid !== 'string') {
163
+ return null;
164
+ }
165
+ return {
166
+ app_id: body.app_id,
167
+ installation_id: body.installation_id,
168
+ customer_id: body.customer_id,
169
+ myshopify_domain: body.myshopify_domain,
170
+ ...(body.shopify_shop_gid ? { shopify_shop_gid: body.shopify_shop_gid } : {}),
171
+ timestamp: body.timestamp,
172
+ request_id: body.request_id,
173
+ };
174
+ }
175
+ function isNonEmptyString(value) {
176
+ return typeof value === 'string' && Boolean(value.trim());
177
+ }
178
+ function isFreshTimestamp(value, now, toleranceSeconds) {
179
+ const timestamp = new Date(value);
180
+ if (Number.isNaN(timestamp.getTime())) {
181
+ return false;
182
+ }
183
+ const differenceMs = Math.abs(now.getTime() - timestamp.getTime());
184
+ return differenceMs <= toleranceSeconds * 1000;
185
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hiclimba/client",
3
- "version": "0.0.3",
4
- "description": "Node SDK for Toasty product backends and customer-scoped API access.",
3
+ "version": "0.0.4",
4
+ "description": "Node SDK for Climba product backends and customer-scoped API access.",
5
5
  "license": "MIT",
6
6
  "private": false,
7
7
  "type": "module",
@@ -14,12 +14,19 @@
14
14
  "types": "./index.d.ts",
15
15
  "import": "./index.js",
16
16
  "default": "./index.js"
17
+ },
18
+ "./delegate-token-provider": {
19
+ "types": "./delegate-token-provider.d.ts",
20
+ "import": "./delegate-token-provider.js",
21
+ "default": "./delegate-token-provider.js"
17
22
  }
18
23
  },
19
24
  "sideEffects": false,
20
25
  "files": [
21
26
  "index.js",
22
27
  "index.d.ts",
28
+ "delegate-token-provider.js",
29
+ "delegate-token-provider.d.ts",
23
30
  "lib/**/*.js",
24
31
  "lib/**/*.d.ts",
25
32
  "README.md",