@hiclimba/client 0.0.2 → 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,43 +8,84 @@ 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
- apiUrl: process.env.TOASTY_API_URL!,
21
20
  });
22
21
 
23
- const identified = await toasty.identify({
22
+ const identified = await climba.identify({
24
23
  shop: 'merchant.myshopify.com',
25
24
  shopifyShopGid: 'gid://shopify/Shop/123',
26
25
  });
27
26
  ```
28
27
 
29
- `identify()` returns `customerApiToken`. It is a random Toasty app-installation
28
+ The default API URL is `https://api.hiclimba.com`. Pass `apiUrl` only when you
29
+ need to target a local, staging, or private API deployment.
30
+
31
+ `identify()` returns `customerApiToken`. It is a random Climba app-installation
30
32
  lookup token. It is not signed, encrypted, expiring, rotatable, privileged app
31
33
  auth, or any kind of Shopify token.
32
34
 
35
+ If your backend already has a Shopify Admin delegate token for a known
36
+ installation, server mode can push it to Climba:
37
+
38
+ ```ts
39
+ await climba.pushShopifyAdminDelegateToken({
40
+ shop: 'merchant.myshopify.com',
41
+ delegateToken: '<shopify-admin-delegate-token>',
42
+ expiresAt: '2026-07-02T12:00:00.000Z',
43
+ accessScopes: ['read_products']
44
+ });
45
+ ```
46
+
47
+ Climba validates the token against Shopify with a minimal Admin GraphQL request
48
+ before storing it encrypted on the existing installation. The pushed token is
49
+ server-side only and must never be sent from browser code.
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
+
33
75
  ## Customer Mode
34
76
 
35
- 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
36
78
  `identify()`.
37
79
 
38
80
  ```ts
39
- const toasty = new ToastyClient({
81
+ const climba = new ClimbaClient({
40
82
  appId: '<toasty-app-id>',
41
83
  customerApiToken: '<customer-api-token>',
42
- apiUrl: 'https://api.toasty.dev',
43
84
  });
44
85
 
45
- const customer = await toasty.getCustomer();
46
- const enabled = await toasty.isFeatureEnabled('api_access');
47
- await toasty.sendUsageEvent({
86
+ const customer = await climba.getCustomer();
87
+ const enabled = await climba.isFeatureEnabled('api_access');
88
+ await climba.sendUsageEvent({
48
89
  eventName: 'order_processed',
49
90
  idempotencyKey: 'order_123',
50
91
  });
@@ -60,6 +101,7 @@ Authorization: Bearer custok_...
60
101
  ## Top-Level Methods
61
102
 
62
103
  - `identify`
104
+ - `pushShopifyAdminDelegateToken`
63
105
  - `getCustomer`
64
106
  - `evaluateFeature`
65
107
  - `isFeatureEnabled`
@@ -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';
@@ -0,0 +1,92 @@
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
+ export declare const DEFAULT_API_URL = "https://api.hiclimba.com";
3
+ export type Customer = IPublicCustomerDto;
4
+ export type Plan = IPublicCustomerPlanDto;
5
+ export type Subscription = IPublicCustomerSubscriptionDto;
6
+ export type Feature = IPublicCustomerFeatureDto;
7
+ export type UsageMetric = IPublicCustomerUsageMetricDto;
8
+ export type UsageCharge = ICreateOneTimeChargeResponse;
9
+ export type Discount = IPlanDiscount;
10
+ export type AppliedDiscount = IPlanDiscount;
11
+ export type UsageEvent = ITrackUsageEventResponse;
12
+ export type SubscribeParams = ICreateSubscriptionInput;
13
+ export interface IClimbaServerClientConfig {
14
+ appId: string;
15
+ keyId: string;
16
+ secret: string;
17
+ apiUrl?: string;
18
+ fetch?: typeof fetch;
19
+ allowBrowserServerCredentials?: boolean;
20
+ }
21
+ export interface IClimbaCustomerClientConfig {
22
+ appId: string;
23
+ customerApiToken: string;
24
+ apiUrl?: string;
25
+ fetch?: typeof fetch;
26
+ }
27
+ export type IClimbaClientConfig = IClimbaServerClientConfig | IClimbaCustomerClientConfig;
28
+ export interface ICreateClimbaHmacHeadersInput {
29
+ appId: string;
30
+ keyId: string;
31
+ secret: string;
32
+ method: string;
33
+ pathWithQuery: string;
34
+ timestamp?: string;
35
+ rawBody?: Buffer;
36
+ }
37
+ export interface ICreateClimbaHmacHeadersResult {
38
+ canonicalString: string;
39
+ headers: Record<string, string>;
40
+ }
41
+ export interface IClimbaError {
42
+ name: 'ClimbaError';
43
+ status: number;
44
+ code: string;
45
+ message: string;
46
+ responseBody?: unknown;
47
+ }
48
+ export type ClimbaError = IClimbaError;
49
+ type ClimbaResult<T> = Promise<T | ClimbaError>;
50
+ export declare function sha256HexRawBody(rawBody?: Buffer): string;
51
+ export declare function buildClimbaHmacCanonicalString(input: {
52
+ method: string;
53
+ pathWithQuery: string;
54
+ timestamp: string;
55
+ rawBody?: Buffer;
56
+ }): string;
57
+ export declare function deriveClimbaCredentialSigningKey(input: {
58
+ rawSecret: string;
59
+ appPublicId: string;
60
+ keyId: string;
61
+ }): Buffer;
62
+ export declare function signClimbaHmacCanonicalString(canonicalString: string, signingKey: Buffer): string;
63
+ export declare function createClimbaHmacHeaders(input: ICreateClimbaHmacHeadersInput): ICreateClimbaHmacHeadersResult;
64
+ export declare function getPathWithQuery(url: URL): string;
65
+ export declare function isClimbaError(value: unknown): value is ClimbaError;
66
+ export declare class ClimbaClient {
67
+ private readonly config;
68
+ private readonly apiUrl;
69
+ private readonly fetchImpl;
70
+ constructor(config: IClimbaClientConfig);
71
+ identify(input: IIdentifyInput): ClimbaResult<IIdentifyResponse>;
72
+ pushShopifyAdminDelegateToken(input: IPushShopifyAdminDelegateTokenInput): ClimbaResult<IPushShopifyAdminDelegateTokenResponse>;
73
+ getCustomer(input?: {
74
+ customerId?: string;
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
+ private get;
87
+ private post;
88
+ private requestJson;
89
+ private createAuthHeaders;
90
+ private assertServerMode;
91
+ }
92
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { createHash, createHmac, hkdfSync } from 'node:crypto';
2
+ export const DEFAULT_API_URL = 'https://api.hiclimba.com';
2
3
  const HMAC_SIGNATURE_PREFIX = 'v1=';
3
4
  const SIGNING_KEY_INFO = 'toasty-hmac-signing-key-v1';
4
5
  export function sha256HexRawBody(rawBody) {
@@ -6,32 +7,25 @@ export function sha256HexRawBody(rawBody) {
6
7
  .update(rawBody ?? Buffer.alloc(0))
7
8
  .digest('hex');
8
9
  }
9
- export function buildToastyHmacCanonicalString(input) {
10
- return [
11
- input.method.toUpperCase(),
12
- input.pathWithQuery,
13
- input.timestamp,
14
- sha256HexRawBody(input.rawBody),
15
- ].join('\n');
10
+ export function buildClimbaHmacCanonicalString(input) {
11
+ return [input.method.toUpperCase(), input.pathWithQuery, input.timestamp, sha256HexRawBody(input.rawBody)].join('\n');
16
12
  }
17
- export function deriveToastyCredentialSigningKey(input) {
13
+ export function deriveClimbaCredentialSigningKey(input) {
18
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));
19
15
  }
20
- export function signToastyHmacCanonicalString(canonicalString, signingKey) {
21
- const digest = createHmac('sha256', signingKey)
22
- .update(canonicalString)
23
- .digest('hex');
16
+ export function signClimbaHmacCanonicalString(canonicalString, signingKey) {
17
+ const digest = createHmac('sha256', signingKey).update(canonicalString).digest('hex');
24
18
  return `${HMAC_SIGNATURE_PREFIX}${digest}`;
25
19
  }
26
- export function createToastyHmacHeaders(input) {
20
+ export function createClimbaHmacHeaders(input) {
27
21
  const timestamp = input.timestamp ?? new Date().toISOString();
28
- const canonicalString = buildToastyHmacCanonicalString({
22
+ const canonicalString = buildClimbaHmacCanonicalString({
29
23
  method: input.method,
30
24
  pathWithQuery: input.pathWithQuery,
31
25
  timestamp,
32
26
  ...(input.rawBody ? { rawBody: input.rawBody } : {}),
33
27
  });
34
- const signingKey = deriveToastyCredentialSigningKey({
28
+ const signingKey = deriveClimbaCredentialSigningKey({
35
29
  rawSecret: input.secret,
36
30
  appPublicId: input.appId,
37
31
  keyId: input.keyId,
@@ -42,25 +36,24 @@ export function createToastyHmacHeaders(input) {
42
36
  'x-toasty-app-id': input.appId,
43
37
  'x-toasty-key-id': input.keyId,
44
38
  'x-toasty-timestamp': timestamp,
45
- 'x-toasty-signature': signToastyHmacCanonicalString(canonicalString, signingKey),
39
+ 'x-toasty-signature': signClimbaHmacCanonicalString(canonicalString, signingKey),
46
40
  },
47
41
  };
48
42
  }
49
43
  export function getPathWithQuery(url) {
50
44
  return `${url.pathname}${url.search}`;
51
45
  }
52
- export function isToastyError(value) {
53
- return (typeof value === 'object' &&
54
- value !== null &&
55
- value.name === 'ToastyError');
46
+ export function isClimbaError(value) {
47
+ return typeof value === 'object' && value !== null && value.name === 'ClimbaError';
56
48
  }
57
- export class ToastyClient {
49
+ export class ClimbaClient {
58
50
  config;
59
51
  apiUrl;
60
52
  fetchImpl;
61
53
  constructor(config) {
62
54
  assertConfigValue('appId', config.appId);
63
- assertConfigValue('apiUrl', config.apiUrl);
55
+ const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
56
+ assertConfigValue('apiUrl', apiUrl);
64
57
  if (isServerConfig(config)) {
65
58
  assertConfigValue('keyId', config.keyId);
66
59
  assertConfigValue('secret', config.secret);
@@ -72,7 +65,7 @@ export class ToastyClient {
72
65
  assertConfigValue('customerApiToken', config.customerApiToken);
73
66
  }
74
67
  this.config = config;
75
- this.apiUrl = normalizeApiUrl(config.apiUrl);
68
+ this.apiUrl = normalizeApiUrl(apiUrl);
76
69
  this.fetchImpl = config.fetch ?? fetch;
77
70
  }
78
71
  identify(input) {
@@ -80,6 +73,10 @@ export class ToastyClient {
80
73
  assertNoShopifyTokenFields(input);
81
74
  return this.post('/v1/identify', input);
82
75
  }
76
+ pushShopifyAdminDelegateToken(input) {
77
+ this.assertServerMode('pushShopifyAdminDelegateToken');
78
+ return this.post('/v1/shopify-admin/delegate-token', input);
79
+ }
83
80
  getCustomer(input = {}) {
84
81
  if (isServerConfig(this.config)) {
85
82
  if (!input.customerId) {
@@ -92,14 +89,14 @@ export class ToastyClient {
92
89
  async evaluateFeature(featureKey) {
93
90
  assertConfigValue('featureKey', featureKey);
94
91
  const response = await this.getCustomer();
95
- if (isToastyError(response)) {
92
+ if (isClimbaError(response)) {
96
93
  return response;
97
94
  }
98
95
  return response.customer.features[featureKey] ?? null;
99
96
  }
100
97
  async isFeatureEnabled(featureKey) {
101
98
  const feature = await this.evaluateFeature(featureKey);
102
- if (isToastyError(feature)) {
99
+ if (isClimbaError(feature)) {
103
100
  return feature;
104
101
  }
105
102
  if (!feature) {
@@ -109,7 +106,7 @@ export class ToastyClient {
109
106
  }
110
107
  async limitForFeature(featureKey) {
111
108
  const feature = await this.evaluateFeature(featureKey);
112
- if (isToastyError(feature)) {
109
+ if (isClimbaError(feature)) {
113
110
  return feature;
114
111
  }
115
112
  if (!feature) {
@@ -163,13 +160,13 @@ export class ToastyClient {
163
160
  });
164
161
  const responseBody = await readResponseBody(response);
165
162
  if (!response.ok) {
166
- return toToastyError(response.status, responseBody);
163
+ return toClimbaError(response.status, responseBody);
167
164
  }
168
165
  return responseBody;
169
166
  }
170
167
  createAuthHeaders(method, pathWithQuery, rawBody) {
171
168
  if (isServerConfig(this.config)) {
172
- return createToastyHmacHeaders({
169
+ return createClimbaHmacHeaders({
173
170
  appId: this.config.appId,
174
171
  keyId: this.config.keyId,
175
172
  secret: this.config.secret,
@@ -230,17 +227,11 @@ async function readResponseBody(response) {
230
227
  return text;
231
228
  }
232
229
  }
233
- function toToastyError(status, responseBody) {
234
- const body = typeof responseBody === 'object' && responseBody !== null
235
- ? responseBody
236
- : {};
237
- const code = typeof body.error === 'string'
238
- ? body.error
239
- : typeof body.code === 'string'
240
- ? body.code
241
- : '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';
242
233
  return {
243
- name: 'ToastyError',
234
+ name: 'ClimbaError',
244
235
  status,
245
236
  code,
246
237
  message: code,
@@ -248,9 +239,7 @@ function toToastyError(status, responseBody) {
248
239
  };
249
240
  }
250
241
  function isBrowserRuntime() {
251
- return (typeof globalThis === 'object' &&
252
- 'window' in globalThis &&
253
- 'document' in globalThis);
242
+ return typeof globalThis === 'object' && 'window' in globalThis && 'document' in globalThis;
254
243
  }
255
244
  function assertNoShopifyTokenFields(input) {
256
245
  const value = input;
@@ -277,8 +266,7 @@ function featureValueEnabled(value) {
277
266
  return value.value !== 0;
278
267
  }
279
268
  function featureLimit(value) {
280
- if (value.type === 'limit' ||
281
- value.type === 'limit_with_overage') {
269
+ if (value.type === 'limit' || value.type === 'limit_with_overage') {
282
270
  return value.value;
283
271
  }
284
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>;
@@ -183,6 +183,20 @@ export interface IIdentifyResponse {
183
183
  customerApiToken: string;
184
184
  customer: IPublicCustomerDto;
185
185
  }
186
+ export interface IPushShopifyAdminDelegateTokenInput {
187
+ shop: string;
188
+ delegateToken: string;
189
+ expiresAt?: string;
190
+ expiresInSeconds?: number;
191
+ accessScopes?: string[];
192
+ }
193
+ export interface IPushShopifyAdminDelegateTokenResponse {
194
+ ok: true;
195
+ installationId: string;
196
+ myshopifyDomain: string;
197
+ shopifyShopId?: string;
198
+ expiresAt?: string;
199
+ }
186
200
  export interface ICreateSubscriptionInput {
187
201
  shop?: string;
188
202
  returnUrl: string;
@@ -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.2",
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",
@@ -1,90 +0,0 @@
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, IPlanDiscount } from './toasty-client.types.js';
2
- export type Customer = IPublicCustomerDto;
3
- export type Plan = IPublicCustomerPlanDto;
4
- export type Subscription = IPublicCustomerSubscriptionDto;
5
- export type Feature = IPublicCustomerFeatureDto;
6
- export type UsageMetric = IPublicCustomerUsageMetricDto;
7
- export type UsageCharge = ICreateOneTimeChargeResponse;
8
- export type Discount = IPlanDiscount;
9
- export type AppliedDiscount = IPlanDiscount;
10
- export type UsageEvent = ITrackUsageEventResponse;
11
- export type SubscribeParams = ICreateSubscriptionInput;
12
- export interface IToastyServerClientConfig {
13
- appId: string;
14
- keyId: string;
15
- secret: string;
16
- apiUrl: string;
17
- fetch?: typeof fetch;
18
- allowBrowserServerCredentials?: boolean;
19
- }
20
- export interface IToastyCustomerClientConfig {
21
- appId: string;
22
- customerApiToken: string;
23
- apiUrl: string;
24
- fetch?: typeof fetch;
25
- }
26
- export type IToastyClientConfig = IToastyServerClientConfig | IToastyCustomerClientConfig;
27
- export interface ICreateToastyHmacHeadersInput {
28
- appId: string;
29
- keyId: string;
30
- secret: string;
31
- method: string;
32
- pathWithQuery: string;
33
- timestamp?: string;
34
- rawBody?: Buffer;
35
- }
36
- export interface ICreateToastyHmacHeadersResult {
37
- canonicalString: string;
38
- headers: Record<string, string>;
39
- }
40
- export interface IToastyError {
41
- name: 'ToastyError';
42
- status: number;
43
- code: string;
44
- message: string;
45
- responseBody?: unknown;
46
- }
47
- export type ToastyError = IToastyError;
48
- type ToastyResult<T> = Promise<T | ToastyError>;
49
- export declare function sha256HexRawBody(rawBody?: Buffer): string;
50
- export declare function buildToastyHmacCanonicalString(input: {
51
- method: string;
52
- pathWithQuery: string;
53
- timestamp: string;
54
- rawBody?: Buffer;
55
- }): string;
56
- export declare function deriveToastyCredentialSigningKey(input: {
57
- rawSecret: string;
58
- appPublicId: string;
59
- keyId: string;
60
- }): Buffer;
61
- export declare function signToastyHmacCanonicalString(canonicalString: string, signingKey: Buffer): string;
62
- export declare function createToastyHmacHeaders(input: ICreateToastyHmacHeadersInput): ICreateToastyHmacHeadersResult;
63
- export declare function getPathWithQuery(url: URL): string;
64
- export declare function isToastyError(value: unknown): value is ToastyError;
65
- export declare class ToastyClient {
66
- private readonly config;
67
- private readonly apiUrl;
68
- private readonly fetchImpl;
69
- constructor(config: IToastyClientConfig);
70
- identify(input: IIdentifyInput): ToastyResult<IIdentifyResponse>;
71
- getCustomer(input?: {
72
- customerId?: string;
73
- }): ToastyResult<IGetCustomerResponse>;
74
- evaluateFeature(featureKey: string): ToastyResult<Feature | null>;
75
- isFeatureEnabled(featureKey: string): ToastyResult<boolean>;
76
- limitForFeature(featureKey: string): ToastyResult<number | null>;
77
- subscribe(input: ICreateSubscriptionInput): ToastyResult<ICreateSubscriptionResponse>;
78
- cancelSubscription(input: ICancelSubscriptionInput): ToastyResult<ICancelSubscriptionResponse>;
79
- updateSubscription(input: IUpdateSubscriptionInput): ToastyResult<IUpdateSubscriptionResponse>;
80
- createOneTimeCharge(input: ICreateOneTimeChargeInput): ToastyResult<ICreateOneTimeChargeResponse>;
81
- sendUsageEvent(input: ITrackUsageEventInput): ToastyResult<ITrackUsageEventResponse>;
82
- sendUsageEvents(input: ITrackUsageEventBatchInput): ToastyResult<ITrackUsageEventBatchResponse>;
83
- getUsageMetricReport(input: IGetUsageMetricReportInput): ToastyResult<IGetUsageMetricReportResponse>;
84
- private get;
85
- private post;
86
- private requestJson;
87
- private createAuthHeaders;
88
- private assertServerMode;
89
- }
90
- export {};