@wtfalch/payments 0.1.0 → 0.3.0
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 +109 -16
- package/dist/db.d.ts +24 -0
- package/dist/db.js +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +88 -0
- package/dist/migrations/0001_payments.sql +75 -0
- package/dist/provider.d.ts +5 -0
- package/dist/store.d.ts +181 -0
- package/dist/store.js +534 -0
- package/dist/stripe.js +32 -1
- package/dist/types.d.ts +38 -4
- package/dist/vipps.d.ts +28 -5
- package/dist/vipps.js +174 -19
- package/package.json +13 -13
package/dist/vipps.d.ts
CHANGED
|
@@ -6,14 +6,37 @@ export interface VippsProviderOptions {
|
|
|
6
6
|
readonly subscriptionKey: string;
|
|
7
7
|
/** `Merchant-Serial-Number`: the sales unit's MSN. */
|
|
8
8
|
readonly merchantSerialNumber: string;
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
* never
|
|
12
|
-
*
|
|
13
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A valid OAuth access token, already fetched. Given this, the adapter
|
|
11
|
+
* never calls `POST /accesstoken/get` itself and never refreshes it --
|
|
12
|
+
* the caller must, on its own schedule. This is v1's shape (0.2.x and
|
|
13
|
+
* earlier), kept so an existing caller migrates by adding
|
|
14
|
+
* `clientId`/`clientSecret` on its own timeline rather than in lockstep
|
|
15
|
+
* with a version bump.
|
|
16
|
+
*
|
|
17
|
+
* Exactly one of `accessToken` or `clientId`+`clientSecret` must be given.
|
|
18
|
+
*/
|
|
19
|
+
readonly accessToken?: string;
|
|
20
|
+
/** The sales unit's OAuth client id, for this adapter to fetch and cache
|
|
21
|
+
* its own access token. Requires `clientSecret`. See
|
|
22
|
+
* docs/adr/0007-vipps-access-token-caching.md. */
|
|
23
|
+
readonly clientId?: string;
|
|
24
|
+
/** The sales unit's OAuth client secret. Requires `clientId`. */
|
|
25
|
+
readonly clientSecret?: string;
|
|
26
|
+
/** Seconds of safety margin before a cached token's real expiry at which
|
|
27
|
+
* this adapter fetches a new one instead of reusing it, so a request that
|
|
28
|
+
* starts just under the deadline does not race the token's own expiry.
|
|
29
|
+
* Default: 60. */
|
|
30
|
+
readonly tokenRefreshMarginSeconds?: number;
|
|
14
31
|
readonly fetch?: FetchLike;
|
|
15
32
|
/** Override for testing. Defaults to `https://api.vipps.no`. */
|
|
16
33
|
readonly baseUrl?: string;
|
|
34
|
+
/** Override for testing. Defaults to `https://api.vipps.no/accesstoken/get`. */
|
|
35
|
+
readonly tokenUrl?: string;
|
|
36
|
+
/** Aborts any request (including the token fetch) still pending after
|
|
37
|
+
* this many milliseconds, via `AbortSignal.timeout`. Default: 10 000
|
|
38
|
+
* (10s). A provider that never responds must not hang its caller forever. */
|
|
39
|
+
readonly timeoutMs?: number;
|
|
17
40
|
}
|
|
18
41
|
export declare function createVippsProvider(options: VippsProviderOptions): PaymentProvider;
|
|
19
42
|
export interface VippsWebhookHeaders {
|
package/dist/vipps.js
CHANGED
|
@@ -5,22 +5,41 @@ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
|
|
|
5
5
|
* REST APIs -- no Vipps SDK (there is no single official Node one). See
|
|
6
6
|
* docs/adr/0003-plain-http-no-provider-sdks.md.
|
|
7
7
|
*
|
|
8
|
-
* Sources (fetched 2026-09-23, primary docs, no live calls):
|
|
8
|
+
* Sources (fetched 2026-09-23/24, primary docs, no live calls):
|
|
9
9
|
* - https://developer.vippsmobilepay.com/api/epayment/ (create, capture, cancel, refund)
|
|
10
|
-
* - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges)
|
|
10
|
+
* - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges, status enum)
|
|
11
11
|
* - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/
|
|
12
|
+
* - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/ (event catalogue)
|
|
13
|
+
* - https://developer.vippsmobilepay.com/docs/knowledge-base/across-borders/ (Nordic markets)
|
|
14
|
+
* - https://developer.vippsmobilepay.com/docs/APIs/recurring-api/recurring-api-guide/
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* One Recurring/ePayment API serves all three Nordic markets (see
|
|
17
|
+
* docs/adr/0006-payment-store.md's "MobilePay" section and this package's
|
|
18
|
+
* README, "MobilePay (Denmark, Finland)"): same base URL, same headers, same
|
|
19
|
+
* request/response shapes. What differs is the sales unit's own currency
|
|
20
|
+
* (NOK/DKK/EUR) and per-market amount ceiling, both the caller's concern
|
|
21
|
+
* (`Money.currency`, `CreateRecurringAgreementInput.amount`), never this
|
|
22
|
+
* adapter's -- `createVippsProvider` needs no market-specific option.
|
|
23
|
+
*
|
|
24
|
+
* `docs/adr/0007-vipps-access-token-caching.md` covers this adapter now
|
|
25
|
+
* fetching and caching its own OAuth access token (`POST /accesstoken/get`),
|
|
26
|
+
* superseding v1's "the caller resolves it" stance (see that ADR for why
|
|
27
|
+
* this does not force the service shape -- docs/adr/0001).
|
|
28
|
+
*
|
|
29
|
+
* Still not confirmed from primary docs: the exact `state` enum on an
|
|
30
|
+
* ePayment payment/capture/refund response (`derivePaymentStatus` below
|
|
31
|
+
* falls back to the `aggregate.*Amount` fields whenever `state` is absent
|
|
32
|
+
* or unrecognised), and the exact field carrying a Recurring webhook
|
|
33
|
+
* event's own id (`normalizeRecurringEvent` below synthesizes one the same
|
|
34
|
+
* way the ePayment side already did, for the same reason).
|
|
22
35
|
*/
|
|
23
36
|
import { hmacSha256Base64, safeEqual, sha256Base64 } from './webhook-crypto.js';
|
|
37
|
+
const AGREEMENT_STATUS = {
|
|
38
|
+
PENDING: 'pending',
|
|
39
|
+
ACTIVE: 'active',
|
|
40
|
+
STOPPED: 'stopped',
|
|
41
|
+
EXPIRED: 'expired',
|
|
42
|
+
};
|
|
24
43
|
const PAYMENT_STATE = {
|
|
25
44
|
CREATED: 'pending',
|
|
26
45
|
AUTHORIZED: 'authorized',
|
|
@@ -80,14 +99,80 @@ function tomorrowIsoDate() {
|
|
|
80
99
|
return d.toISOString().slice(0, 10);
|
|
81
100
|
}
|
|
82
101
|
export function createVippsProvider(options) {
|
|
102
|
+
if (!options.accessToken && !(options.clientId && options.clientSecret)) {
|
|
103
|
+
throw new PaymentProviderError('vipps', 'createVippsProvider needs either accessToken, or both clientId and clientSecret');
|
|
104
|
+
}
|
|
83
105
|
const fetchImpl = options.fetch ?? defaultFetch();
|
|
84
106
|
const baseUrl = options.baseUrl ?? 'https://api.vipps.no';
|
|
85
|
-
|
|
107
|
+
const tokenUrl = options.tokenUrl ?? 'https://api.vipps.no/accesstoken/get';
|
|
108
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
109
|
+
const refreshMarginMs = (options.tokenRefreshMarginSeconds ?? 60) * 1000;
|
|
110
|
+
// Token cache, scoped to this provider instance (one per `subscriptionKey`
|
|
111
|
+
// + `merchantSerialNumber` pair, ordinarily). `inFlight` is the one thing
|
|
112
|
+
// that makes this concurrency-safe: N callers racing `resolveAccessToken`
|
|
113
|
+
// while the cache is empty or expired share the same `POST
|
|
114
|
+
// /accesstoken/get` promise instead of each firing their own -- assigned
|
|
115
|
+
// before any `await`, so there is no gap between "check the cache" and
|
|
116
|
+
// "start a refresh" for a second caller to land in. Cleared in `finally`,
|
|
117
|
+
// on success and on failure alike: a failed refresh must not wedge every
|
|
118
|
+
// later call behind a promise that has already rejected.
|
|
119
|
+
let cached = null;
|
|
120
|
+
let inFlight = null;
|
|
121
|
+
async function fetchAccessToken() {
|
|
122
|
+
const response = await fetchImpl(tokenUrl, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
client_id: options.clientId,
|
|
126
|
+
client_secret: options.clientSecret,
|
|
127
|
+
'Ocp-Apim-Subscription-Key': options.subscriptionKey,
|
|
128
|
+
'Merchant-Serial-Number': options.merchantSerialNumber,
|
|
129
|
+
},
|
|
130
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
131
|
+
});
|
|
132
|
+
const parsed = await response.json();
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new PaymentProviderError('vipps', `access token request failed: HTTP ${response.status}`, {
|
|
135
|
+
status: response.status,
|
|
136
|
+
raw: parsed,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const body = parsed;
|
|
140
|
+
if (!body.access_token) {
|
|
141
|
+
throw new PaymentProviderError('vipps', 'access token response has no "access_token" field', {
|
|
142
|
+
raw: parsed,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const expiresInSeconds = Number(body.expires_in ?? 0);
|
|
146
|
+
cached = {
|
|
147
|
+
token: body.access_token,
|
|
148
|
+
// A missing/unparsable expires_in caches nothing usable: expiresAtMs
|
|
149
|
+
// stays in the past, so the very next call refreshes again rather
|
|
150
|
+
// than reusing a token whose real lifetime is unknown.
|
|
151
|
+
expiresAtMs: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
|
152
|
+
? Date.now() + expiresInSeconds * 1000
|
|
153
|
+
: 0,
|
|
154
|
+
};
|
|
155
|
+
return cached.token;
|
|
156
|
+
}
|
|
157
|
+
async function resolveAccessToken() {
|
|
158
|
+
if (options.accessToken)
|
|
159
|
+
return options.accessToken; // v1 shape: caller-managed, never cached/refreshed here
|
|
160
|
+
if (cached && cached.expiresAtMs - refreshMarginMs > Date.now())
|
|
161
|
+
return cached.token;
|
|
162
|
+
if (!inFlight) {
|
|
163
|
+
inFlight = fetchAccessToken().finally(() => {
|
|
164
|
+
inFlight = null;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return inFlight;
|
|
168
|
+
}
|
|
169
|
+
async function resolveHeaders(idempotencyKey) {
|
|
170
|
+
const token = await resolveAccessToken();
|
|
86
171
|
const h = {
|
|
87
172
|
'Content-Type': 'application/json',
|
|
88
173
|
'Ocp-Apim-Subscription-Key': options.subscriptionKey,
|
|
89
174
|
'Merchant-Serial-Number': options.merchantSerialNumber,
|
|
90
|
-
Authorization: `Bearer ${
|
|
175
|
+
Authorization: `Bearer ${token}`,
|
|
91
176
|
};
|
|
92
177
|
if (idempotencyKey)
|
|
93
178
|
h['Idempotency-Key'] = idempotencyKey;
|
|
@@ -96,8 +181,9 @@ export function createVippsProvider(options) {
|
|
|
96
181
|
async function post(path, body, idempotencyKey) {
|
|
97
182
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
98
183
|
method: 'POST',
|
|
99
|
-
headers:
|
|
184
|
+
headers: await resolveHeaders(idempotencyKey),
|
|
100
185
|
body: JSON.stringify(body),
|
|
186
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
187
|
});
|
|
102
188
|
const parsed = await response.json();
|
|
103
189
|
if (!response.ok) {
|
|
@@ -111,7 +197,8 @@ export function createVippsProvider(options) {
|
|
|
111
197
|
async function get(path) {
|
|
112
198
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
113
199
|
method: 'GET',
|
|
114
|
-
headers:
|
|
200
|
+
headers: await resolveHeaders(undefined),
|
|
201
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
115
202
|
});
|
|
116
203
|
const parsed = await response.json();
|
|
117
204
|
if (!response.ok) {
|
|
@@ -187,12 +274,21 @@ export function createVippsProvider(options) {
|
|
|
187
274
|
};
|
|
188
275
|
},
|
|
189
276
|
async createRecurringAgreement(input) {
|
|
190
|
-
|
|
191
|
-
|
|
277
|
+
// pricing.type LEGACY (fixed amount) vs VARIABLE (suggestedMaxAmount,
|
|
278
|
+
// no fixed amount): https://developer.vippsmobilepay.com/api/recurring/
|
|
279
|
+
const pricing = input.variablePricing
|
|
280
|
+
? {
|
|
281
|
+
type: 'VARIABLE',
|
|
282
|
+
currency: input.amount.currency.toUpperCase(),
|
|
283
|
+
suggestedMaxAmount: input.variablePricing.suggestedMaxAmount,
|
|
284
|
+
}
|
|
285
|
+
: {
|
|
192
286
|
type: 'LEGACY',
|
|
193
287
|
amount: input.amount.value,
|
|
194
288
|
currency: input.amount.currency.toUpperCase(),
|
|
195
|
-
}
|
|
289
|
+
};
|
|
290
|
+
const agreement = await post('/recurring/v3/agreements', {
|
|
291
|
+
pricing,
|
|
196
292
|
interval: {
|
|
197
293
|
unit: (input.interval?.unit ?? 'month').toUpperCase(),
|
|
198
294
|
count: input.interval?.count ?? 1,
|
|
@@ -212,9 +308,16 @@ export function createVippsProvider(options) {
|
|
|
212
308
|
};
|
|
213
309
|
},
|
|
214
310
|
async chargeRecurringAgreement(agreementReference, input) {
|
|
311
|
+
// No `currency` field here: the charge schema at
|
|
312
|
+
// https://developer.vippsmobilepay.com/api/recurring/ has no such
|
|
313
|
+
// property -- the charge always uses the agreement's own currency, so
|
|
314
|
+
// sending one was a mismatch with the documented request body (fixed
|
|
315
|
+
// here, not merely renamed). For a VARIABLE agreement, Vipps enforces
|
|
316
|
+
// amount <= the payer's accepted max itself: a charge above it is
|
|
317
|
+
// held `DUE`, not rejected synchronously here, so this adapter does
|
|
318
|
+
// not duplicate that check.
|
|
215
319
|
const charge = await post(`/recurring/v3/agreements/${agreementReference}/charges`, {
|
|
216
320
|
amount: input.amount.value,
|
|
217
|
-
currency: input.amount.currency.toUpperCase(),
|
|
218
321
|
transactionType: 'DIRECT_CAPTURE',
|
|
219
322
|
due: input.dueDate ?? tomorrowIsoDate(),
|
|
220
323
|
retryDays: 0,
|
|
@@ -231,6 +334,16 @@ export function createVippsProvider(options) {
|
|
|
231
334
|
raw: charge,
|
|
232
335
|
};
|
|
233
336
|
},
|
|
337
|
+
async getRecurringAgreement(agreementReference) {
|
|
338
|
+
const agreement = await get(`/recurring/v3/agreements/${agreementReference}`);
|
|
339
|
+
return {
|
|
340
|
+
provider: 'vipps',
|
|
341
|
+
agreementReference: agreement.agreementId,
|
|
342
|
+
status: (agreement.status ? AGREEMENT_STATUS[agreement.status] : undefined) ?? 'pending',
|
|
343
|
+
confirmationUrl: agreement.vippsConfirmationUrl,
|
|
344
|
+
raw: agreement,
|
|
345
|
+
};
|
|
346
|
+
},
|
|
234
347
|
};
|
|
235
348
|
}
|
|
236
349
|
const WEBHOOK_EVENT_NAME = {
|
|
@@ -243,6 +356,42 @@ const WEBHOOK_EVENT_NAME = {
|
|
|
243
356
|
EXPIRED: 'payment.expired',
|
|
244
357
|
ABORTED: 'payment.cancelled',
|
|
245
358
|
};
|
|
359
|
+
/** The Recurring API's own webhook event catalogue -- a different namespace
|
|
360
|
+
* from the ePayment `name` field `WEBHOOK_EVENT_NAME` above maps.
|
|
361
|
+
* https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/
|
|
362
|
+
* (fetched 2026-09-24 via a documentation-summarizing tool, the same
|
|
363
|
+
* caveat docs/adr/0004 already carries for the ePayment side -- not
|
|
364
|
+
* checked against one real signed Vipps delivery). */
|
|
365
|
+
const RECURRING_EVENT_TYPE = {
|
|
366
|
+
'recurring.agreement-activated.v1': 'agreement.activated',
|
|
367
|
+
'recurring.agreement-rejected.v1': 'agreement.rejected',
|
|
368
|
+
'recurring.agreement-stopped.v1': 'agreement.stopped',
|
|
369
|
+
'recurring.agreement-expired.v1': 'agreement.expired',
|
|
370
|
+
'recurring.charge-reserved.v1': 'charge.reserved',
|
|
371
|
+
'recurring.charge-captured.v1': 'charge.captured',
|
|
372
|
+
'recurring.charge-canceled.v1': 'charge.canceled',
|
|
373
|
+
'recurring.charge-refunded.v1': 'charge.refunded',
|
|
374
|
+
'recurring.charge-failed.v1': 'charge.failed',
|
|
375
|
+
'recurring.charge-creation-failed.v1': 'charge.failed',
|
|
376
|
+
};
|
|
377
|
+
/** A Recurring event names one agreement always, and a charge only for a
|
|
378
|
+
* `charge.*` type -- `paymentReference` (which `store.ts` matches against
|
|
379
|
+
* `payment_charges.provider_charge_id`) is empty for an `agreement.*` event,
|
|
380
|
+
* which has none. No documented event id field (unlike the ePayment side's
|
|
381
|
+
* `idempotencyKey`), so `eventId` is synthesized from what is documented,
|
|
382
|
+
* the same fallback shape the ePayment path below already uses. */
|
|
383
|
+
function normalizeRecurringEvent(event) {
|
|
384
|
+
const amount = event.amountCaptured ?? event.amountCanceled ?? event.amountRefunded ?? event.amount;
|
|
385
|
+
return {
|
|
386
|
+
provider: 'vipps',
|
|
387
|
+
type: RECURRING_EVENT_TYPE[event.eventType] ?? 'unknown',
|
|
388
|
+
eventId: `${event.agreementId ?? 'unknown'}:${event.chargeId ?? ''}:${event.eventType}:${event.occurred ?? ''}`,
|
|
389
|
+
paymentReference: event.chargeId ?? '',
|
|
390
|
+
agreementReference: event.agreementId,
|
|
391
|
+
amount: amount ? { value: amount.value, currency: amount.currency.toUpperCase() } : undefined,
|
|
392
|
+
raw: event,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
246
395
|
/**
|
|
247
396
|
* Verifies a Vipps MobilePay webhook request, by hand, against the
|
|
248
397
|
* Azure-API-Management-style HMAC scheme documented at
|
|
@@ -298,6 +447,12 @@ export function verifyVippsWebhook(rawBody, requestHeaders, webhookSecret, metho
|
|
|
298
447
|
catch {
|
|
299
448
|
throw new WebhookVerificationError('vipps', 'payload is not valid JSON');
|
|
300
449
|
}
|
|
450
|
+
// Two payload shapes share this endpoint's signature scheme: ePayment's
|
|
451
|
+
// (`name`/`success`, handled below) and the Recurring API's (`eventType`,
|
|
452
|
+
// agreement/charge events -- see normalizeRecurringEvent's doc comment).
|
|
453
|
+
if (event.eventType) {
|
|
454
|
+
return normalizeRecurringEvent(event);
|
|
455
|
+
}
|
|
301
456
|
const mapped = event.name ? WEBHOOK_EVENT_NAME[event.name] : undefined;
|
|
302
457
|
const type = event.success === false ? 'payment.failed' : (mapped ?? 'unknown');
|
|
303
458
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/payments",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One PaymentProvider interface -- create a payment, capture, refund, set up and charge a recurring agreement, verify a webhook -- over Stripe and Vipps MobilePay. Holds no card data.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "One PaymentProvider interface -- create a payment, capture, refund, set up and charge a recurring agreement, verify a webhook -- over Stripe and Vipps MobilePay, plus a provider-neutral Postgres store for agreements and charges. Holds no card data.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/wtfalch/payments",
|
|
@@ -9,16 +9,13 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": [
|
|
13
|
-
"dist",
|
|
14
|
-
"README.md",
|
|
15
|
-
"LICENSE"
|
|
16
|
-
],
|
|
12
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
17
13
|
"exports": {
|
|
18
14
|
".": {
|
|
19
15
|
"types": "./dist/index.d.ts",
|
|
20
16
|
"default": "./dist/index.js"
|
|
21
17
|
},
|
|
18
|
+
"./migrations/*.sql": "./dist/migrations/*.sql",
|
|
22
19
|
"./package.json": "./package.json"
|
|
23
20
|
},
|
|
24
21
|
"sideEffects": false,
|
|
@@ -28,14 +25,17 @@
|
|
|
28
25
|
"engines": {
|
|
29
26
|
"node": ">=22.0.0"
|
|
30
27
|
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"prepack": "pnpm build"
|
|
33
|
+
},
|
|
31
34
|
"devDependencies": {
|
|
35
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
32
36
|
"@types/node": "^22",
|
|
37
|
+
"postgres": "^3.4.5",
|
|
33
38
|
"typescript": "^5.9.0",
|
|
34
39
|
"vitest": "^4.1.6"
|
|
35
|
-
},
|
|
36
|
-
"scripts": {
|
|
37
|
-
"build": "tsc -p tsconfig.build.json",
|
|
38
|
-
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "vitest run"
|
|
40
40
|
}
|
|
41
|
-
}
|
|
41
|
+
}
|