@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 +38 -14
- package/delegate-token-provider.d.ts +1 -0
- package/delegate-token-provider.js +1 -0
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/lib/{toasty-client.d.ts → climba-client.d.ts} +30 -30
- package/lib/{toasty-client.js → climba-client.js} +23 -41
- package/lib/{toasty-client.types.d.ts → climba-client.types.d.ts} +2 -2
- package/lib/delegate-token-provider.d.ts +37 -0
- package/lib/delegate-token-provider.js +185 -0
- package/package.json +9 -2
- /package/lib/{toasty-client.types.js → climba-client.types.js} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @hiclimba/client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
11
|
+
Server mode uses Climba app HMAC credentials. Keep it on the product backend.
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
|
-
import {
|
|
14
|
+
import { ClimbaClient } from '@hiclimba/client';
|
|
15
15
|
|
|
16
|
-
const
|
|
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
|
|
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
|
|
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
|
|
36
|
+
installation, server mode can push it to Climba:
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
|
-
await
|
|
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
|
-
|
|
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
|
|
77
|
+
Customer mode uses the Climba app ID plus the `customerApiToken` returned by
|
|
54
78
|
`identify()`.
|
|
55
79
|
|
|
56
80
|
```ts
|
|
57
|
-
const
|
|
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
|
|
63
|
-
const enabled = await
|
|
64
|
-
await
|
|
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/
|
|
2
|
-
export type * from './lib/
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
28
|
-
export interface
|
|
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
|
|
37
|
+
export interface ICreateClimbaHmacHeadersResult {
|
|
38
38
|
canonicalString: string;
|
|
39
39
|
headers: Record<string, string>;
|
|
40
40
|
}
|
|
41
|
-
export interface
|
|
42
|
-
name: '
|
|
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
|
|
49
|
-
type
|
|
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
|
|
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
|
|
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
|
|
63
|
-
export declare function
|
|
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
|
|
66
|
-
export declare class
|
|
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:
|
|
71
|
-
identify(input: IIdentifyInput):
|
|
72
|
-
pushShopifyAdminDelegateToken(input: IPushShopifyAdminDelegateTokenInput):
|
|
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
|
-
}):
|
|
76
|
-
evaluateFeature(featureKey: string):
|
|
77
|
-
isFeatureEnabled(featureKey: string):
|
|
78
|
-
limitForFeature(featureKey: string):
|
|
79
|
-
subscribe(input: ICreateSubscriptionInput):
|
|
80
|
-
cancelSubscription(input: ICancelSubscriptionInput):
|
|
81
|
-
updateSubscription(input: IUpdateSubscriptionInput):
|
|
82
|
-
createOneTimeCharge(input: ICreateOneTimeChargeInput):
|
|
83
|
-
sendUsageEvent(input: ITrackUsageEventInput):
|
|
84
|
-
sendUsageEvents(input: ITrackUsageEventBatchInput):
|
|
85
|
-
getUsageMetricReport(input: IGetUsageMetricReportInput):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
20
|
+
export function createClimbaHmacHeaders(input) {
|
|
28
21
|
const timestamp = input.timestamp ?? new Date().toISOString();
|
|
29
|
-
const canonicalString =
|
|
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 =
|
|
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':
|
|
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
|
|
54
|
-
return
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
240
|
-
const body = typeof responseBody === 'object' && responseBody !== null
|
|
241
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
4
|
-
"description": "Node SDK for
|
|
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",
|
|
File without changes
|