@vivoa/partner-sdk 0.1.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/CHANGELOG.md +13 -0
- package/README.md +162 -0
- package/dist/cjs/adapters/fetch-transport.d.ts +7 -0
- package/dist/cjs/adapters/fetch-transport.js +34 -0
- package/dist/cjs/adapters/in-memory/fake-vivoa.d.ts +93 -0
- package/dist/cjs/adapters/in-memory/fake-vivoa.js +468 -0
- package/dist/cjs/adapters/manual-clock.d.ts +9 -0
- package/dist/cjs/adapters/manual-clock.js +20 -0
- package/dist/cjs/adapters/node-crypto.d.ts +2 -0
- package/dist/cjs/adapters/node-crypto.js +13 -0
- package/dist/cjs/adapters/system-clock.d.ts +2 -0
- package/dist/cjs/adapters/system-clock.js +7 -0
- package/dist/cjs/application/partner-client.d.ts +45 -0
- package/dist/cjs/application/partner-client.js +55 -0
- package/dist/cjs/application/resources/fleet.d.ts +7 -0
- package/dist/cjs/application/resources/fleet.js +13 -0
- package/dist/cjs/application/resources/me.d.ts +13 -0
- package/dist/cjs/application/resources/me.js +23 -0
- package/dist/cjs/application/resources/merchants.d.ts +37 -0
- package/dist/cjs/application/resources/merchants.js +92 -0
- package/dist/cjs/application/resources/webhooks.d.ts +16 -0
- package/dist/cjs/application/resources/webhooks.js +30 -0
- package/dist/cjs/application/signed-http.d.ts +41 -0
- package/dist/cjs/application/signed-http.js +105 -0
- package/dist/cjs/application/use-cases/provision-store.d.ts +50 -0
- package/dist/cjs/application/use-cases/provision-store.js +103 -0
- package/dist/cjs/domain/errors.d.ts +72 -0
- package/dist/cjs/domain/errors.js +111 -0
- package/dist/cjs/domain/merchant.d.ts +74 -0
- package/dist/cjs/domain/merchant.js +28 -0
- package/dist/cjs/domain/operator.d.ts +32 -0
- package/dist/cjs/domain/operator.js +6 -0
- package/dist/cjs/domain/signature.d.ts +21 -0
- package/dist/cjs/domain/signature.js +23 -0
- package/dist/cjs/domain/webhook.d.ts +48 -0
- package/dist/cjs/domain/webhook.js +10 -0
- package/dist/cjs/index.d.ts +25 -0
- package/dist/cjs/index.js +61 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/ports/clock.port.d.ts +5 -0
- package/dist/cjs/ports/clock.port.js +2 -0
- package/dist/cjs/ports/crypto.port.d.ts +7 -0
- package/dist/cjs/ports/crypto.port.js +2 -0
- package/dist/cjs/ports/http-transport.port.d.ts +25 -0
- package/dist/cjs/ports/http-transport.port.js +2 -0
- package/dist/cjs/ports/index.d.ts +5 -0
- package/dist/cjs/ports/index.js +5 -0
- package/dist/cjs/ports/logger.port.d.ts +6 -0
- package/dist/cjs/ports/logger.port.js +7 -0
- package/dist/cjs/testing.d.ts +12 -0
- package/dist/cjs/testing.js +16 -0
- package/dist/cjs/webhooks/verify-webhook.d.ts +21 -0
- package/dist/cjs/webhooks/verify-webhook.js +36 -0
- package/dist/esm/adapters/fetch-transport.d.ts +7 -0
- package/dist/esm/adapters/fetch-transport.js +30 -0
- package/dist/esm/adapters/in-memory/fake-vivoa.d.ts +93 -0
- package/dist/esm/adapters/in-memory/fake-vivoa.js +464 -0
- package/dist/esm/adapters/manual-clock.d.ts +9 -0
- package/dist/esm/adapters/manual-clock.js +16 -0
- package/dist/esm/adapters/node-crypto.d.ts +2 -0
- package/dist/esm/adapters/node-crypto.js +10 -0
- package/dist/esm/adapters/system-clock.d.ts +2 -0
- package/dist/esm/adapters/system-clock.js +4 -0
- package/dist/esm/application/partner-client.d.ts +45 -0
- package/dist/esm/application/partner-client.js +51 -0
- package/dist/esm/application/resources/fleet.d.ts +7 -0
- package/dist/esm/application/resources/fleet.js +9 -0
- package/dist/esm/application/resources/me.d.ts +13 -0
- package/dist/esm/application/resources/me.js +19 -0
- package/dist/esm/application/resources/merchants.d.ts +37 -0
- package/dist/esm/application/resources/merchants.js +87 -0
- package/dist/esm/application/resources/webhooks.d.ts +16 -0
- package/dist/esm/application/resources/webhooks.js +26 -0
- package/dist/esm/application/signed-http.d.ts +41 -0
- package/dist/esm/application/signed-http.js +101 -0
- package/dist/esm/application/use-cases/provision-store.d.ts +50 -0
- package/dist/esm/application/use-cases/provision-store.js +99 -0
- package/dist/esm/cli.d.ts +2 -0
- package/dist/esm/cli.js +106 -0
- package/dist/esm/domain/errors.d.ts +72 -0
- package/dist/esm/domain/errors.js +92 -0
- package/dist/esm/domain/merchant.d.ts +74 -0
- package/dist/esm/domain/merchant.js +23 -0
- package/dist/esm/domain/operator.d.ts +32 -0
- package/dist/esm/domain/operator.js +3 -0
- package/dist/esm/domain/signature.d.ts +21 -0
- package/dist/esm/domain/signature.js +19 -0
- package/dist/esm/domain/webhook.d.ts +48 -0
- package/dist/esm/domain/webhook.js +7 -0
- package/dist/esm/index.d.ts +25 -0
- package/dist/esm/index.js +33 -0
- package/dist/esm/ports/clock.port.d.ts +5 -0
- package/dist/esm/ports/clock.port.js +1 -0
- package/dist/esm/ports/crypto.port.d.ts +7 -0
- package/dist/esm/ports/crypto.port.js +1 -0
- package/dist/esm/ports/http-transport.port.d.ts +25 -0
- package/dist/esm/ports/http-transport.port.js +1 -0
- package/dist/esm/ports/index.d.ts +5 -0
- package/dist/esm/ports/index.js +1 -0
- package/dist/esm/ports/logger.port.d.ts +6 -0
- package/dist/esm/ports/logger.port.js +4 -0
- package/dist/esm/testing.d.ts +12 -0
- package/dist/esm/testing.js +11 -0
- package/dist/esm/webhooks/verify-webhook.d.ts +21 -0
- package/dist/esm/webhooks/verify-webhook.js +33 -0
- package/package.json +56 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { HttpTransport } from '../ports/http-transport.port.ts';
|
|
2
|
+
import type { CryptoProvider } from '../ports/crypto.port.ts';
|
|
3
|
+
import type { Clock } from '../ports/clock.port.ts';
|
|
4
|
+
import { type SdkLogger } from '../ports/logger.port.ts';
|
|
5
|
+
import { MeResource } from './resources/me.ts';
|
|
6
|
+
import { MerchantsResource } from './resources/merchants.ts';
|
|
7
|
+
import { WebhooksResource } from './resources/webhooks.ts';
|
|
8
|
+
import { FleetResource } from './resources/fleet.ts';
|
|
9
|
+
import { type ProvisionStoreOptions, type ProvisionStoreResult } from './use-cases/provision-store.ts';
|
|
10
|
+
import { type VerifyWebhookParams } from '../webhooks/verify-webhook.ts';
|
|
11
|
+
import type { CreateMerchantInput, MerchantStatus } from '../domain/merchant.ts';
|
|
12
|
+
import type { WebhookEvent } from '../domain/webhook.ts';
|
|
13
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
14
|
+
/** Every adapter is injected — the client itself has no I/O of its own. */
|
|
15
|
+
export interface PartnerClientDeps {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
apiSecret: string;
|
|
19
|
+
transport: HttpTransport;
|
|
20
|
+
crypto: CryptoProvider;
|
|
21
|
+
clock: Clock;
|
|
22
|
+
logger?: SdkLogger;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
maxRetries?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class VivoaPartnerClient {
|
|
27
|
+
readonly me: MeResource;
|
|
28
|
+
readonly merchants: MerchantsResource;
|
|
29
|
+
readonly webhooks: WebhooksResource;
|
|
30
|
+
readonly fleet: FleetResource;
|
|
31
|
+
private readonly provisioning;
|
|
32
|
+
private readonly crypto;
|
|
33
|
+
constructor(deps: PartnerClientDeps);
|
|
34
|
+
/**
|
|
35
|
+
* Ensure a merchant exists for `input.externalId`, activate it and wait until
|
|
36
|
+
* it is live. Safe to call again with the same externalId — it resumes.
|
|
37
|
+
*/
|
|
38
|
+
provisionStore(input: CreateMerchantInput, options?: ProvisionStoreOptions): Promise<ProvisionStoreResult>;
|
|
39
|
+
waitUntilSettled(merchantId: string, options?: {
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
pollIntervalMs?: number;
|
|
42
|
+
}): Promise<MerchantStatus>;
|
|
43
|
+
/** Verify + parse an incoming webhook with this client's crypto adapter. */
|
|
44
|
+
verifyWebhook(params: VerifyWebhookParams): Promise<WebhookEvent>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { silentLogger } from "../ports/logger.port.js";
|
|
2
|
+
import { SignedHttpClient } from "./signed-http.js";
|
|
3
|
+
import { MeResource } from "./resources/me.js";
|
|
4
|
+
import { MerchantsResource } from "./resources/merchants.js";
|
|
5
|
+
import { WebhooksResource } from "./resources/webhooks.js";
|
|
6
|
+
import { FleetResource } from "./resources/fleet.js";
|
|
7
|
+
import { ProvisionStoreUseCase, } from "./use-cases/provision-store.js";
|
|
8
|
+
import { verifyWebhook } from "../webhooks/verify-webhook.js";
|
|
9
|
+
export const SDK_VERSION = '0.1.0';
|
|
10
|
+
export class VivoaPartnerClient {
|
|
11
|
+
me;
|
|
12
|
+
merchants;
|
|
13
|
+
webhooks;
|
|
14
|
+
fleet;
|
|
15
|
+
provisioning;
|
|
16
|
+
crypto;
|
|
17
|
+
constructor(deps) {
|
|
18
|
+
const http = new SignedHttpClient({
|
|
19
|
+
baseUrl: deps.baseUrl,
|
|
20
|
+
apiKey: deps.apiKey,
|
|
21
|
+
apiSecret: deps.apiSecret,
|
|
22
|
+
transport: deps.transport,
|
|
23
|
+
crypto: deps.crypto,
|
|
24
|
+
clock: deps.clock,
|
|
25
|
+
logger: deps.logger ?? silentLogger,
|
|
26
|
+
timeoutMs: deps.timeoutMs ?? 30_000,
|
|
27
|
+
maxRetries: deps.maxRetries ?? 2,
|
|
28
|
+
userAgent: `vivoa-partner-sdk/${SDK_VERSION}`,
|
|
29
|
+
});
|
|
30
|
+
this.crypto = deps.crypto;
|
|
31
|
+
this.me = new MeResource(http);
|
|
32
|
+
this.merchants = new MerchantsResource(http);
|
|
33
|
+
this.webhooks = new WebhooksResource(http);
|
|
34
|
+
this.fleet = new FleetResource(http);
|
|
35
|
+
this.provisioning = new ProvisionStoreUseCase(this.merchants, deps.clock);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ensure a merchant exists for `input.externalId`, activate it and wait until
|
|
39
|
+
* it is live. Safe to call again with the same externalId — it resumes.
|
|
40
|
+
*/
|
|
41
|
+
provisionStore(input, options) {
|
|
42
|
+
return this.provisioning.execute(input, options);
|
|
43
|
+
}
|
|
44
|
+
waitUntilSettled(merchantId, options) {
|
|
45
|
+
return this.provisioning.waitUntilSettled(merchantId, options);
|
|
46
|
+
}
|
|
47
|
+
/** Verify + parse an incoming webhook with this client's crypto adapter. */
|
|
48
|
+
verifyWebhook(params) {
|
|
49
|
+
return verifyWebhook(this.crypto, params);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SignedHttpClient } from '../signed-http.ts';
|
|
2
|
+
import type { FleetAnalytics } from '../../domain/operator.ts';
|
|
3
|
+
export declare class FleetResource {
|
|
4
|
+
private readonly http;
|
|
5
|
+
constructor(http: SignedHttpClient);
|
|
6
|
+
analytics(): Promise<FleetAnalytics>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SignedHttpClient } from '../signed-http.ts';
|
|
2
|
+
import type { OperatorProfile, RotatedApiSecret } from '../../domain/operator.ts';
|
|
3
|
+
export declare class MeResource {
|
|
4
|
+
private readonly http;
|
|
5
|
+
constructor(http: SignedHttpClient);
|
|
6
|
+
/** Plan, quota usage (`storesUsed/maxStores`), allowed countries, rate limit. */
|
|
7
|
+
get(): Promise<OperatorProfile>;
|
|
8
|
+
/**
|
|
9
|
+
* Rotates the apiSecret. The new secret is returned ONCE and this client
|
|
10
|
+
* switches to it immediately; persist it before the process exits.
|
|
11
|
+
*/
|
|
12
|
+
rotateSecret(): Promise<RotatedApiSecret>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class MeResource {
|
|
2
|
+
http;
|
|
3
|
+
constructor(http) {
|
|
4
|
+
this.http = http;
|
|
5
|
+
}
|
|
6
|
+
/** Plan, quota usage (`storesUsed/maxStores`), allowed countries, rate limit. */
|
|
7
|
+
get() {
|
|
8
|
+
return this.http.request('GET', '/partner/v1/me');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Rotates the apiSecret. The new secret is returned ONCE and this client
|
|
12
|
+
* switches to it immediately; persist it before the process exits.
|
|
13
|
+
*/
|
|
14
|
+
async rotateSecret() {
|
|
15
|
+
const rotated = await this.http.request('POST', '/partner/v1/me/rotate-secret');
|
|
16
|
+
this.http.setApiSecret(rotated.apiSecret);
|
|
17
|
+
return rotated;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SignedHttpClient } from '../signed-http.ts';
|
|
2
|
+
import { type CreateMerchantInput, type ListMerchantsQuery, type Merchant, type MerchantStatus, type Page } from '../../domain/merchant.ts';
|
|
3
|
+
/** Provisioning is synchronous on the platform: allow it minutes, not seconds. */
|
|
4
|
+
export declare const DEFAULT_ACTIVATE_TIMEOUT_MS: number;
|
|
5
|
+
export declare class MerchantsResource {
|
|
6
|
+
private readonly http;
|
|
7
|
+
constructor(http: SignedHttpClient);
|
|
8
|
+
/**
|
|
9
|
+
* Creates owner + store in your fleet. Not live until `activate`. The
|
|
10
|
+
* platform assigns the handle. Pass `externalId` and replaying the same
|
|
11
|
+
* value returns the merchant you already created — never a second one.
|
|
12
|
+
*/
|
|
13
|
+
create(input: CreateMerchantInput): Promise<Merchant>;
|
|
14
|
+
list(query?: ListMerchantsQuery): Promise<Page<Merchant>>;
|
|
15
|
+
/** Walks every page of the fleet. */
|
|
16
|
+
listAll(query?: Omit<ListMerchantsQuery, 'page'>): AsyncGenerator<Merchant>;
|
|
17
|
+
/** The merchant created with this reference, or `null` — an exact server-side match. */
|
|
18
|
+
findByExternalId(externalId: string): Promise<Merchant | null>;
|
|
19
|
+
status(merchantId: string): Promise<MerchantStatus>;
|
|
20
|
+
/**
|
|
21
|
+
* Provisions the store and installs your default plugins. 409 if it is
|
|
22
|
+
* already creating/live/suspended; retry allowed after `failed`.
|
|
23
|
+
* Prefer `client.provisionStore()` which handles all of that.
|
|
24
|
+
*/
|
|
25
|
+
activate(merchantId: string, options?: {
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}): Promise<MerchantStatus>;
|
|
28
|
+
suspend(merchantId: string): Promise<{
|
|
29
|
+
storeId: string;
|
|
30
|
+
status: 'suspended';
|
|
31
|
+
}>;
|
|
32
|
+
reactivate(merchantId: string): Promise<{
|
|
33
|
+
storeId: string;
|
|
34
|
+
status: 'active';
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export declare function validateCreate(input: CreateMerchantInput): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { VivoaValidationError } from "../../domain/errors.js";
|
|
2
|
+
import { MAX_EXTERNAL_ID_LENGTH, } from "../../domain/merchant.js";
|
|
3
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
4
|
+
const COUNTRY_RE = /^[A-Z]{2}$/;
|
|
5
|
+
/** Provisioning is synchronous on the platform: allow it minutes, not seconds. */
|
|
6
|
+
export const DEFAULT_ACTIVATE_TIMEOUT_MS = 10 * 60_000;
|
|
7
|
+
export class MerchantsResource {
|
|
8
|
+
http;
|
|
9
|
+
constructor(http) {
|
|
10
|
+
this.http = http;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates owner + store in your fleet. Not live until `activate`. The
|
|
14
|
+
* platform assigns the handle. Pass `externalId` and replaying the same
|
|
15
|
+
* value returns the merchant you already created — never a second one.
|
|
16
|
+
*/
|
|
17
|
+
async create(input) {
|
|
18
|
+
validateCreate(input);
|
|
19
|
+
return this.http.request('POST', '/partner/v1/merchants', { body: input });
|
|
20
|
+
}
|
|
21
|
+
list(query = {}) {
|
|
22
|
+
return this.http.request('GET', '/partner/v1/merchants', {
|
|
23
|
+
query: { page: query.page, limit: query.limit, search: query.search, externalId: query.externalId },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Walks every page of the fleet. */
|
|
27
|
+
async *listAll(query = {}) {
|
|
28
|
+
const limit = query.limit ?? 100;
|
|
29
|
+
for (let page = 1;; page++) {
|
|
30
|
+
const res = await this.list({ ...query, page, limit });
|
|
31
|
+
yield* res.data;
|
|
32
|
+
if (res.data.length === 0 || page * limit >= res.meta.total)
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** The merchant created with this reference, or `null` — an exact server-side match. */
|
|
37
|
+
async findByExternalId(externalId) {
|
|
38
|
+
const page = await this.list({ limit: 1, externalId });
|
|
39
|
+
return page.data[0] ?? null;
|
|
40
|
+
}
|
|
41
|
+
async status(merchantId) {
|
|
42
|
+
return this.http.request('GET', `/partner/v1/merchants/${encodeId(merchantId)}/status`);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Provisions the store and installs your default plugins. 409 if it is
|
|
46
|
+
* already creating/live/suspended; retry allowed after `failed`.
|
|
47
|
+
* Prefer `client.provisionStore()` which handles all of that.
|
|
48
|
+
*/
|
|
49
|
+
async activate(merchantId, options = {}) {
|
|
50
|
+
return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/activate`, {
|
|
51
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_ACTIVATE_TIMEOUT_MS,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async suspend(merchantId) {
|
|
55
|
+
return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/suspend`);
|
|
56
|
+
}
|
|
57
|
+
async reactivate(merchantId) {
|
|
58
|
+
return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/reactivate`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function encodeId(id) {
|
|
62
|
+
if (!id)
|
|
63
|
+
throw new VivoaValidationError('merchantId is required');
|
|
64
|
+
return encodeURIComponent(id);
|
|
65
|
+
}
|
|
66
|
+
export function validateCreate(input) {
|
|
67
|
+
const problems = [];
|
|
68
|
+
if (!EMAIL_RE.test(input.email ?? ''))
|
|
69
|
+
problems.push('email must be a valid address');
|
|
70
|
+
if (!input.name?.trim())
|
|
71
|
+
problems.push('name is required');
|
|
72
|
+
if (!Array.isArray(input.countries) || input.countries.length === 0) {
|
|
73
|
+
problems.push('countries must contain at least one ISO-3166 code');
|
|
74
|
+
}
|
|
75
|
+
else if (input.countries.some((c) => !COUNTRY_RE.test(c))) {
|
|
76
|
+
problems.push('countries must be uppercase ISO-3166 alpha-2 codes (e.g. "SV")');
|
|
77
|
+
}
|
|
78
|
+
if (input.externalId !== undefined) {
|
|
79
|
+
if (!input.externalId.trim())
|
|
80
|
+
problems.push('externalId must not be empty');
|
|
81
|
+
else if (input.externalId.length > MAX_EXTERNAL_ID_LENGTH) {
|
|
82
|
+
problems.push(`externalId must be at most ${MAX_EXTERNAL_ID_LENGTH} chars`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (problems.length > 0)
|
|
86
|
+
throw new VivoaValidationError(problems.join('; '));
|
|
87
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SignedHttpClient } from '../signed-http.ts';
|
|
2
|
+
import type { SetWebhookInput, SetWebhookResult, WebhookConfig, WebhookDelivery } from '../../domain/webhook.ts';
|
|
3
|
+
export declare class WebhooksResource {
|
|
4
|
+
private readonly http;
|
|
5
|
+
constructor(http: SignedHttpClient);
|
|
6
|
+
get(): Promise<WebhookConfig>;
|
|
7
|
+
/** `signingSecret` comes back only when the endpoint is first created. */
|
|
8
|
+
set(input: SetWebhookInput): Promise<SetWebhookResult>;
|
|
9
|
+
remove(): Promise<WebhookConfig>;
|
|
10
|
+
rotateSecret(): Promise<{
|
|
11
|
+
signingSecret: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** Sends a signed `platform.test` event to your endpoint now. */
|
|
14
|
+
test(): Promise<WebhookDelivery>;
|
|
15
|
+
deliveries(limit?: number): Promise<WebhookDelivery[]>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class WebhooksResource {
|
|
2
|
+
http;
|
|
3
|
+
constructor(http) {
|
|
4
|
+
this.http = http;
|
|
5
|
+
}
|
|
6
|
+
get() {
|
|
7
|
+
return this.http.request('GET', '/partner/v1/webhooks');
|
|
8
|
+
}
|
|
9
|
+
/** `signingSecret` comes back only when the endpoint is first created. */
|
|
10
|
+
set(input) {
|
|
11
|
+
return this.http.request('PUT', '/partner/v1/webhooks', { body: input });
|
|
12
|
+
}
|
|
13
|
+
remove() {
|
|
14
|
+
return this.http.request('DELETE', '/partner/v1/webhooks');
|
|
15
|
+
}
|
|
16
|
+
rotateSecret() {
|
|
17
|
+
return this.http.request('POST', '/partner/v1/webhooks/rotate-secret');
|
|
18
|
+
}
|
|
19
|
+
/** Sends a signed `platform.test` event to your endpoint now. */
|
|
20
|
+
test() {
|
|
21
|
+
return this.http.request('POST', '/partner/v1/webhooks/test');
|
|
22
|
+
}
|
|
23
|
+
deliveries(limit = 25) {
|
|
24
|
+
return this.http.request('GET', '/partner/v1/webhooks/deliveries', { query: { limit } });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HttpRequest, HttpTransport } from '../ports/http-transport.port.ts';
|
|
2
|
+
import type { CryptoProvider } from '../ports/crypto.port.ts';
|
|
3
|
+
import type { Clock } from '../ports/clock.port.ts';
|
|
4
|
+
import type { SdkLogger } from '../ports/logger.port.ts';
|
|
5
|
+
export type HttpMethod = HttpRequest['method'];
|
|
6
|
+
export type QueryValue = string | number | boolean | undefined | null;
|
|
7
|
+
export interface SignedHttpConfig {
|
|
8
|
+
/** Origin of the platform API, e.g. `https://api.vivoa.app` (no path). */
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
apiSecret: string;
|
|
12
|
+
transport: HttpTransport;
|
|
13
|
+
crypto: CryptoProvider;
|
|
14
|
+
clock: Clock;
|
|
15
|
+
logger: SdkLogger;
|
|
16
|
+
/** Default per-request timeout. */
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
/** Retries for safe (GET) requests on 429 / 5xx / network errors. */
|
|
19
|
+
maxRetries: number;
|
|
20
|
+
userAgent: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RequestOptions {
|
|
23
|
+
body?: unknown;
|
|
24
|
+
query?: Record<string, QueryValue>;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The only place that talks to the transport. Signs every attempt with a fresh
|
|
29
|
+
* timestamp over the EXACT bytes it sends, parses JSON and turns non-2xx into a
|
|
30
|
+
* typed `VivoaApiError`. Only GETs are retried: writes are not idempotent on
|
|
31
|
+
* the platform yet, so a retried POST could create a second merchant.
|
|
32
|
+
*/
|
|
33
|
+
export declare class SignedHttpClient {
|
|
34
|
+
private readonly config;
|
|
35
|
+
private apiSecret;
|
|
36
|
+
constructor(config: SignedHttpConfig);
|
|
37
|
+
/** Used after `me.rotateSecret()` — the old secret stops working immediately. */
|
|
38
|
+
setApiSecret(secret: string): void;
|
|
39
|
+
request<T>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
|
|
40
|
+
private sign;
|
|
41
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { apiErrorFor, VivoaApiError, VivoaNetworkError } from "../domain/errors.js";
|
|
2
|
+
import { canonicalRequestPayload, PARTNER_HEADERS } from "../domain/signature.js";
|
|
3
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
4
|
+
const BACKOFF_BASE_MS = 500;
|
|
5
|
+
const BACKOFF_MAX_MS = 15_000;
|
|
6
|
+
/**
|
|
7
|
+
* The only place that talks to the transport. Signs every attempt with a fresh
|
|
8
|
+
* timestamp over the EXACT bytes it sends, parses JSON and turns non-2xx into a
|
|
9
|
+
* typed `VivoaApiError`. Only GETs are retried: writes are not idempotent on
|
|
10
|
+
* the platform yet, so a retried POST could create a second merchant.
|
|
11
|
+
*/
|
|
12
|
+
export class SignedHttpClient {
|
|
13
|
+
config;
|
|
14
|
+
apiSecret;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
if (!config.apiKey)
|
|
17
|
+
throw new Error('apiKey is required');
|
|
18
|
+
if (!config.apiSecret)
|
|
19
|
+
throw new Error('apiSecret is required');
|
|
20
|
+
this.config = { ...config, baseUrl: config.baseUrl.replace(/\/+$/, '') };
|
|
21
|
+
this.apiSecret = config.apiSecret;
|
|
22
|
+
}
|
|
23
|
+
/** Used after `me.rotateSecret()` — the old secret stops working immediately. */
|
|
24
|
+
setApiSecret(secret) {
|
|
25
|
+
this.apiSecret = secret;
|
|
26
|
+
}
|
|
27
|
+
async request(method, path, options = {}) {
|
|
28
|
+
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
29
|
+
const url = this.config.baseUrl + path + queryString(options.query);
|
|
30
|
+
const timeoutMs = options.timeoutMs ?? this.config.timeoutMs;
|
|
31
|
+
const maxAttempts = method === 'GET' ? this.config.maxRetries + 1 : 1;
|
|
32
|
+
for (let attempt = 1;; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
const res = await this.config.transport.send(await this.sign({ method, url, path, body, timeoutMs }));
|
|
35
|
+
this.config.logger.debug('vivoa.request', { method, path, status: res.status, attempt });
|
|
36
|
+
if (res.status >= 200 && res.status < 300)
|
|
37
|
+
return parseBody(res);
|
|
38
|
+
throw apiErrorFor({ status: res.status, method, path, body: parseBody(res) });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (attempt >= maxAttempts || !isRetryable(err))
|
|
42
|
+
throw err;
|
|
43
|
+
const delay = backoffMs(attempt, err);
|
|
44
|
+
this.config.logger.warn('vivoa.retry', { method, path, attempt, delayMs: delay, reason: err.message });
|
|
45
|
+
await this.config.clock.sleep(delay);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async sign(params) {
|
|
50
|
+
const { crypto, clock, apiKey, userAgent } = this.config;
|
|
51
|
+
const timestamp = Math.floor(clock.nowMs() / 1000).toString();
|
|
52
|
+
const payload = canonicalRequestPayload({
|
|
53
|
+
timestamp,
|
|
54
|
+
method: params.method,
|
|
55
|
+
path: params.path,
|
|
56
|
+
bodySha256Hex: await crypto.sha256Hex(params.body ?? ''),
|
|
57
|
+
});
|
|
58
|
+
const headers = {
|
|
59
|
+
accept: 'application/json',
|
|
60
|
+
'user-agent': userAgent,
|
|
61
|
+
[PARTNER_HEADERS.key]: apiKey,
|
|
62
|
+
[PARTNER_HEADERS.timestamp]: timestamp,
|
|
63
|
+
[PARTNER_HEADERS.signature]: await crypto.hmacSha256Hex(this.apiSecret, payload),
|
|
64
|
+
};
|
|
65
|
+
if (params.body !== undefined)
|
|
66
|
+
headers['content-type'] = 'application/json';
|
|
67
|
+
return { method: params.method, url: params.url, headers, body: params.body, timeoutMs: params.timeoutMs };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function queryString(query) {
|
|
71
|
+
if (!query)
|
|
72
|
+
return '';
|
|
73
|
+
const params = new URLSearchParams();
|
|
74
|
+
for (const [key, value] of Object.entries(query)) {
|
|
75
|
+
if (value !== undefined && value !== null && value !== '')
|
|
76
|
+
params.set(key, String(value));
|
|
77
|
+
}
|
|
78
|
+
const qs = params.toString();
|
|
79
|
+
return qs ? `?${qs}` : '';
|
|
80
|
+
}
|
|
81
|
+
function parseBody(res) {
|
|
82
|
+
if (!res.body)
|
|
83
|
+
return undefined;
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(res.body);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return res.body;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isRetryable(err) {
|
|
92
|
+
if (err instanceof VivoaNetworkError)
|
|
93
|
+
return true;
|
|
94
|
+
return err instanceof VivoaApiError && RETRYABLE_STATUS.has(err.status);
|
|
95
|
+
}
|
|
96
|
+
function backoffMs(attempt, err) {
|
|
97
|
+
const exp = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
|
|
98
|
+
// Rate limits are per minute: waiting less than a few seconds just burns the next attempt.
|
|
99
|
+
const floor = err instanceof VivoaApiError && err.status === 429 ? 2_000 : 0;
|
|
100
|
+
return Math.max(floor, Math.round(exp / 2 + Math.random() * (exp / 2)));
|
|
101
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Clock } from '../../ports/clock.port.ts';
|
|
2
|
+
import type { MerchantsResource } from '../resources/merchants.ts';
|
|
3
|
+
import { type CreateMerchantInput, type Merchant, type MerchantRuntimeStatus, type MerchantStatus } from '../../domain/merchant.ts';
|
|
4
|
+
export type ProvisionPhase = 'found' | 'created' | 'activating' | 'waiting' | 'settled';
|
|
5
|
+
export interface ProvisionProgress {
|
|
6
|
+
phase: ProvisionPhase;
|
|
7
|
+
merchantId: string;
|
|
8
|
+
handle: string;
|
|
9
|
+
runtime?: MerchantRuntimeStatus;
|
|
10
|
+
}
|
|
11
|
+
export interface ProvisionStoreOptions {
|
|
12
|
+
/** Call `activate` (default true). `false` only ensures the merchant exists. */
|
|
13
|
+
activate?: boolean;
|
|
14
|
+
/** Wait for a settled runtime (live/failed/…) — default true. */
|
|
15
|
+
waitUntilLive?: boolean;
|
|
16
|
+
/** Overall budget for activation + waiting. Default 15 min. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Status poll cadence while `creating`. Default 5 s. */
|
|
19
|
+
pollIntervalMs?: number;
|
|
20
|
+
onProgress?: (progress: ProvisionProgress) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface ProvisionStoreResult {
|
|
23
|
+
merchant: Merchant;
|
|
24
|
+
status: MerchantStatus;
|
|
25
|
+
/** false when a merchant with this externalId already existed in your fleet. */
|
|
26
|
+
created: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* "Give me a live store" — the one call most partners need.
|
|
30
|
+
*
|
|
31
|
+
* Requires `input.externalId`: it is what makes retrying safe. Re-running
|
|
32
|
+
* after a crash, a timeout or a double click resumes the same merchant
|
|
33
|
+
* instead of creating a second one — the platform is idempotent on it (see
|
|
34
|
+
* `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
|
|
35
|
+
* platform now assigns and a partner never sees before creation). Never
|
|
36
|
+
* retries a failed activation on its own.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ProvisionStoreUseCase {
|
|
39
|
+
private readonly merchants;
|
|
40
|
+
private readonly clock;
|
|
41
|
+
constructor(merchants: MerchantsResource, clock: Clock);
|
|
42
|
+
execute(input: CreateMerchantInput, options?: ProvisionStoreOptions): Promise<ProvisionStoreResult>;
|
|
43
|
+
/** Polls `GET /status` until the runtime leaves `creating`. */
|
|
44
|
+
waitUntilSettled(merchantId: string, options?: {
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
pollIntervalMs?: number;
|
|
47
|
+
}): Promise<MerchantStatus>;
|
|
48
|
+
private ensureMerchant;
|
|
49
|
+
private activate;
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { canActivate, isSettled, } from "../../domain/merchant.js";
|
|
2
|
+
import { VivoaConflictError, VivoaNetworkError, VivoaProvisioningFailedError, VivoaServerError, VivoaTimeoutError, VivoaValidationError, } from "../../domain/errors.js";
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60_000;
|
|
4
|
+
const DEFAULT_POLL_MS = 5_000;
|
|
5
|
+
/**
|
|
6
|
+
* "Give me a live store" — the one call most partners need.
|
|
7
|
+
*
|
|
8
|
+
* Requires `input.externalId`: it is what makes retrying safe. Re-running
|
|
9
|
+
* after a crash, a timeout or a double click resumes the same merchant
|
|
10
|
+
* instead of creating a second one — the platform is idempotent on it (see
|
|
11
|
+
* `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
|
|
12
|
+
* platform now assigns and a partner never sees before creation). Never
|
|
13
|
+
* retries a failed activation on its own.
|
|
14
|
+
*/
|
|
15
|
+
export class ProvisionStoreUseCase {
|
|
16
|
+
merchants;
|
|
17
|
+
clock;
|
|
18
|
+
constructor(merchants, clock) {
|
|
19
|
+
this.merchants = merchants;
|
|
20
|
+
this.clock = clock;
|
|
21
|
+
}
|
|
22
|
+
async execute(input, options = {}) {
|
|
23
|
+
if (!input.externalId?.trim()) {
|
|
24
|
+
throw new VivoaValidationError('provisionStore requires input.externalId — it is the key that makes retrying safe. ' +
|
|
25
|
+
'Use merchants.create() directly if you do not need that guarantee.');
|
|
26
|
+
}
|
|
27
|
+
const deadline = this.clock.nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
28
|
+
const progress = (phase, merchant, runtime) => options.onProgress?.({ phase, merchantId: merchant.id, handle: merchant.handle, runtime });
|
|
29
|
+
const { merchant, created } = await this.ensureMerchant(input);
|
|
30
|
+
progress(created ? 'created' : 'found', merchant, merchant.runtime);
|
|
31
|
+
let status = await this.merchants.status(merchant.id);
|
|
32
|
+
if (options.activate === false)
|
|
33
|
+
return { merchant, status, created };
|
|
34
|
+
if (canActivate(status.deployStatus)) {
|
|
35
|
+
progress('activating', merchant, status.deployStatus);
|
|
36
|
+
status = await this.activate(merchant.id, deadline);
|
|
37
|
+
}
|
|
38
|
+
if (options.waitUntilLive !== false && !isSettled(status.deployStatus)) {
|
|
39
|
+
progress('waiting', merchant, status.deployStatus);
|
|
40
|
+
status = await this.waitUntilSettled(merchant.id, {
|
|
41
|
+
timeoutMs: Math.max(0, deadline - this.clock.nowMs()),
|
|
42
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
progress('settled', merchant, status.deployStatus);
|
|
46
|
+
if (status.deployStatus === 'failed') {
|
|
47
|
+
throw new VivoaProvisioningFailedError(merchant.id, 'the platform reported runtime status "failed"');
|
|
48
|
+
}
|
|
49
|
+
return { merchant: { ...merchant, runtime: status.deployStatus, domain: status.domain }, status, created };
|
|
50
|
+
}
|
|
51
|
+
/** Polls `GET /status` until the runtime leaves `creating`. */
|
|
52
|
+
async waitUntilSettled(merchantId, options = {}) {
|
|
53
|
+
const deadline = this.clock.nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
54
|
+
const interval = options.pollIntervalMs ?? DEFAULT_POLL_MS;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const status = await this.merchants.status(merchantId);
|
|
57
|
+
if (isSettled(status.deployStatus))
|
|
58
|
+
return status;
|
|
59
|
+
if (this.clock.nowMs() + interval > deadline) {
|
|
60
|
+
throw new VivoaTimeoutError(`Merchant ${merchantId} still "${status.deployStatus}" after the wait budget`);
|
|
61
|
+
}
|
|
62
|
+
await this.clock.sleep(interval);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async ensureMerchant(input) {
|
|
66
|
+
const existing = await this.merchants.findByExternalId(input.externalId);
|
|
67
|
+
if (existing)
|
|
68
|
+
return { merchant: existing, created: false };
|
|
69
|
+
// The platform is idempotent on externalId: even if a concurrent call for
|
|
70
|
+
// the same externalId wins the race between the check above and this
|
|
71
|
+
// request, `create` still resolves to ITS merchant — never a 409 or a
|
|
72
|
+
// second store. `created` can read `true` in that narrow window; harmless,
|
|
73
|
+
// since the merchant returned is the same either way.
|
|
74
|
+
return { merchant: await this.merchants.create(input), created: true };
|
|
75
|
+
}
|
|
76
|
+
async activate(merchantId, deadline) {
|
|
77
|
+
try {
|
|
78
|
+
return await this.merchants.activate(merchantId, {
|
|
79
|
+
timeoutMs: Math.max(1_000, deadline - this.clock.nowMs()),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
// 409: someone else already activated it. Network/timeout: the request may
|
|
84
|
+
// still be provisioning server-side. Either way, the status endpoint knows.
|
|
85
|
+
if (err instanceof VivoaConflictError || err instanceof VivoaNetworkError) {
|
|
86
|
+
return this.merchants.status(merchantId);
|
|
87
|
+
}
|
|
88
|
+
if (err instanceof VivoaServerError) {
|
|
89
|
+
const status = await this.merchants.status(merchantId).catch(() => null);
|
|
90
|
+
if (status?.deployStatus === 'failed') {
|
|
91
|
+
throw new VivoaProvisioningFailedError(merchantId, err.message, { cause: err });
|
|
92
|
+
}
|
|
93
|
+
if (status && !canActivate(status.deployStatus))
|
|
94
|
+
return status;
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|