@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +732 -0
  3. package/dist/auth.d.ts +180 -0
  4. package/dist/auth.js +297 -0
  5. package/dist/client.d.ts +75 -0
  6. package/dist/client.js +79 -0
  7. package/dist/errors.d.ts +91 -0
  8. package/dist/errors.js +112 -0
  9. package/dist/form.d.ts +47 -0
  10. package/dist/form.js +127 -0
  11. package/dist/http.d.ts +33 -0
  12. package/dist/http.js +115 -0
  13. package/dist/index.d.ts +15 -0
  14. package/dist/index.js +14 -0
  15. package/dist/resources/account-holders.d.ts +36 -0
  16. package/dist/resources/account-holders.js +46 -0
  17. package/dist/resources/balance.d.ts +8 -0
  18. package/dist/resources/balance.js +10 -0
  19. package/dist/resources/checkout-sessions.d.ts +55 -0
  20. package/dist/resources/checkout-sessions.js +127 -0
  21. package/dist/resources/customers.d.ts +97 -0
  22. package/dist/resources/customers.js +157 -0
  23. package/dist/resources/events.d.ts +8 -0
  24. package/dist/resources/events.js +10 -0
  25. package/dist/resources/invoices.d.ts +193 -0
  26. package/dist/resources/invoices.js +275 -0
  27. package/dist/resources/payment-intents.d.ts +12 -0
  28. package/dist/resources/payment-intents.js +44 -0
  29. package/dist/resources/refunds.d.ts +92 -0
  30. package/dist/resources/refunds.js +106 -0
  31. package/dist/stripe-auth.d.ts +177 -0
  32. package/dist/stripe-auth.js +192 -0
  33. package/dist/types.d.ts +1268 -0
  34. package/dist/types.js +48 -0
  35. package/dist/validate.d.ts +16 -0
  36. package/dist/validate.js +20 -0
  37. package/dist/version.d.ts +12 -0
  38. package/dist/version.js +12 -0
  39. package/dist/webhooks.d.ts +22 -0
  40. package/dist/webhooks.js +94 -0
  41. package/package.json +63 -0
  42. package/scripts/mint-assertion.mjs +73 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * `@vaam-apps/vpay-sdk/stripe` — a `config.authenticator` for the **official Stripe
