@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 +55 -13
- 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/climba-client.d.ts +92 -0
- package/lib/{toasty-client.js → climba-client.js} +31 -43
- package/lib/{toasty-client.types.d.ts → climba-client.types.d.ts} +16 -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.d.ts +0 -90
- /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,43 +8,84 @@ 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
|
-
apiUrl: process.env.TOASTY_API_URL!,
|
|
21
20
|
});
|
|
22
21
|
|
|
23
|
-
const identified = await
|
|
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
|
-
|
|
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
|
|
77
|
+
Customer mode uses the Climba app ID plus the `customerApiToken` returned by
|
|
36
78
|
`identify()`.
|
|
37
79
|
|
|
38
80
|
```ts
|
|
39
|
-
const
|
|
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
|
|
46
|
-
const enabled = await
|
|
47
|
-
await
|
|
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/
|
|
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';
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
20
|
+
export function createClimbaHmacHeaders(input) {
|
|
27
21
|
const timestamp = input.timestamp ?? new Date().toISOString();
|
|
28
|
-
const canonicalString =
|
|
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 =
|
|
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':
|
|
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
|
|
53
|
-
return
|
|
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
|
|
49
|
+
export class ClimbaClient {
|
|
58
50
|
config;
|
|
59
51
|
apiUrl;
|
|
60
52
|
fetchImpl;
|
|
61
53
|
constructor(config) {
|
|
62
54
|
assertConfigValue('appId', config.appId);
|
|
63
|
-
|
|
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(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
234
|
-
const body = typeof responseBody === 'object' && responseBody !== null
|
|
235
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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>;
|
|
@@ -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.
|
|
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",
|
package/lib/toasty-client.d.ts
DELETED
|
@@ -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 {};
|
|
File without changes
|