@vaam-apps/vpay-sdk 0.2.1
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/LICENSE +21 -0
- package/README.md +732 -0
- package/dist/auth.d.ts +180 -0
- package/dist/auth.js +297 -0
- package/dist/client.d.ts +75 -0
- package/dist/client.js +79 -0
- package/dist/errors.d.ts +91 -0
- package/dist/errors.js +112 -0
- package/dist/form.d.ts +47 -0
- package/dist/form.js +127 -0
- package/dist/http.d.ts +33 -0
- package/dist/http.js +115 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/resources/account-holders.d.ts +36 -0
- package/dist/resources/account-holders.js +46 -0
- package/dist/resources/balance.d.ts +8 -0
- package/dist/resources/balance.js +10 -0
- package/dist/resources/checkout-sessions.d.ts +55 -0
- package/dist/resources/checkout-sessions.js +127 -0
- package/dist/resources/customers.d.ts +97 -0
- package/dist/resources/customers.js +157 -0
- package/dist/resources/events.d.ts +8 -0
- package/dist/resources/events.js +10 -0
- package/dist/resources/invoices.d.ts +193 -0
- package/dist/resources/invoices.js +275 -0
- package/dist/resources/payment-intents.d.ts +12 -0
- package/dist/resources/payment-intents.js +44 -0
- package/dist/resources/refunds.d.ts +92 -0
- package/dist/resources/refunds.js +106 -0
- package/dist/stripe-auth.d.ts +177 -0
- package/dist/stripe-auth.js +192 -0
- package/dist/types.d.ts +1268 -0
- package/dist/types.js +48 -0
- package/dist/validate.d.ts +16 -0
- package/dist/validate.js +20 -0
- package/dist/version.d.ts +12 -0
- package/dist/version.js +12 -0
- package/dist/webhooks.d.ts +22 -0
- package/dist/webhooks.js +94 -0
- package/package.json +63 -0
- package/scripts/mint-assertion.mjs +73 -0
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `private_key_jwt` client assertion minting and access-token management,
|
|
3
|
+
* per docs/flows/merchant-auth.md.
|
|
4
|
+
*/
|
|
5
|
+
import { type KeyObject } from "node:crypto";
|
|
6
|
+
/** RFC 7523's fixed URN for this grant's `client_assertion_type`. */
|
|
7
|
+
export declare const CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
8
|
+
/** `authkestra-op`'s ceiling on how far in the future `exp` may sit (`MAX_CLIENT_ASSERTION_LIFETIME_SECS`). */
|
|
9
|
+
export declare const MAX_ASSERTION_LIFETIME_SECONDS = 300;
|
|
10
|
+
/**
|
|
11
|
+
* Default client-assertion lifetime, in seconds. Well inside the OP's
|
|
12
|
+
* `1..=300` bound — see the README's note on why raising it is a trap.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_ASSERTION_LIFETIME_SECONDS = 60;
|
|
15
|
+
/**
|
|
16
|
+
* Default OAuth2 `audience` request parameter. Server-side the same string is
|
|
17
|
+
* `vpay_config::MERCHANT_AUDIENCE`; this package keeps its own copy, so the
|
|
18
|
+
* two must change together (docs/flows/merchant-auth.md, "The token request").
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_AUDIENCE = "vpay:v1";
|
|
21
|
+
/** Default timeout for the token exchange and every resource call, in milliseconds. */
|
|
22
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
23
|
+
/** Validates a configured assertion lifetime against the OP's `1..=300` bound. */
|
|
24
|
+
export declare function validateAssertionLifetimeSeconds(lifetimeSeconds: number): void;
|
|
25
|
+
export interface MintClientAssertionOptions {
|
|
26
|
+
clientId: string;
|
|
27
|
+
privateKey: string | KeyObject;
|
|
28
|
+
/**
|
|
29
|
+
* The assertion's `aud` claim: the OP's own token endpoint URL, or its
|
|
30
|
+
* issuer identifier — both are accepted by `authenticate_client`
|
|
31
|
+
* (docs/flows/merchant-auth.md). Not necessarily the URL the caller POSTs
|
|
32
|
+
* to; see `MerchantAuthOptions.assertionAudience`.
|
|
33
|
+
*/
|
|
34
|
+
audience: string;
|
|
35
|
+
kid?: string | undefined;
|
|
36
|
+
/** Default 60. Must be an integer in `1..=300`. */
|
|
37
|
+
lifetimeSeconds?: number | undefined;
|
|
38
|
+
/** Unix seconds. Defaults to `Date.now() / 1000`, injectable for tests. */
|
|
39
|
+
now?: number | undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Mints an RS256 `private_key_jwt` client assertion
|
|
43
|
+
* (docs/flows/merchant-auth.md, "The client assertion" table).
|
|
44
|
+
*
|
|
45
|
+
* Signed with `crypto.sign('sha256', ..., rsaKey)`, which for an RSA key
|
|
46
|
+
* without an explicit padding option produces PKCS#1 v1.5 — exactly RS256.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mintClientAssertion(options: MintClientAssertionOptions): string;
|
|
49
|
+
export interface TokenManagerOptions {
|
|
50
|
+
clientId: string;
|
|
51
|
+
/**
|
|
52
|
+
* The signing key as a parsed {@link KeyObject}, never PEM text.
|
|
53
|
+
*
|
|
54
|
+
* {@link resolveMerchantAuth} parses it **once**, at construction: a PEM
|
|
55
|
+
* that `createPrivateKey` cannot read is a `VpayConfigError` the merchant
|
|
56
|
+
* sees at startup rather than a `crypto` exception thrown out of the first
|
|
57
|
+
* token exchange, and every later assertion signs against the same already
|
|
58
|
+
* parsed key instead of re-parsing the same text per mint.
|
|
59
|
+
*/
|
|
60
|
+
privateKey: KeyObject;
|
|
61
|
+
kid: string | undefined;
|
|
62
|
+
/** Where the token request is POSTed — reachable from *this* process. */
|
|
63
|
+
tokenEndpoint: string;
|
|
64
|
+
/**
|
|
65
|
+
* The client assertion's `aud` claim — what the OP calls itself. Resolved
|
|
66
|
+
* by {@link resolveMerchantAuth}; defaults to {@link tokenEndpoint}, which
|
|
67
|
+
* is right only when this process reaches vpay at the same URL vpay
|
|
68
|
+
* publishes as its own.
|
|
69
|
+
*/
|
|
70
|
+
assertionAudience: string;
|
|
71
|
+
/** The OAuth2 `audience` **request parameter** (`vpay:v1`). Not the `aud` claim. */
|
|
72
|
+
audience: string;
|
|
73
|
+
scope: string | undefined;
|
|
74
|
+
assertionLifetimeSeconds: number;
|
|
75
|
+
timeoutMs: number;
|
|
76
|
+
fetchImpl: typeof fetch;
|
|
77
|
+
userAgent: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Caches the merchant's `/v1` access token and mints a fresh one only when
|
|
81
|
+
* needed, per docs/flows/merchant-auth.md's "Using the token" section:
|
|
82
|
+
*
|
|
83
|
+
* - Cached until `expires_in − margin` has elapsed (margin: 30s, or half of
|
|
84
|
+
* `expires_in` for short TTLs — integer arithmetic only).
|
|
85
|
+
* - Concurrent callers share one in-flight token request (a single `jti`
|
|
86
|
+
* spent per refresh, not one per caller).
|
|
87
|
+
* - {@link TokenManager.invalidate} lets the caller force a re-auth on a 401.
|
|
88
|
+
*/
|
|
89
|
+
export declare class TokenManager {
|
|
90
|
+
#private;
|
|
91
|
+
constructor(options: TokenManagerOptions);
|
|
92
|
+
getToken(): Promise<string>;
|
|
93
|
+
/** Discards the cached token, forcing the next {@link getToken} call to re-authenticate. */
|
|
94
|
+
invalidate(): void;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The subset of an entry point's constructor options that decides how this
|
|
98
|
+
* package authenticates: everything the {@link TokenManager} needs, plus the
|
|
99
|
+
* `baseUrl` the defaults are derived from.
|
|
100
|
+
*
|
|
101
|
+
* Shared by {@link VpayClient} and `createStripeAuthenticator` on purpose.
|
|
102
|
+
* The two entry points mint the *same* assertion against the *same* endpoint
|
|
103
|
+
* with the *same* defaults; resolving those defaults in one place is what
|
|
104
|
+
* stops them from drifting apart as one of them is edited.
|
|
105
|
+
*
|
|
106
|
+
* Every optional property is `?: T | undefined` for the
|
|
107
|
+
* `exactOptionalPropertyTypes` reason spelled out on `VpayClientOptions`.
|
|
108
|
+
*/
|
|
109
|
+
export interface MerchantAuthOptions {
|
|
110
|
+
/** vpay's base URL, e.g. `https://api.vpay.example`. */
|
|
111
|
+
baseUrl: string;
|
|
112
|
+
/** This merchant's registered OAuth2 `client_id`. */
|
|
113
|
+
clientId: string;
|
|
114
|
+
/** The merchant's RSA private key — PEM text or a `crypto.KeyObject`. */
|
|
115
|
+
privateKey: string | KeyObject;
|
|
116
|
+
/** Required only if the merchant registered more than one JWK. */
|
|
117
|
+
kid?: string | undefined;
|
|
118
|
+
/** Default `${baseUrl}/v1/oauth`. */
|
|
119
|
+
issuer?: string | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* The URL this package POSTs the token request to. Default
|
|
122
|
+
* `${issuer}/token`.
|
|
123
|
+
*
|
|
124
|
+
* This is a *reachability* setting: it must resolve from wherever your
|
|
125
|
+
* server runs — a compose service name, a private DNS name, a mesh
|
|
126
|
+
* address. It is **not** necessarily what the assertion is addressed to;
|
|
127
|
+
* see {@link assertionAudience}.
|
|
128
|
+
*/
|
|
129
|
+
tokenEndpoint?: string | undefined;
|
|
130
|
+
/**
|
|
131
|
+
* The OP's own token endpoint (or issuer) as vpay is configured publicly —
|
|
132
|
+
* `{deployment.public_base_url}/v1/oauth/token`, or
|
|
133
|
+
* `{deployment.public_base_url}/v1/oauth`. This is what the client
|
|
134
|
+
* assertion's `aud` claim is signed as. **Set it when your server reaches
|
|
135
|
+
* vpay by a different URL than payers do.**
|
|
136
|
+
*
|
|
137
|
+
* Default: {@link tokenEndpoint}, which is correct only when the two are
|
|
138
|
+
* the same string. When they are not — a merchant server calling
|
|
139
|
+
* `http://vpay-server:8080` inside a compose network, or a private name in
|
|
140
|
+
* production — the OP's `authenticate_client` compares the `aud` claim
|
|
141
|
+
* against its own `{token endpoint, issuer}` pair, neither of which is the
|
|
142
|
+
* internal URL, and every token request answers `invalid_client` /
|
|
143
|
+
* `InvalidAudience`. Nothing else in the handshake is wrong, which is why
|
|
144
|
+
* the symptom carries no hint at the cause.
|
|
145
|
+
*/
|
|
146
|
+
assertionAudience?: string | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* The OAuth2 `audience` **request parameter** — a form field naming the
|
|
149
|
+
* resource server (`vpay:v1`), not the assertion's `aud` claim. Default
|
|
150
|
+
* {@link DEFAULT_AUDIENCE}.
|
|
151
|
+
*/
|
|
152
|
+
audience?: string | undefined;
|
|
153
|
+
/** Omitted from the token request unless set. */
|
|
154
|
+
scope?: string | undefined;
|
|
155
|
+
/** Default {@link DEFAULT_ASSERTION_LIFETIME_SECONDS}. Must be an integer in `1..=300`. */
|
|
156
|
+
assertionLifetimeSeconds?: number | undefined;
|
|
157
|
+
/** Default {@link DEFAULT_TIMEOUT_MS}. */
|
|
158
|
+
timeoutMs?: number | undefined;
|
|
159
|
+
/** Injection point for tests and proxies. Defaults to the global `fetch`. */
|
|
160
|
+
fetch?: typeof fetch | undefined;
|
|
161
|
+
}
|
|
162
|
+
/** What {@link resolveMerchantAuth} hands back: a ready {@link TokenManager} and the settings it was built from. */
|
|
163
|
+
export interface ResolvedMerchantAuth {
|
|
164
|
+
/** `baseUrl` with any trailing slash removed. */
|
|
165
|
+
baseUrl: string;
|
|
166
|
+
clientId: string;
|
|
167
|
+
timeoutMs: number;
|
|
168
|
+
fetchImpl: typeof fetch;
|
|
169
|
+
userAgent: string;
|
|
170
|
+
tokenManager: TokenManager;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Validates {@link MerchantAuthOptions}, applies this package's defaults, and
|
|
174
|
+
* builds the {@link TokenManager} both entry points share.
|
|
175
|
+
*
|
|
176
|
+
* Every failure it can detect is detected here — at construction — rather
|
|
177
|
+
* than on the first request: a merchant with an out-of-range
|
|
178
|
+
* `assertionLifetimeSeconds` finds out at startup, not at 3am under load.
|
|
179
|
+
*/
|
|
180
|
+
export declare function resolveMerchantAuth(options: MerchantAuthOptions): ResolvedMerchantAuth;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `private_key_jwt` client assertion minting and access-token management,
|
|
3
|
+
* per docs/flows/merchant-auth.md.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID, createPrivateKey, sign, } from "node:crypto";
|
|
6
|
+
import { VpayAuthError, VpayConfigError, VpayTransportError, VpayUnexpectedResponseError, boundedBodyPrefix, } from "./errors.js";
|
|
7
|
+
import { encodeForm } from "./form.js";
|
|
8
|
+
import { SDK_VERSION } from "./version.js";
|
|
9
|
+
/** RFC 7523's fixed URN for this grant's `client_assertion_type`. */
|
|
10
|
+
export const CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
11
|
+
/** `authkestra-op`'s ceiling on how far in the future `exp` may sit (`MAX_CLIENT_ASSERTION_LIFETIME_SECS`). */
|
|
12
|
+
export const MAX_ASSERTION_LIFETIME_SECONDS = 300;
|
|
13
|
+
const MIN_ASSERTION_LIFETIME_SECONDS = 1;
|
|
14
|
+
/**
|
|
15
|
+
* Default client-assertion lifetime, in seconds. Well inside the OP's
|
|
16
|
+
* `1..=300` bound — see the README's note on why raising it is a trap.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_ASSERTION_LIFETIME_SECONDS = 60;
|
|
19
|
+
/**
|
|
20
|
+
* Default OAuth2 `audience` request parameter. Server-side the same string is
|
|
21
|
+
* `vpay_config::MERCHANT_AUDIENCE`; this package keeps its own copy, so the
|
|
22
|
+
* two must change together (docs/flows/merchant-auth.md, "The token request").
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_AUDIENCE = "vpay:v1";
|
|
25
|
+
/** Default timeout for the token exchange and every resource call, in milliseconds. */
|
|
26
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
27
|
+
/** Validates a configured assertion lifetime against the OP's `1..=300` bound. */
|
|
28
|
+
export function validateAssertionLifetimeSeconds(lifetimeSeconds) {
|
|
29
|
+
if (!Number.isInteger(lifetimeSeconds) ||
|
|
30
|
+
lifetimeSeconds < MIN_ASSERTION_LIFETIME_SECONDS ||
|
|
31
|
+
lifetimeSeconds > MAX_ASSERTION_LIFETIME_SECONDS) {
|
|
32
|
+
throw new VpayConfigError(`assertionLifetimeSeconds must be an integer between ${MIN_ASSERTION_LIFETIME_SECONDS} and ` +
|
|
33
|
+
`${MAX_ASSERTION_LIFETIME_SECONDS}, got ${lifetimeSeconds}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function base64url(input) {
|
|
37
|
+
return input.toString("base64url");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Mints an RS256 `private_key_jwt` client assertion
|
|
41
|
+
* (docs/flows/merchant-auth.md, "The client assertion" table).
|
|
42
|
+
*
|
|
43
|
+
* Signed with `crypto.sign('sha256', ..., rsaKey)`, which for an RSA key
|
|
44
|
+
* without an explicit padding option produces PKCS#1 v1.5 — exactly RS256.
|
|
45
|
+
*/
|
|
46
|
+
export function mintClientAssertion(options) {
|
|
47
|
+
const lifetimeSeconds = options.lifetimeSeconds ?? DEFAULT_ASSERTION_LIFETIME_SECONDS;
|
|
48
|
+
validateAssertionLifetimeSeconds(lifetimeSeconds);
|
|
49
|
+
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
50
|
+
const header = {
|
|
51
|
+
alg: "RS256",
|
|
52
|
+
typ: "JWT",
|
|
53
|
+
};
|
|
54
|
+
if (options.kid !== undefined) {
|
|
55
|
+
header.kid = options.kid;
|
|
56
|
+
}
|
|
57
|
+
const payload = {
|
|
58
|
+
iss: options.clientId,
|
|
59
|
+
sub: options.clientId,
|
|
60
|
+
aud: options.audience,
|
|
61
|
+
jti: randomUUID(),
|
|
62
|
+
exp: now + lifetimeSeconds,
|
|
63
|
+
iat: now,
|
|
64
|
+
};
|
|
65
|
+
const encodedHeader = base64url(Buffer.from(JSON.stringify(header), "utf8"));
|
|
66
|
+
const encodedPayload = base64url(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
67
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
68
|
+
const keyObject = typeof options.privateKey === "string"
|
|
69
|
+
? createPrivateKey(options.privateKey)
|
|
70
|
+
: options.privateKey;
|
|
71
|
+
const signature = sign("sha256", Buffer.from(signingInput, "utf8"), keyObject);
|
|
72
|
+
return `${signingInput}.${base64url(signature)}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Recognises `authkestra_op::handlers::token::TokenResponse`.
|
|
76
|
+
*
|
|
77
|
+
* `token_type` must be `Bearer`, compared case-insensitively per RFC 6749
|
|
78
|
+
* §7.1 (the value is a case-insensitive token, and OPs differ on `Bearer` vs
|
|
79
|
+
* `bearer`). Anything else — a `MAC` or `DPoP` token, say — is a token this
|
|
80
|
+
* SDK does not know how to present: it only ever sends
|
|
81
|
+
* `Authorization: Bearer …`. Such a response is reported as a
|
|
82
|
+
* {@link VpayUnexpectedResponseError}, the same as any other 200 whose body
|
|
83
|
+
* is not the documented shape; it is not an authentication *failure*, so it
|
|
84
|
+
* is deliberately not a {@link VpayAuthError}.
|
|
85
|
+
*/
|
|
86
|
+
function isTokenResponseBody(value) {
|
|
87
|
+
if (typeof value !== "object" || value === null) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const record = value;
|
|
91
|
+
const tokenType = record["token_type"];
|
|
92
|
+
return (typeof record["access_token"] === "string" &&
|
|
93
|
+
typeof tokenType === "string" &&
|
|
94
|
+
tokenType.toLowerCase() === "bearer" &&
|
|
95
|
+
typeof record["expires_in"] === "number");
|
|
96
|
+
}
|
|
97
|
+
function isTokenErrorBody(value) {
|
|
98
|
+
return (typeof value === "object" &&
|
|
99
|
+
value !== null &&
|
|
100
|
+
typeof value["error"] === "string");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Caches the merchant's `/v1` access token and mints a fresh one only when
|
|
104
|
+
* needed, per docs/flows/merchant-auth.md's "Using the token" section:
|
|
105
|
+
*
|
|
106
|
+
* - Cached until `expires_in − margin` has elapsed (margin: 30s, or half of
|
|
107
|
+
* `expires_in` for short TTLs — integer arithmetic only).
|
|
108
|
+
* - Concurrent callers share one in-flight token request (a single `jti`
|
|
109
|
+
* spent per refresh, not one per caller).
|
|
110
|
+
* - {@link TokenManager.invalidate} lets the caller force a re-auth on a 401.
|
|
111
|
+
*/
|
|
112
|
+
export class TokenManager {
|
|
113
|
+
#options;
|
|
114
|
+
#cached;
|
|
115
|
+
#inflight;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.#options = options;
|
|
118
|
+
}
|
|
119
|
+
async getToken() {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
if (this.#cached && this.#cached.expiresAtMs > now) {
|
|
122
|
+
return this.#cached.accessToken;
|
|
123
|
+
}
|
|
124
|
+
if (!this.#inflight) {
|
|
125
|
+
this.#inflight = this.#fetchToken().finally(() => {
|
|
126
|
+
this.#inflight = undefined;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return this.#inflight;
|
|
130
|
+
}
|
|
131
|
+
/** Discards the cached token, forcing the next {@link getToken} call to re-authenticate. */
|
|
132
|
+
invalidate() {
|
|
133
|
+
this.#cached = undefined;
|
|
134
|
+
}
|
|
135
|
+
async #fetchToken() {
|
|
136
|
+
const assertion = mintClientAssertion({
|
|
137
|
+
clientId: this.#options.clientId,
|
|
138
|
+
privateKey: this.#options.privateKey,
|
|
139
|
+
// `aud` is the **OP's own token endpoint (or issuer)**, never the
|
|
140
|
+
// `audience` request parameter below (`vpay:v1`). RFC 7523 §3 and the
|
|
141
|
+
// OP's own `authenticate_client` both compare it against the endpoint
|
|
142
|
+
// as the OP names itself — `{deployment.public_base_url}/v1/oauth/token`
|
|
143
|
+
// or that issuer — which is not necessarily the URL this process
|
|
144
|
+
// POSTed to. The two are different values with different jobs
|
|
145
|
+
// (docs/flows/merchant-auth.md, "The client assertion").
|
|
146
|
+
audience: this.#options.assertionAudience,
|
|
147
|
+
kid: this.#options.kid,
|
|
148
|
+
lifetimeSeconds: this.#options.assertionLifetimeSeconds,
|
|
149
|
+
});
|
|
150
|
+
const formBody = {
|
|
151
|
+
grant_type: "client_credentials",
|
|
152
|
+
client_id: this.#options.clientId,
|
|
153
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
|
154
|
+
client_assertion: assertion,
|
|
155
|
+
audience: this.#options.audience,
|
|
156
|
+
};
|
|
157
|
+
if (this.#options.scope !== undefined) {
|
|
158
|
+
formBody["scope"] = this.#options.scope;
|
|
159
|
+
}
|
|
160
|
+
let status;
|
|
161
|
+
let ok;
|
|
162
|
+
let text;
|
|
163
|
+
try {
|
|
164
|
+
const response = await this.#options.fetchImpl(this.#options.tokenEndpoint, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
168
|
+
Accept: "application/json",
|
|
169
|
+
"User-Agent": this.#options.userAgent,
|
|
170
|
+
},
|
|
171
|
+
body: encodeForm(formBody),
|
|
172
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs),
|
|
173
|
+
});
|
|
174
|
+
status = response.status;
|
|
175
|
+
ok = response.ok;
|
|
176
|
+
// Read the body inside the same `try`: `fetch` resolves on headers, so
|
|
177
|
+
// a body that stalls (or the timeout firing mid-stream) rejects here.
|
|
178
|
+
// Outside this block it escapes as a raw `DOMException: TimeoutError`
|
|
179
|
+
// rather than a `VpayTransportError` carrying it as `cause`.
|
|
180
|
+
text = await response.text();
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
throw new VpayTransportError("token request failed", { cause: err });
|
|
184
|
+
}
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = text.length > 0 ? JSON.parse(text) : undefined;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
parsed = undefined;
|
|
191
|
+
}
|
|
192
|
+
if (!ok) {
|
|
193
|
+
if (isTokenErrorBody(parsed)) {
|
|
194
|
+
throw new VpayAuthError(parsed.error, parsed.error_description);
|
|
195
|
+
}
|
|
196
|
+
throw new VpayUnexpectedResponseError(status, boundedBodyPrefix(text));
|
|
197
|
+
}
|
|
198
|
+
if (!isTokenResponseBody(parsed)) {
|
|
199
|
+
throw new VpayUnexpectedResponseError(status, boundedBodyPrefix(text));
|
|
200
|
+
}
|
|
201
|
+
const margin = Math.min(30, Math.floor(parsed.expires_in / 2));
|
|
202
|
+
const ttlSeconds = Math.max(parsed.expires_in - margin, 0);
|
|
203
|
+
this.#cached = {
|
|
204
|
+
accessToken: parsed.access_token,
|
|
205
|
+
expiresAtMs: Date.now() + ttlSeconds * 1000,
|
|
206
|
+
};
|
|
207
|
+
return parsed.access_token;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function requireNonEmptyString(value, field) {
|
|
211
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
212
|
+
throw new VpayConfigError(`${field} is required`);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
function stripTrailingSlash(url) {
|
|
217
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Turns a configured signing key into a {@link KeyObject}, once.
|
|
221
|
+
*
|
|
222
|
+
* Two things this buys, and both are the reason it is here rather than in
|
|
223
|
+
* {@link mintClientAssertion}'s per-call path:
|
|
224
|
+
*
|
|
225
|
+
* - **A bad key is a startup failure.** `createPrivateKey('')`, or a PEM with
|
|
226
|
+
* a mangled header, throws an OpenSSL `Error` out of `crypto`. Left to the
|
|
227
|
+
* first mint, that surfaced as an unrecognisable exception from the middle
|
|
228
|
+
* of a token exchange — and, through `@vaam-apps/vpay-sdk/stripe`, as stripe-node's
|
|
229
|
+
* detached `unhandledRejection` (see `stripe-auth.ts`). Parsed here it is a
|
|
230
|
+
* {@link VpayConfigError} raised by the constructor the merchant wrote.
|
|
231
|
+
* - **One parse, not one per request.** RSA PEM decoding is not free, and
|
|
232
|
+
* nothing about it changes between mints.
|
|
233
|
+
*
|
|
234
|
+
* The message never quotes the key: the input is the merchant's private key,
|
|
235
|
+
* and a `VpayConfigError` is exactly the sort of thing that reaches a log.
|
|
236
|
+
* `cause` carries OpenSSL's own reason, which names the decoder that failed
|
|
237
|
+
* and no key material.
|
|
238
|
+
*/
|
|
239
|
+
function parsePrivateKey(privateKey) {
|
|
240
|
+
if (typeof privateKey !== "string") {
|
|
241
|
+
return privateKey;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return createPrivateKey(privateKey);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
throw new VpayConfigError("privateKey could not be read as a private key: expected PEM text (or a crypto.KeyObject)", { cause: err });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Validates {@link MerchantAuthOptions}, applies this package's defaults, and
|
|
252
|
+
* builds the {@link TokenManager} both entry points share.
|
|
253
|
+
*
|
|
254
|
+
* Every failure it can detect is detected here — at construction — rather
|
|
255
|
+
* than on the first request: a merchant with an out-of-range
|
|
256
|
+
* `assertionLifetimeSeconds` finds out at startup, not at 3am under load.
|
|
257
|
+
*/
|
|
258
|
+
export function resolveMerchantAuth(options) {
|
|
259
|
+
const baseUrl = stripTrailingSlash(requireNonEmptyString(options.baseUrl, "baseUrl"));
|
|
260
|
+
const clientId = requireNonEmptyString(options.clientId, "clientId");
|
|
261
|
+
if (options.privateKey === undefined || options.privateKey === null) {
|
|
262
|
+
throw new VpayConfigError("privateKey is required");
|
|
263
|
+
}
|
|
264
|
+
const privateKey = parsePrivateKey(options.privateKey);
|
|
265
|
+
const assertionLifetimeSeconds = options.assertionLifetimeSeconds ?? DEFAULT_ASSERTION_LIFETIME_SECONDS;
|
|
266
|
+
validateAssertionLifetimeSeconds(assertionLifetimeSeconds);
|
|
267
|
+
const issuer = options.issuer ?? `${baseUrl}/v1/oauth`;
|
|
268
|
+
const tokenEndpoint = options.tokenEndpoint ?? `${issuer}/token`;
|
|
269
|
+
// Defaults to the URL we POST to — unchanged behaviour for every merchant
|
|
270
|
+
// whose server reaches vpay at the same URL vpay publishes as its own.
|
|
271
|
+
// A merchant reaching vpay by an internal name sets it explicitly.
|
|
272
|
+
const assertionAudience = options.assertionAudience ?? tokenEndpoint;
|
|
273
|
+
const audience = options.audience ?? DEFAULT_AUDIENCE;
|
|
274
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
275
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
276
|
+
const userAgent = `vpay-sdk-node/${SDK_VERSION}`;
|
|
277
|
+
return {
|
|
278
|
+
baseUrl,
|
|
279
|
+
clientId,
|
|
280
|
+
timeoutMs,
|
|
281
|
+
fetchImpl,
|
|
282
|
+
userAgent,
|
|
283
|
+
tokenManager: new TokenManager({
|
|
284
|
+
clientId,
|
|
285
|
+
privateKey,
|
|
286
|
+
kid: options.kid,
|
|
287
|
+
tokenEndpoint,
|
|
288
|
+
assertionAudience,
|
|
289
|
+
audience,
|
|
290
|
+
scope: options.scope,
|
|
291
|
+
assertionLifetimeSeconds,
|
|
292
|
+
timeoutMs,
|
|
293
|
+
fetchImpl,
|
|
294
|
+
userAgent,
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
297
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type MerchantAuthOptions } from "./auth.js";
|
|
2
|
+
import { AccountHoldersResource } from "./resources/account-holders.js";
|
|
3
|
+
import { BalanceResource } from "./resources/balance.js";
|
|
4
|
+
import { CheckoutResource } from "./resources/checkout-sessions.js";
|
|
5
|
+
import { CustomersResource } from "./resources/customers.js";
|
|
6
|
+
import { EventsResource } from "./resources/events.js";
|
|
7
|
+
import { InvoiceItemsResource, InvoicesResource } from "./resources/invoices.js";
|
|
8
|
+
import { PaymentIntentsResource } from "./resources/payment-intents.js";
|
|
9
|
+
import { RefundsResource } from "./resources/refunds.js";
|
|
10
|
+
/**
|
|
11
|
+
* Constructor options for {@link VpayClient} — see the package README's
|
|
12
|
+
* configuration table.
|
|
13
|
+
*
|
|
14
|
+
* **This is {@link MerchantAuthOptions}**, which is also what
|
|
15
|
+
* `createStripeAuthenticator` takes. The two entry points authenticate the
|
|
16
|
+
* same way against the same endpoint with the same defaults
|
|
17
|
+
* ({@link resolveMerchantAuth} is the single implementation), so they must
|
|
18
|
+
* offer the same knobs — and an interface that merely *listed* the same
|
|
19
|
+
* eleven properties would drift the first time one of them gained a twelfth.
|
|
20
|
+
* Extending is what makes "the same options" checked rather than intended.
|
|
21
|
+
*
|
|
22
|
+
* Every optional property is declared `?: T | undefined`, not `?: T`. Under
|
|
23
|
+
* `exactOptionalPropertyTypes` (which this repo sets, and which consumers are
|
|
24
|
+
* likely to) the shorter form rejects `{ kid: maybeKid }` where `maybeKid` is
|
|
25
|
+
* `string | undefined` — forcing every consumer into conditional spreads for
|
|
26
|
+
* options that are, semantically, simply optional. `src/types.test.ts` pins
|
|
27
|
+
* this at the type level.
|
|
28
|
+
*/
|
|
29
|
+
export interface VpayClientOptions extends MerchantAuthOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Default 30000. Applies to **both** the token exchange and every resource
|
|
32
|
+
* call — the one place this client's reading of an inherited option is
|
|
33
|
+
* narrower than the base's, because `VpayClient` owns its resource
|
|
34
|
+
* transport and `createStripeAuthenticator` does not.
|
|
35
|
+
*/
|
|
36
|
+
timeoutMs?: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
declare const INSPECT_CUSTOM: unique symbol;
|
|
39
|
+
/**
|
|
40
|
+
* The vpay merchant SDK client. Mints `private_key_jwt` assertions, exchanges
|
|
41
|
+
* and caches `/v1` access tokens, and exposes the resource namespaces below.
|
|
42
|
+
*
|
|
43
|
+
* The private key never appears in `util.inspect(client)` or
|
|
44
|
+
* `JSON.stringify(client)` output — this class never holds it. It is parsed
|
|
45
|
+
* once by `resolveMerchantAuth` and kept by the {@link TokenManager} as a
|
|
46
|
+
* `crypto.KeyObject`, whose own inspection reveals no key material; the two
|
|
47
|
+
* redacted representations below are built by hand from the two fields that
|
|
48
|
+
* are safe to print.
|
|
49
|
+
*/
|
|
50
|
+
export declare class VpayClient {
|
|
51
|
+
#private;
|
|
52
|
+
readonly paymentIntents: PaymentIntentsResource;
|
|
53
|
+
readonly checkout: CheckoutResource;
|
|
54
|
+
/** `/v1/customers` — the merchant-owned record of a payer (S4a). */
|
|
55
|
+
readonly customers: CustomersResource;
|
|
56
|
+
/** `/v1/invoices` — a merchant's bill to one customer (S4b). */
|
|
57
|
+
readonly invoices: InvoicesResource;
|
|
58
|
+
/** `/v1/invoice_items` — the lines of a draft invoice (S4b). */
|
|
59
|
+
readonly invoiceItems: InvoiceItemsResource;
|
|
60
|
+
readonly refunds: RefundsResource;
|
|
61
|
+
readonly events: EventsResource;
|
|
62
|
+
readonly balance: BalanceResource;
|
|
63
|
+
/** `/v1/account_holders` — issue #47. */
|
|
64
|
+
readonly accountHolders: AccountHoldersResource;
|
|
65
|
+
constructor(options: VpayClientOptions);
|
|
66
|
+
/** A safe, redacted representation — never the private key. */
|
|
67
|
+
[INSPECT_CUSTOM](): string;
|
|
68
|
+
/** A safe, redacted representation — never the private key. */
|
|
69
|
+
toJSON(): {
|
|
70
|
+
object: "vpay_client";
|
|
71
|
+
clientId: string;
|
|
72
|
+
baseUrl: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import { resolveMerchantAuth, } from "./auth.js";
|
|
3
|
+
import { HttpClient } from "./http.js";
|
|
4
|
+
import { AccountHoldersResource } from "./resources/account-holders.js";
|
|
5
|
+
import { BalanceResource } from "./resources/balance.js";
|
|
6
|
+
import { CheckoutResource } from "./resources/checkout-sessions.js";
|
|
7
|
+
import { CustomersResource } from "./resources/customers.js";
|
|
8
|
+
import { EventsResource } from "./resources/events.js";
|
|
9
|
+
import { InvoiceItemsResource, InvoicesResource, } from "./resources/invoices.js";
|
|
10
|
+
import { PaymentIntentsResource } from "./resources/payment-intents.js";
|
|
11
|
+
import { RefundsResource } from "./resources/refunds.js";
|
|
12
|
+
const INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
13
|
+
/**
|
|
14
|
+
* The vpay merchant SDK client. Mints `private_key_jwt` assertions, exchanges
|
|
15
|
+
* and caches `/v1` access tokens, and exposes the resource namespaces below.
|
|
16
|
+
*
|
|
17
|
+
* The private key never appears in `util.inspect(client)` or
|
|
18
|
+
* `JSON.stringify(client)` output — this class never holds it. It is parsed
|
|
19
|
+
* once by `resolveMerchantAuth` and kept by the {@link TokenManager} as a
|
|
20
|
+
* `crypto.KeyObject`, whose own inspection reveals no key material; the two
|
|
21
|
+
* redacted representations below are built by hand from the two fields that
|
|
22
|
+
* are safe to print.
|
|
23
|
+
*/
|
|
24
|
+
export class VpayClient {
|
|
25
|
+
#clientId;
|
|
26
|
+
#baseUrl;
|
|
27
|
+
#tokenManager;
|
|
28
|
+
paymentIntents;
|
|
29
|
+
checkout;
|
|
30
|
+
/** `/v1/customers` — the merchant-owned record of a payer (S4a). */
|
|
31
|
+
customers;
|
|
32
|
+
/** `/v1/invoices` — a merchant's bill to one customer (S4b). */
|
|
33
|
+
invoices;
|
|
34
|
+
/** `/v1/invoice_items` — the lines of a draft invoice (S4b). */
|
|
35
|
+
invoiceItems;
|
|
36
|
+
refunds;
|
|
37
|
+
events;
|
|
38
|
+
balance;
|
|
39
|
+
/** `/v1/account_holders` — issue #47. */
|
|
40
|
+
accountHolders;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
// Validation, defaulting and the `TokenManager` itself live in
|
|
43
|
+
// `resolveMerchantAuth`, shared with `@vaam-apps/vpay-sdk/stripe`'s authenticator:
|
|
44
|
+
// both entry points must mint the same assertion against the same
|
|
45
|
+
// endpoint with the same defaults, and one copy is how that stays true.
|
|
46
|
+
const auth = resolveMerchantAuth(options);
|
|
47
|
+
this.#baseUrl = auth.baseUrl;
|
|
48
|
+
this.#clientId = auth.clientId;
|
|
49
|
+
this.#tokenManager = auth.tokenManager;
|
|
50
|
+
const httpClient = new HttpClient({
|
|
51
|
+
baseUrl: `${this.#baseUrl}/v1`,
|
|
52
|
+
tokenManager: this.#tokenManager,
|
|
53
|
+
fetchImpl: auth.fetchImpl,
|
|
54
|
+
timeoutMs: auth.timeoutMs,
|
|
55
|
+
userAgent: auth.userAgent,
|
|
56
|
+
});
|
|
57
|
+
this.paymentIntents = new PaymentIntentsResource(httpClient);
|
|
58
|
+
this.checkout = new CheckoutResource(httpClient);
|
|
59
|
+
this.customers = new CustomersResource(httpClient);
|
|
60
|
+
this.invoices = new InvoicesResource(httpClient);
|
|
61
|
+
this.invoiceItems = new InvoiceItemsResource(httpClient);
|
|
62
|
+
this.refunds = new RefundsResource(httpClient);
|
|
63
|
+
this.events = new EventsResource(httpClient);
|
|
64
|
+
this.balance = new BalanceResource(httpClient);
|
|
65
|
+
this.accountHolders = new AccountHoldersResource(httpClient);
|
|
66
|
+
}
|
|
67
|
+
/** A safe, redacted representation — never the private key. */
|
|
68
|
+
[INSPECT_CUSTOM]() {
|
|
69
|
+
return `VpayClient ${inspect({ clientId: this.#clientId, baseUrl: this.#baseUrl }, { colors: false })}`;
|
|
70
|
+
}
|
|
71
|
+
/** A safe, redacted representation — never the private key. */
|
|
72
|
+
toJSON() {
|
|
73
|
+
return {
|
|
74
|
+
object: "vpay_client",
|
|
75
|
+
clientId: this.#clientId,
|
|
76
|
+
baseUrl: this.#baseUrl,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|