3
+ * Node SDK**, so `stripe-node` can talk to vpay's `/v1` API.
4
+ *
5
+ * vpay never accepts an API key ([ADR-0010](../../../docs/adr/0010-merchant-auth-private-key-jwt.md)):
6
+ * every `/v1` call carries a short-lived bearer token minted from a
7
+ * `private_key_jwt` client assertion (docs/flows/merchant-auth.md). stripe-node
8
+ * has no notion of that handshake, but it does accept an arbitrary async
9
+ * `authenticator` invoked once per request attempt with the whole outbound
10
+ * request — which is exactly the seam this module fills:
11
+ *
12
+ * ```js
13
+ * import Stripe from "stripe";
14
+ * import { createStripeAuthenticator } from "@vaam-apps/vpay-sdk/stripe";
15
+ *
16
+ * const stripe = new Stripe("", {
17
+ * authenticator: createStripeAuthenticator({
18
+ * baseUrl: "http://localhost:8080",
19
+ * clientId: "acme-cameroon",
20
+ * privateKey: readFileSync("./merchant-key.pem", "utf8"),
21
+ * }),
22
+ * host: "localhost", port: "8080", protocol: "http",
23
+ * });
24
+ * ```
25
+ *
26
+ * The `host`/`port`/`protocol` in that snippet are **not optional garnish** —
27
+ * see {@link createStripeAuthenticator}'s "Where the token may be sent".
28
+ *
29
+ * This module deliberately imports **nothing** from `stripe`. `stripe` is an
30
+ * optional peer dependency, and the request type below is written out
31
+ * structurally rather than imported, so `@vaam-apps/vpay-sdk` type-checks and builds
32
+ * with `stripe` absent. `src/stripe-auth.test.ts` pins the structural type
33
+ * against the real `Stripe.StripeConfig["authenticator"]`, so a divergence in
34
+ * a future `stripe` release fails the build rather than the merchant's
35
+ * deployment.
36
+ *
37
+ * See the README's "Using the official Stripe SDK" section for what does and
38
+ * does not carry over from real Stripe.
39
+ */
40
+ import { type MerchantAuthOptions } from "./auth.js";
41
+ /**
42
+ * Configuration for {@link createStripeAuthenticator}.
43
+ *
44
+ * **The same options `VpayClient` takes** ({@link MerchantAuthOptions}, which
45
+ * `VpayClientOptions` also extends): both entry points run the same handshake
46
+ * through the same {@link resolveMerchantAuth}, so a knob one of them offers
47
+ * and the other does not would be a bug rather than a feature. Only the
48
+ * *meaning* of `timeoutMs` narrows, and it is redeclared below to say so —
49
+ * `stripe-node` owns the resource requests, their timeout and their retries,
50
+ * so this module configures the **token exchange** alone.
51
+ *
52
+ * Every optional property is declared `?: T | undefined` rather than `?: T`
53
+ * so a consumer compiling with `exactOptionalPropertyTypes` can pass a value
54
+ * read from configuration (`string | undefined`) without a conditional
55
+ * spread — the same rule the core entry point follows.
56
+ */
57
+ export interface StripeAuthenticatorOptions extends MerchantAuthOptions {
58
+ /**
59
+ * Default 30000. Applies to the **token exchange only** — stripe-node's own
60
+ * `timeout` and `httpClient` govern every resource call.
61
+ */
62
+ timeoutMs?: number | undefined;
63
+ }
64
+ /**
65
+ * `stripe`'s `StripeRequest` (`Types.d.ts`: `{host, port, path, method,
66
+ * headers, body, protocol}`), written out structurally so this module needs
67
+ * no `stripe` import.
68
+ */
69
+ export interface StripeRequestShape {
70
+ host: string;
71
+ port: string;
72
+ path: string;
73
+ method: string;
74
+ headers: Record<string, string | number | string[]>;
75
+ body: string;
76
+ protocol: string;
77
+ }
78
+ /**
79
+ * What an authenticator is actually called with.
80
+ *
81
+ * Wider than {@link StripeRequestShape} on purpose, in the one direction that
82
+ * is safe: `headers` is required (it is the only field this module writes),
83
+ * everything else is optional. Two reasons, and neither is convenience:
84
+ *
85
+ * - the README's startup probe is `await authenticator({ headers: {} })` —
86
+ * mint a token at boot so a wrong key fails deployment rather than the
87
+ * first payment — and that call has no host, path or body to give;
88
+ * - a parameter type *wider* than `StripeRequest` keeps the returned function
89
+ * assignable to `Stripe.StripeConfig["authenticator"]` (parameters are
90
+ * contravariant), which `stripe-auth.test.ts` pins.
91
+ *
92
+ * A narrower parameter would have made the probe a type error and the
93
+ * assignment unsound.
94
+ */
95
+ export type StripeAuthenticatorRequest = Pick<StripeRequestShape, "headers"> & Partial<Omit<StripeRequestShape, "headers">>;
96
+ /**
97
+ * What {@link createStripeAuthenticator} returns: a function assignable to
98
+ * stripe-node's `StripeConfig["authenticator"]`, carrying one extra method.
99
+ */
100
+ export interface VpayStripeAuthenticator {
101
+ (request: StripeAuthenticatorRequest): Promise<void>;
102
+ /**
103
+ * Discards the cached access token so the next request mints a fresh one.
104
+ *
105
+ * Needed because the authenticator **cannot see the response**: it runs
106
+ * before the request and its promise settles before any status code
107
+ * exists, so a `401` cannot trigger a re-auth from in here the way
108
+ * `VpayClient` does it. Normal expiry is handled by the cache's own safety
109
+ * margin; this is the escape hatch for the rest:
110
+ *
111
+ * ```js
112
+ * try { await stripe.paymentIntents.retrieve(id); }
113
+ * catch (err) {
114
+ * if (err instanceof Stripe.errors.StripeAuthenticationError) {
115
+ * authenticator.invalidate();
116
+ * }
117
+ * throw err;
118
+ * }
119
+ * ```
120
+ */
121
+ invalidate(): void;
122
+ }
123
+ /**
124
+ * Builds a stripe-node `authenticator` that performs vpay's
125
+ * `client_credentials` + `private_key_jwt` handshake and puts the resulting
126
+ * bearer token on the outbound request.
127
+ *
128
+ * Token caching, single-flight refresh under concurrency, and the
129
+ * `expires_in` safety margin are the *same* `TokenManager` `VpayClient` uses
130
+ * — not a second implementation of the handshake.
131
+ *
132
+ * # Where the token may be sent
133
+ *
134
+ * **The authenticator is bound to `baseUrl`'s origin and refuses every other
135
+ * one.** stripe-node calls it with the whole outbound request, for *any*
136
+ * request the client makes; `host`/`port`/`protocol` are configured on the
137
+ * `Stripe` instance, separately from this module, and when they are omitted
138
+ * stripe-node addresses `api.stripe.com:443`. An authenticator that wrote
139
+ * `Authorization` unconditionally would therefore hand a merchant's vpay
140
+ * bearer token to Stripe the moment someone forgot one line of config — a
141
+ * live credential for someone else's API, sent over TLS to a party that has
142
+ * no reason to want it and every reason to log it.
143
+ *
144
+ * So a request whose host, effective port or protocol is not `baseUrl`'s is
145
+ * a {@link VpayConfigError} naming both origins, thrown **before** a token is
146
+ * minted (a refused request costs no assertion and spends no `jti`). A
147
+ * request with no `host` at all — the README's startup probe — is allowed.
148
+ *
149
+ * # What it writes
150
+ *
151
+ * The returned function writes exactly one thing: `headers.Authorization`.
152
+ * Mutating anything else is possible (stripe-node re-reads
153
+ * `host`/`port`/`path`/`method`/`headers`/`body`/`protocol` after the
154
+ * authenticator resolves) but must not be done: `Content-Length` is computed
155
+ * from the body *before* this runs, so rewriting the body desynchronises the
156
+ * two and the request is truncated or hangs.
157
+ *
158
+ * A failed token exchange rejects, and that rejection is the caller's to
159
+ * handle when the authenticator is called directly.
160
+ *
161
+ * **Through stripe-node it is not.** Measured against `stripe@22.6.1`: it
162
+ * builds the right error (`StripeError: Unable to authenticate the request`,
163
+ * with the `VpayAuthError` at `err.raw.exception`) but throws it inside a
164
+ * detached promise chain that never calls its own callback — so the error
165
+ * arrives as a process-level `unhandledRejection` and the promise the
166
+ * merchant awaited never settles. See the README's "A stripe-node defect you
167
+ * will hit if your key is wrong", and the test that pins it. It is also why
168
+ * both of this function's construction-time checks are construction-time:
169
+ * a `VpayConfigError` a merchant sees when they build the client is worth
170
+ * more than the same fact delivered as an unhandled rejection at 3am.
171
+ *
172
+ * @throws `VpayConfigError` at construction if `baseUrl`, `clientId` or
173
+ * `privateKey` is missing, if `baseUrl` is not an absolute URL, if
174
+ * `privateKey` is not a readable private key, or if
175
+ * `assertionLifetimeSeconds` is out of range.
176
+ */
177
+ export declare function createStripeAuthenticator(options: StripeAuthenticatorOptions): VpayStripeAuthenticator;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `@vaam-apps/vpay-sdk/stripe` — a `config.authenticator` for the **official Stripe
3
+ * Node SDK**, so `stripe-node` can talk to vpay's `/v1` API.
4
+ *
5
+ * vpay never accepts an API key ([ADR-0010](../../../docs/adr/0010-merchant-auth-private-key-jwt.md)):
6
+ * every `/v1` call carries a short-lived bearer token minted from a
7
+ * `private_key_jwt` client assertion (docs/flows/merchant-auth.md). stripe-node
8
+ * has no notion of that handshake, but it does accept an arbitrary async
9
+ * `authenticator` invoked once per request attempt with the whole outbound
10
+ * request — which is exactly the seam this module fills:
11
+ *
12
+ * ```js
13
+ * import Stripe from "stripe";
14
+ * import { createStripeAuthenticator } from "@vaam-apps/vpay-sdk/stripe";
15
+ *
16
+ * const stripe = new Stripe("", {
17
+ * authenticator: createStripeAuthenticator({
18
+ * baseUrl: "http://localhost:8080",
19
+ * clientId: "acme-cameroon",
20
+ * privateKey: readFileSync("./merchant-key.pem", "utf8"),
21
+ * }),
22
+ * host: "localhost", port: "8080", protocol: "http",
23
+ * });
24
+ * ```
25
+ *
26
+ * The `host`/`port`/`protocol` in that snippet are **not optional garnish** —
27
+ * see {@link createStripeAuthenticator}'s "Where the token may be sent".
28
+ *
29
+ * This module deliberately imports **nothing** from `stripe`. `stripe` is an
30
+ * optional peer dependency, and the request type below is written out
31
+ * structurally rather than imported, so `@vaam-apps/vpay-sdk` type-checks and builds
32
+ * with `stripe` absent. `src/stripe-auth.test.ts` pins the structural type
33
+ * against the real `Stripe.StripeConfig["authenticator"]`, so a divergence in
34
+ * a future `stripe` release fails the build rather than the merchant's
35
+ * deployment.
36
+ *
37
+ * See the README's "Using the official Stripe SDK" section for what does and
38
+ * does not carry over from real Stripe.
39
+ */
40
+ import { resolveMerchantAuth } from "./auth.js";
41
+ import { VpayConfigError } from "./errors.js";
42
+ function defaultPortFor(protocol) {
43
+ return protocol === "http" ? "80" : "443";
44
+ }
45
+ /**
46
+ * `api.vpay.example`, `API.Vpay.Example.` and `[::1]` all have to compare
47
+ * equal to the same configured host, because a merchant writes `baseUrl` and
48
+ * stripe-node's `host` by hand and nothing makes them agree on case or on
49
+ * bracket notation. Everything else about the value is left alone: this is a
50
+ * comparison, not a validator.
51
+ */
52
+ function normaliseHost(host) {
53
+ const lowered = host.trim().toLowerCase();
54
+ const unbracketed = lowered.startsWith("[") && lowered.endsWith("]")
55
+ ? lowered.slice(1, -1)
56
+ : lowered;
57
+ return unbracketed.endsWith(".") ? unbracketed.slice(0, -1) : unbracketed;
58
+ }
59
+ function normaliseProtocol(protocol) {
60
+ const lowered = protocol.trim().toLowerCase();
61
+ return lowered.endsWith(":") ? lowered.slice(0, -1) : lowered;
62
+ }
63
+ function parseBoundOrigin(baseUrl) {
64
+ let url;
65
+ try {
66
+ url = new URL(baseUrl);
67
+ }
68
+ catch (err) {
69
+ throw new VpayConfigError(`baseUrl is not an absolute URL: ${baseUrl}`, {
70
+ cause: err,
71
+ });
72
+ }
73
+ const protocol = normaliseProtocol(url.protocol);
74
+ return {
75
+ host: normaliseHost(url.hostname),
76
+ port: url.port === "" ? defaultPortFor(protocol) : url.port,
77
+ protocol,
78
+ };
79
+ }
80
+ function describeOrigin(origin) {
81
+ return `${origin.protocol}://${origin.host}:${origin.port}`;
82
+ }
83
+ /**
84
+ * Refuses to mint a token for a request bound somewhere other than
85
+ * {@link BoundOrigin}.
86
+ *
87
+ * See {@link createStripeAuthenticator}'s "Where the token may be sent" for
88
+ * why this is a refusal and not a warning.
89
+ *
90
+ * A request with **no `host`** is allowed: that is the README's startup probe
91
+ * (`authenticator({ headers: {} })`), which has no destination to check
92
+ * against and never reaches a socket. Every field stripe-node does supply is
93
+ * checked; a `protocol` it somehow omitted is read as the configured one
94
+ * rather than as a mismatch, because a missing field is not evidence of a
95
+ * different destination and this check exists to catch a *different* one.
96
+ */
97
+ function assertRequestIsBound(request, bound) {
98
+ if (request.host === undefined) {
99
+ return;
100
+ }
101
+ const protocol = request.protocol === undefined
102
+ ? bound.protocol
103
+ : normaliseProtocol(request.protocol);
104
+ const host = normaliseHost(request.host);
105
+ const port = request.port === undefined || request.port === ""
106
+ ? defaultPortFor(protocol)
107
+ : String(request.port);
108
+ if (host === bound.host &&
109
+ port === bound.port &&
110
+ protocol === bound.protocol) {
111
+ return;
112
+ }
113
+ throw new VpayConfigError(`refusing to authenticate a request to ${describeOrigin({ host, port, protocol })}: ` +
114
+ `this authenticator mints tokens for ${describeOrigin(bound)} (its baseUrl) and will not ` +
115
+ `send one anywhere else. Set stripe-node's host, port and protocol to match baseUrl — ` +
116
+ `with them omitted stripe-node addresses api.stripe.com:443, and your vpay access token ` +
117
+ `would be sent to Stripe.`);
118
+ }
119
+ /**
120
+ * Builds a stripe-node `authenticator` that performs vpay's
121
+ * `client_credentials` + `private_key_jwt` handshake and puts the resulting
122
+ * bearer token on the outbound request.
123
+ *
124
+ * Token caching, single-flight refresh under concurrency, and the
125
+ * `expires_in` safety margin are the *same* `TokenManager` `VpayClient` uses
126
+ * — not a second implementation of the handshake.
127
+ *
128
+ * # Where the token may be sent
129
+ *
130
+ * **The authenticator is bound to `baseUrl`'s origin and refuses every other
131
+ * one.** stripe-node calls it with the whole outbound request, for *any*
132
+ * request the client makes; `host`/`port`/`protocol` are configured on the
133
+ * `Stripe` instance, separately from this module, and when they are omitted
134
+ * stripe-node addresses `api.stripe.com:443`. An authenticator that wrote
135
+ * `Authorization` unconditionally would therefore hand a merchant's vpay
136
+ * bearer token to Stripe the moment someone forgot one line of config — a
137
+ * live credential for someone else's API, sent over TLS to a party that has
138
+ * no reason to want it and every reason to log it.
139
+ *
140
+ * So a request whose host, effective port or protocol is not `baseUrl`'s is
141
+ * a {@link VpayConfigError} naming both origins, thrown **before** a token is
142
+ * minted (a refused request costs no assertion and spends no `jti`). A
143
+ * request with no `host` at all — the README's startup probe — is allowed.
144
+ *
145
+ * # What it writes
146
+ *
147
+ * The returned function writes exactly one thing: `headers.Authorization`.
148
+ * Mutating anything else is possible (stripe-node re-reads
149
+ * `host`/`port`/`path`/`method`/`headers`/`body`/`protocol` after the
150
+ * authenticator resolves) but must not be done: `Content-Length` is computed
151
+ * from the body *before* this runs, so rewriting the body desynchronises the
152
+ * two and the request is truncated or hangs.
153
+ *
154
+ * A failed token exchange rejects, and that rejection is the caller's to
155
+ * handle when the authenticator is called directly.
156
+ *
157
+ * **Through stripe-node it is not.** Measured against `stripe@22.6.1`: it
158
+ * builds the right error (`StripeError: Unable to authenticate the request`,
159
+ * with the `VpayAuthError` at `err.raw.exception`) but throws it inside a
160
+ * detached promise chain that never calls its own callback — so the error
161
+ * arrives as a process-level `unhandledRejection` and the promise the
162
+ * merchant awaited never settles. See the README's "A stripe-node defect you
163
+ * will hit if your key is wrong", and the test that pins it. It is also why
164
+ * both of this function's construction-time checks are construction-time:
165
+ * a `VpayConfigError` a merchant sees when they build the client is worth
166
+ * more than the same fact delivered as an unhandled rejection at 3am.
167
+ *
168
+ * @throws `VpayConfigError` at construction if `baseUrl`, `clientId` or
169
+ * `privateKey` is missing, if `baseUrl` is not an absolute URL, if
170
+ * `privateKey` is not a readable private key, or if
171
+ * `assertionLifetimeSeconds` is out of range.
172
+ */
173
+ export function createStripeAuthenticator(options) {
174
+ // Validation, defaulting, the one-time private-key parse and the
175
+ // `TokenManager` itself all live in `resolveMerchantAuth`, shared with
176
+ // `VpayClient`: both entry points must mint the same assertion against the
177
+ // same endpoint with the same defaults, and one copy is how that stays
178
+ // true.
179
+ const { baseUrl, tokenManager } = resolveMerchantAuth(options);
180
+ // Parsed once, here, rather than per request: the answer cannot change
181
+ // between calls, and a `baseUrl` that is not a URL is a configuration
182
+ // mistake the merchant should be told about at construction.
183
+ const bound = parseBoundOrigin(baseUrl);
184
+ const authenticator = Object.assign(async (request) => {
185
+ assertRequestIsBound(request, bound);
186
+ const token = await tokenManager.getToken();
187
+ request.headers["Authorization"] = `Bearer ${token}`;
188
+ }, {
189
+ invalidate: () => tokenManager.invalidate(),
190
+ });
191
+ return authenticator;
192
+ }