@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +162 -0
  3. package/dist/cjs/adapters/fetch-transport.d.ts +7 -0
  4. package/dist/cjs/adapters/fetch-transport.js +34 -0
  5. package/dist/cjs/adapters/in-memory/fake-vivoa.d.ts +93 -0
  6. package/dist/cjs/adapters/in-memory/fake-vivoa.js +468 -0
  7. package/dist/cjs/adapters/manual-clock.d.ts +9 -0
  8. package/dist/cjs/adapters/manual-clock.js +20 -0
  9. package/dist/cjs/adapters/node-crypto.d.ts +2 -0
  10. package/dist/cjs/adapters/node-crypto.js +13 -0
  11. package/dist/cjs/adapters/system-clock.d.ts +2 -0
  12. package/dist/cjs/adapters/system-clock.js +7 -0
  13. package/dist/cjs/application/partner-client.d.ts +45 -0
  14. package/dist/cjs/application/partner-client.js +55 -0
  15. package/dist/cjs/application/resources/fleet.d.ts +7 -0
  16. package/dist/cjs/application/resources/fleet.js +13 -0
  17. package/dist/cjs/application/resources/me.d.ts +13 -0
  18. package/dist/cjs/application/resources/me.js +23 -0
  19. package/dist/cjs/application/resources/merchants.d.ts +37 -0
  20. package/dist/cjs/application/resources/merchants.js +92 -0
  21. package/dist/cjs/application/resources/webhooks.d.ts +16 -0
  22. package/dist/cjs/application/resources/webhooks.js +30 -0
  23. package/dist/cjs/application/signed-http.d.ts +41 -0
  24. package/dist/cjs/application/signed-http.js +105 -0
  25. package/dist/cjs/application/use-cases/provision-store.d.ts +50 -0
  26. package/dist/cjs/application/use-cases/provision-store.js +103 -0
  27. package/dist/cjs/domain/errors.d.ts +72 -0
  28. package/dist/cjs/domain/errors.js +111 -0
  29. package/dist/cjs/domain/merchant.d.ts +74 -0
  30. package/dist/cjs/domain/merchant.js +28 -0
  31. package/dist/cjs/domain/operator.d.ts +32 -0
  32. package/dist/cjs/domain/operator.js +6 -0
  33. package/dist/cjs/domain/signature.d.ts +21 -0
  34. package/dist/cjs/domain/signature.js +23 -0
  35. package/dist/cjs/domain/webhook.d.ts +48 -0
  36. package/dist/cjs/domain/webhook.js +10 -0
  37. package/dist/cjs/index.d.ts +25 -0
  38. package/dist/cjs/index.js +61 -0
  39. package/dist/cjs/package.json +1 -0
  40. package/dist/cjs/ports/clock.port.d.ts +5 -0
  41. package/dist/cjs/ports/clock.port.js +2 -0
  42. package/dist/cjs/ports/crypto.port.d.ts +7 -0
  43. package/dist/cjs/ports/crypto.port.js +2 -0
  44. package/dist/cjs/ports/http-transport.port.d.ts +25 -0
  45. package/dist/cjs/ports/http-transport.port.js +2 -0
  46. package/dist/cjs/ports/index.d.ts +5 -0
  47. package/dist/cjs/ports/index.js +5 -0
  48. package/dist/cjs/ports/logger.port.d.ts +6 -0
  49. package/dist/cjs/ports/logger.port.js +7 -0
  50. package/dist/cjs/testing.d.ts +12 -0
  51. package/dist/cjs/testing.js +16 -0
  52. package/dist/cjs/webhooks/verify-webhook.d.ts +21 -0
  53. package/dist/cjs/webhooks/verify-webhook.js +36 -0
  54. package/dist/esm/adapters/fetch-transport.d.ts +7 -0
  55. package/dist/esm/adapters/fetch-transport.js +30 -0
  56. package/dist/esm/adapters/in-memory/fake-vivoa.d.ts +93 -0
  57. package/dist/esm/adapters/in-memory/fake-vivoa.js +464 -0
  58. package/dist/esm/adapters/manual-clock.d.ts +9 -0
  59. package/dist/esm/adapters/manual-clock.js +16 -0
  60. package/dist/esm/adapters/node-crypto.d.ts +2 -0
  61. package/dist/esm/adapters/node-crypto.js +10 -0
  62. package/dist/esm/adapters/system-clock.d.ts +2 -0
  63. package/dist/esm/adapters/system-clock.js +4 -0
  64. package/dist/esm/application/partner-client.d.ts +45 -0
  65. package/dist/esm/application/partner-client.js +51 -0
  66. package/dist/esm/application/resources/fleet.d.ts +7 -0
  67. package/dist/esm/application/resources/fleet.js +9 -0
  68. package/dist/esm/application/resources/me.d.ts +13 -0
  69. package/dist/esm/application/resources/me.js +19 -0
  70. package/dist/esm/application/resources/merchants.d.ts +37 -0
  71. package/dist/esm/application/resources/merchants.js +87 -0
  72. package/dist/esm/application/resources/webhooks.d.ts +16 -0
  73. package/dist/esm/application/resources/webhooks.js +26 -0
  74. package/dist/esm/application/signed-http.d.ts +41 -0
  75. package/dist/esm/application/signed-http.js +101 -0
  76. package/dist/esm/application/use-cases/provision-store.d.ts +50 -0
  77. package/dist/esm/application/use-cases/provision-store.js +99 -0
  78. package/dist/esm/cli.d.ts +2 -0
  79. package/dist/esm/cli.js +106 -0
  80. package/dist/esm/domain/errors.d.ts +72 -0
  81. package/dist/esm/domain/errors.js +92 -0
  82. package/dist/esm/domain/merchant.d.ts +74 -0
  83. package/dist/esm/domain/merchant.js +23 -0
  84. package/dist/esm/domain/operator.d.ts +32 -0
  85. package/dist/esm/domain/operator.js +3 -0
  86. package/dist/esm/domain/signature.d.ts +21 -0
  87. package/dist/esm/domain/signature.js +19 -0
  88. package/dist/esm/domain/webhook.d.ts +48 -0
  89. package/dist/esm/domain/webhook.js +7 -0
  90. package/dist/esm/index.d.ts +25 -0
  91. package/dist/esm/index.js +33 -0
  92. package/dist/esm/ports/clock.port.d.ts +5 -0
  93. package/dist/esm/ports/clock.port.js +1 -0
  94. package/dist/esm/ports/crypto.port.d.ts +7 -0
  95. package/dist/esm/ports/crypto.port.js +1 -0
  96. package/dist/esm/ports/http-transport.port.d.ts +25 -0
  97. package/dist/esm/ports/http-transport.port.js +1 -0
  98. package/dist/esm/ports/index.d.ts +5 -0
  99. package/dist/esm/ports/index.js +1 -0
  100. package/dist/esm/ports/logger.port.d.ts +6 -0
  101. package/dist/esm/ports/logger.port.js +4 -0
  102. package/dist/esm/testing.d.ts +12 -0
  103. package/dist/esm/testing.js +11 -0
  104. package/dist/esm/webhooks/verify-webhook.d.ts +21 -0
  105. package/dist/esm/webhooks/verify-webhook.js +33 -0
  106. package/package.json +56 -0
@@ -0,0 +1,468 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FakeVivoa = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_crypto_ts_1 = require("../node-crypto.js");
6
+ const system_clock_ts_1 = require("../system-clock.js");
7
+ const errors_ts_1 = require("../../domain/errors.js");
8
+ const signature_ts_1 = require("../../domain/signature.js");
9
+ const merchant_ts_1 = require("../../domain/merchant.js");
10
+ const webhook_ts_1 = require("../../domain/webhook.js");
11
+ class HttpError extends Error {
12
+ status;
13
+ constructor(status, message) {
14
+ super(message);
15
+ this.status = status;
16
+ }
17
+ }
18
+ /** `POST /merchants` body — matches CreateMerchantDto's whitelist exactly (a stale `handle` 400s, like on the real platform). */
19
+ const CREATE_MERCHANT_KEYS = new Set(['email', 'name', 'countries', 'industry', 'externalId']);
20
+ /** Mirrors `shared/utils/slugify.ts` on the platform closely enough for a fake. */
21
+ function slugify(value) {
22
+ return value
23
+ .toLowerCase()
24
+ .normalize('NFD')
25
+ .replace(/[̀-ͯ]/g, '')
26
+ .replace(/[^a-z0-9]+/g, '-')
27
+ .replace(/(^-|-$)/g, '')
28
+ .slice(0, 40);
29
+ }
30
+ const REASON = {
31
+ 400: 'Bad Request',
32
+ 401: 'Unauthorized',
33
+ 403: 'Forbidden',
34
+ 404: 'Not Found',
35
+ 409: 'Conflict',
36
+ 429: 'Too Many Requests',
37
+ 500: 'Internal Server Error',
38
+ };
39
+ /**
40
+ * In-memory Vivoa Partner API behind the `HttpTransport` port. It verifies the
41
+ * real HMAC signature and enforces the same rules as the platform
42
+ * (`engine/partner-api`): quota, allowed countries, unique handle, active-only
43
+ * writes, fleet scoping, activation state machine and per-minute rate limit.
44
+ * The shared contract suite (`test/contract`) runs against this fake AND the
45
+ * live API, so the two cannot silently drift.
46
+ */
47
+ class FakeVivoa {
48
+ clock;
49
+ activation;
50
+ pollsUntilLive;
51
+ failureReason;
52
+ crypto;
53
+ onWebhook;
54
+ operators = new Map();
55
+ merchants = new Map();
56
+ handles = new Set();
57
+ constructor(options = {}) {
58
+ this.clock = options.clock ?? system_clock_ts_1.systemClock;
59
+ this.crypto = options.crypto ?? node_crypto_ts_1.nodeCrypto;
60
+ this.activation = options.activation ?? 'live';
61
+ this.pollsUntilLive = options.pollsUntilLive ?? 2;
62
+ this.failureReason = options.failureReason ?? 'droplet pool exhausted';
63
+ this.onWebhook = options.onWebhook;
64
+ for (const handle of options.takenHandles ?? [])
65
+ this.handles.add(handle);
66
+ for (const seed of options.operators ?? [])
67
+ this.addOperator(seed);
68
+ }
69
+ addOperator(seed) {
70
+ const state = {
71
+ name: 'Fake Partner',
72
+ status: 'active',
73
+ plan: 'starter',
74
+ maxStores: 3,
75
+ allowedRegions: ['SV'],
76
+ rateLimitPerMin: 60,
77
+ ...seed,
78
+ id: `op_${(0, node_crypto_1.randomUUID)().slice(0, 8)}`,
79
+ window: { startMs: 0, count: 0 },
80
+ webhook: null,
81
+ deliveries: [],
82
+ };
83
+ this.operators.set(seed.apiKey, state);
84
+ return this.profile(state);
85
+ }
86
+ setOperatorStatus(apiKey, status) {
87
+ this.requireOperatorByKey(apiKey).status = status;
88
+ }
89
+ /** Test helper: flip a merchant's runtime (e.g. simulate ops tearing it down). */
90
+ setRuntime(merchantId, runtime) {
91
+ const m = this.merchants.get(merchantId);
92
+ if (m)
93
+ m.runtime = runtime;
94
+ }
95
+ async send(req) {
96
+ try {
97
+ const url = new URL(req.url);
98
+ const operator = await this.authenticate(req, url.pathname);
99
+ this.rateLimit(operator);
100
+ const result = await this.route(operator, req.method, url, req.body);
101
+ return json(200, result);
102
+ }
103
+ catch (err) {
104
+ if (err instanceof errors_ts_1.VivoaNetworkError)
105
+ throw err;
106
+ if (err instanceof HttpError) {
107
+ return json(err.status, { statusCode: err.status, message: err.message, error: REASON[err.status] });
108
+ }
109
+ return json(500, { statusCode: 500, message: err.message, error: REASON[500] });
110
+ }
111
+ }
112
+ // ── auth (mirrors core/partner/guards) ──────────────────────────────────
113
+ async authenticate(req, path) {
114
+ const h = lower(req.headers);
115
+ const key = h[signature_ts_1.PARTNER_HEADERS.key];
116
+ const timestamp = h[signature_ts_1.PARTNER_HEADERS.timestamp];
117
+ const signature = h[signature_ts_1.PARTNER_HEADERS.signature];
118
+ if (!key || !timestamp || !signature)
119
+ throw new HttpError(401, 'Missing partner auth headers');
120
+ const ts = Number(timestamp);
121
+ if (!Number.isFinite(ts))
122
+ throw new HttpError(401, 'Invalid timestamp');
123
+ if (Math.abs(Math.floor(this.clock.nowMs() / 1000) - ts) > signature_ts_1.MAX_CLOCK_SKEW_SECONDS) {
124
+ throw new HttpError(401, 'Timestamp skew too large');
125
+ }
126
+ const operator = this.operators.get(key);
127
+ if (!operator)
128
+ throw new HttpError(401, 'Unknown partner key');
129
+ const expected = await this.crypto.hmacSha256Hex(operator.apiSecret, (0, signature_ts_1.canonicalRequestPayload)({
130
+ timestamp,
131
+ method: req.method,
132
+ path,
133
+ bodySha256Hex: await this.crypto.sha256Hex(req.body ?? ''),
134
+ }));
135
+ if (!(await this.crypto.timingSafeEqualHex(expected, signature))) {
136
+ throw new HttpError(401, 'Invalid signature');
137
+ }
138
+ return operator;
139
+ }
140
+ rateLimit(operator) {
141
+ const now = this.clock.nowMs();
142
+ if (now - operator.window.startMs >= 60_000)
143
+ operator.window = { startMs: now, count: 0 };
144
+ operator.window.count += 1;
145
+ if (operator.window.count > operator.rateLimitPerMin) {
146
+ throw new HttpError(429, `Rate limit of ${operator.rateLimitPerMin} req/min exceeded`);
147
+ }
148
+ }
149
+ requireActive(operator) {
150
+ if (operator.status !== 'active') {
151
+ throw new HttpError(403, `Operator is ${operator.status} — write operations require an active account`);
152
+ }
153
+ }
154
+ // ── routing (mirrors partner-api.controller.ts) ─────────────────────────
155
+ async route(op, method, url, rawBody) {
156
+ const path = url.pathname.replace(/^\/partner\/v1/, '');
157
+ const body = rawBody ? JSON.parse(rawBody) : {};
158
+ const merchantRoute = path.match(/^\/merchants\/([^/]+)\/(status|activate|suspend|reactivate)$/);
159
+ if (method === 'GET' && path === '/me')
160
+ return this.profile(op);
161
+ if (method === 'POST' && path === '/me/rotate-secret') {
162
+ this.requireActive(op);
163
+ op.apiSecret = (0, node_crypto_1.randomUUID)().replace(/-/g, '') + (0, node_crypto_1.randomUUID)().replace(/-/g, '');
164
+ return { apiSecret: op.apiSecret };
165
+ }
166
+ if (method === 'GET' && path === '/merchants')
167
+ return this.list(op, url.searchParams);
168
+ if (method === 'POST' && path === '/merchants') {
169
+ this.requireActive(op);
170
+ return this.create(op, body);
171
+ }
172
+ if (merchantRoute) {
173
+ const [, id, action] = merchantRoute;
174
+ const merchant = this.owned(op, decodeURIComponent(id));
175
+ if (method === 'GET' && action === 'status')
176
+ return this.statusView(merchant, true);
177
+ if (method === 'POST') {
178
+ this.requireActive(op);
179
+ if (action === 'activate')
180
+ return this.activate(op, merchant);
181
+ if (action === 'suspend') {
182
+ merchant.status = 'suspended';
183
+ if (merchant.runtime !== 'not_activated')
184
+ merchant.runtime = 'suspended';
185
+ this.emit(op, 'merchant.suspended', merchant);
186
+ return { storeId: merchant.id, status: 'suspended' };
187
+ }
188
+ if (action === 'reactivate') {
189
+ merchant.status = 'active';
190
+ if (merchant.runtime === 'suspended')
191
+ merchant.runtime = 'live';
192
+ this.emit(op, 'merchant.reactivated', merchant);
193
+ return { storeId: merchant.id, status: 'active' };
194
+ }
195
+ }
196
+ }
197
+ if (method === 'GET' && path === '/fleet/analytics')
198
+ return this.analytics(op);
199
+ if (path === '/webhooks' || path.startsWith('/webhooks/'))
200
+ return this.webhookRoute(op, method, path, body, url);
201
+ throw new HttpError(404, `Cannot ${method} ${url.pathname}`);
202
+ }
203
+ create(op, body) {
204
+ const unknownKey = Object.keys(body).find((k) => !CREATE_MERCHANT_KEYS.has(k));
205
+ if (unknownKey)
206
+ throw new HttpError(400, `property ${unknownKey} should not exist`);
207
+ const email = String(body.email ?? '');
208
+ const name = String(body.name ?? '');
209
+ const countries = Array.isArray(body.countries) ? body.countries.map(String) : [];
210
+ const externalId = body.externalId === undefined ? undefined : String(body.externalId);
211
+ const problems = [];
212
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
213
+ problems.push('email must be an email');
214
+ if (!name)
215
+ problems.push('name must be longer than or equal to 1 characters');
216
+ if (countries.length === 0)
217
+ problems.push('countries should not be empty');
218
+ if (externalId !== undefined && externalId.length > merchant_ts_1.MAX_EXTERNAL_ID_LENGTH) {
219
+ problems.push(`externalId must be shorter than or equal to ${merchant_ts_1.MAX_EXTERNAL_ID_LENGTH} characters`);
220
+ }
221
+ if (problems.length)
222
+ throw new HttpError(400, problems.join('; '));
223
+ // Idempotent replay BEFORE quota/region checks — mirrors the platform.
224
+ if (externalId) {
225
+ const existing = this.fleet(op).find((m) => m.externalId === externalId);
226
+ if (existing)
227
+ return publicMerchant(existing);
228
+ }
229
+ if (this.fleet(op).length >= op.maxStores) {
230
+ throw new HttpError(403, `Operator '${op.name}' has reached the store limit of ${op.maxStores} for plan '${op.plan}'`);
231
+ }
232
+ const forbidden = countries.filter((c) => !op.allowedRegions.includes(c));
233
+ if (forbidden.length) {
234
+ throw new HttpError(400, `Countries not allowed for this operator: ${forbidden.join(', ')}. Allowed: ${op.allowedRegions.join(', ')}`);
235
+ }
236
+ const merchant = {
237
+ id: (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 24),
238
+ name,
239
+ handle: this.allocateHandle(name),
240
+ status: 'active',
241
+ runtime: 'not_activated',
242
+ countries,
243
+ plan: 'basic',
244
+ domain: null,
245
+ ownerEmail: email,
246
+ externalId: externalId ?? null,
247
+ createdAt: new Date(this.clock.nowMs()).toISOString(),
248
+ operatorId: op.id,
249
+ pollsLeft: 0,
250
+ };
251
+ this.handles.add(merchant.handle);
252
+ this.merchants.set(merchant.id, merchant);
253
+ this.emit(op, 'merchant.created', merchant, { externalId: merchant.externalId });
254
+ return publicMerchant(merchant);
255
+ }
256
+ /**
257
+ * Mirrors `StoreService.allocateHandle`: the readable slug of `name` first,
258
+ * a random `-xxxx` suffix if it (or a seeded `takenHandles` entry) is
259
+ * already used. The platform assigns this — a partner never sends one.
260
+ */
261
+ allocateHandle(name) {
262
+ const base = slugify(name) || 'tienda';
263
+ if (!this.handles.has(base))
264
+ return base;
265
+ for (let attempt = 0; attempt < 8; attempt++) {
266
+ const suffix = (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 4);
267
+ const candidate = `${base.slice(0, 34)}-${suffix}`;
268
+ if (!this.handles.has(candidate))
269
+ return candidate;
270
+ }
271
+ throw new HttpError(500, `Could not allocate a free handle for "${name}"`);
272
+ }
273
+ activate(op, m) {
274
+ if (m.runtime === 'creating' || m.runtime === 'live' || m.runtime === 'suspended') {
275
+ throw new HttpError(409, m.runtime === 'suspended'
276
+ ? `Merchant '${m.handle}' is suspended — use reactivate instead`
277
+ : `Merchant '${m.handle}' is already ${m.runtime}`);
278
+ }
279
+ switch (this.activation) {
280
+ case 'fail':
281
+ m.runtime = 'failed';
282
+ this.emit(op, 'merchant.failed', m, { reason: this.failureReason });
283
+ throw new HttpError(500, this.failureReason);
284
+ case 'slow':
285
+ m.runtime = 'creating';
286
+ m.pollsLeft = this.pollsUntilLive;
287
+ return this.statusView(m, false);
288
+ case 'drop':
289
+ m.runtime = 'creating';
290
+ m.pollsLeft = this.pollsUntilLive;
291
+ throw new errors_ts_1.VivoaNetworkError('POST activate: socket hang up');
292
+ default:
293
+ this.goLive(m);
294
+ return this.statusView(m, false);
295
+ }
296
+ }
297
+ goLive(m) {
298
+ m.runtime = 'live';
299
+ m.domain = `${m.handle}.vivoa.store`;
300
+ const op = [...this.operators.values()].find((o) => o.id === m.operatorId);
301
+ if (op)
302
+ this.emit(op, 'merchant.live', m);
303
+ }
304
+ statusView(m, isPoll) {
305
+ if (isPoll && m.runtime === 'creating') {
306
+ if (m.pollsLeft <= 0)
307
+ this.goLive(m);
308
+ else
309
+ m.pollsLeft -= 1;
310
+ }
311
+ return {
312
+ storeId: m.id,
313
+ handle: m.handle,
314
+ status: m.status,
315
+ deployStatus: m.runtime,
316
+ domain: m.domain,
317
+ apiBaseUrl: m.runtime === 'live' ? 'https://shared-api.vivoa.app' : null,
318
+ };
319
+ }
320
+ list(op, qs) {
321
+ const page = Math.max(1, Number(qs.get('page')) || 1);
322
+ const limit = Math.min(Math.max(1, Number(qs.get('limit')) || 20), 100);
323
+ const search = qs.get('search')?.toLowerCase();
324
+ const externalId = qs.get('externalId') ?? undefined;
325
+ const rows = this.fleet(op)
326
+ .filter((m) => !search || m.name.toLowerCase().includes(search) || m.handle.includes(search))
327
+ .filter((m) => !externalId || m.externalId === externalId)
328
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
329
+ return {
330
+ data: rows.slice((page - 1) * limit, page * limit).map(publicMerchant),
331
+ meta: { total: rows.length, page, limit },
332
+ };
333
+ }
334
+ analytics(op) {
335
+ const fleet = this.fleet(op);
336
+ const byStatus = {};
337
+ for (const m of fleet)
338
+ byStatus[m.status] = (byStatus[m.status] ?? 0) + 1;
339
+ return {
340
+ operatorId: op.id,
341
+ stores: { total: fleet.length, active: byStatus.active ?? 0, suspended: byStatus.suspended ?? 0, byStatus },
342
+ revenue: { grossVolume: 0, currency: 'USD', pendingAmount: 0, completedPayments: 0, avgTicket: 0 },
343
+ };
344
+ }
345
+ async webhookRoute(op, method, path, body, url) {
346
+ if (method === 'GET' && path === '/webhooks')
347
+ return webhookView(op);
348
+ if (method === 'GET' && path === '/webhooks/deliveries') {
349
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 25, 100);
350
+ return op.deliveries.slice(0, limit);
351
+ }
352
+ this.requireActive(op);
353
+ if (method === 'PUT' && path === '/webhooks') {
354
+ const target = String(body.url ?? '');
355
+ if (!/^https?:\/\//.test(target))
356
+ throw new HttpError(400, 'url must be a URL address');
357
+ const events = Array.isArray(body.events) ? body.events.map(String) : [];
358
+ const unknown = events.filter((e) => !webhook_ts_1.MERCHANT_EVENT_TYPES.includes(e));
359
+ if (unknown.length)
360
+ throw new HttpError(400, `each value in events must be one of the following values: ${webhook_ts_1.MERCHANT_EVENT_TYPES.join(', ')}`);
361
+ const isNew = !op.webhook;
362
+ const secret = op.webhook?.secret ?? `whsec_${(0, node_crypto_1.randomUUID)().replace(/-/g, '')}`;
363
+ op.webhook = { url: target, events, secret, updatedAt: new Date(this.clock.nowMs()).toISOString() };
364
+ return { webhook: webhookView(op), signingSecret: isNew ? secret : null };
365
+ }
366
+ if (method === 'DELETE' && path === '/webhooks') {
367
+ op.webhook = null;
368
+ return webhookView(op);
369
+ }
370
+ if (!op.webhook)
371
+ throw new HttpError(404, 'No webhook configured for this operator');
372
+ if (method === 'POST' && path === '/webhooks/rotate-secret') {
373
+ op.webhook.secret = `whsec_${(0, node_crypto_1.randomUUID)().replace(/-/g, '')}`;
374
+ return { signingSecret: op.webhook.secret };
375
+ }
376
+ if (method === 'POST' && path === '/webhooks/test') {
377
+ return this.deliver(op, 'platform.test', { message: 'Test event from Vivoa', operatorId: op.id });
378
+ }
379
+ throw new HttpError(404, `Cannot ${method} /partner/v1${path}`);
380
+ }
381
+ // ── events → signed webhook ─────────────────────────────────────────────
382
+ emit(op, event, m, extra = {}) {
383
+ if (!op.webhook)
384
+ return;
385
+ if (op.webhook.events.length > 0 && !op.webhook.events.includes(event))
386
+ return;
387
+ void this.deliver(op, event, {
388
+ merchantId: m.id,
389
+ handle: m.handle,
390
+ operatorId: op.id,
391
+ occurredAt: new Date(this.clock.nowMs()).toISOString(),
392
+ ...extra,
393
+ });
394
+ }
395
+ async deliver(op, event, data) {
396
+ const hook = op.webhook;
397
+ const body = JSON.stringify({ event, data, sentAt: new Date(this.clock.nowMs()).toISOString() });
398
+ const signature = await this.crypto.hmacSha256Hex(hook.secret, body);
399
+ this.onWebhook?.({
400
+ url: hook.url,
401
+ headers: { 'content-type': 'application/json', 'x-vivoa-event': event, 'x-vivoa-signature': `sha256=${signature}` },
402
+ body,
403
+ });
404
+ const delivery = {
405
+ id: (0, node_crypto_1.randomUUID)(),
406
+ event,
407
+ statusCode: 200,
408
+ success: true,
409
+ error: null,
410
+ durationMs: 1,
411
+ attempt: 1,
412
+ createdAt: new Date(this.clock.nowMs()).toISOString(),
413
+ };
414
+ op.deliveries.unshift(delivery);
415
+ return delivery;
416
+ }
417
+ // ── helpers ─────────────────────────────────────────────────────────────
418
+ fleet(op) {
419
+ return [...this.merchants.values()].filter((m) => m.operatorId === op.id);
420
+ }
421
+ owned(op, id) {
422
+ const m = this.merchants.get(id);
423
+ if (!m || m.operatorId !== op.id)
424
+ throw new HttpError(404, `Merchant store '${id}' not found for this operator`);
425
+ return m;
426
+ }
427
+ profile(op) {
428
+ return {
429
+ id: op.id,
430
+ name: op.name,
431
+ status: op.status,
432
+ plan: op.plan,
433
+ storesUsed: this.fleet(op).length,
434
+ maxStores: op.maxStores,
435
+ allowedRegions: [...op.allowedRegions],
436
+ rateLimitPerMin: op.rateLimitPerMin,
437
+ };
438
+ }
439
+ requireOperatorByKey(apiKey) {
440
+ const op = this.operators.get(apiKey);
441
+ if (!op)
442
+ throw new Error(`FakeVivoa: unknown operator key ${apiKey}`);
443
+ return op;
444
+ }
445
+ }
446
+ exports.FakeVivoa = FakeVivoa;
447
+ function publicMerchant(m) {
448
+ const { operatorId: _op, pollsLeft: _polls, ...view } = m;
449
+ return { ...view, countries: [...view.countries] };
450
+ }
451
+ function webhookView(op) {
452
+ return {
453
+ configured: !!op.webhook,
454
+ url: op.webhook?.url ?? null,
455
+ events: op.webhook ? [...op.webhook.events] : [],
456
+ active: !!op.webhook,
457
+ updatedAt: op.webhook?.updatedAt ?? null,
458
+ };
459
+ }
460
+ function json(status, body) {
461
+ return { status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
462
+ }
463
+ function lower(headers) {
464
+ const out = {};
465
+ for (const [k, v] of Object.entries(headers))
466
+ out[k.toLowerCase()] = v;
467
+ return out;
468
+ }
@@ -0,0 +1,9 @@
1
+ import type { Clock } from '../ports/clock.port.ts';
2
+ /** Deterministic clock: `sleep` advances time instantly. For tests and the fake. */
3
+ export declare class ManualClock implements Clock {
4
+ private current;
5
+ constructor(startMs?: number);
6
+ nowMs(): number;
7
+ sleep(ms: number): Promise<void>;
8
+ advance(ms: number): void;
9
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ManualClock = void 0;
4
+ /** Deterministic clock: `sleep` advances time instantly. For tests and the fake. */
5
+ class ManualClock {
6
+ current;
7
+ constructor(startMs = Date.UTC(2026, 0, 1)) {
8
+ this.current = startMs;
9
+ }
10
+ nowMs() {
11
+ return this.current;
12
+ }
13
+ async sleep(ms) {
14
+ this.current += ms;
15
+ }
16
+ advance(ms) {
17
+ this.current += ms;
18
+ }
19
+ }
20
+ exports.ManualClock = ManualClock;
@@ -0,0 +1,2 @@
1
+ import type { CryptoProvider } from '../ports/crypto.port.ts';
2
+ export declare const nodeCrypto: CryptoProvider;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nodeCrypto = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ exports.nodeCrypto = {
6
+ sha256Hex: (data) => (0, node_crypto_1.createHash)('sha256').update(data).digest('hex'),
7
+ hmacSha256Hex: (secret, data) => (0, node_crypto_1.createHmac)('sha256', secret).update(data).digest('hex'),
8
+ timingSafeEqualHex: (a, b) => {
9
+ const x = Buffer.from(a, 'hex');
10
+ const y = Buffer.from(b, 'hex');
11
+ return x.length > 0 && x.length === y.length && (0, node_crypto_1.timingSafeEqual)(x, y);
12
+ },
13
+ };
@@ -0,0 +1,2 @@
1
+ import type { Clock } from '../ports/clock.port.ts';
2
+ export declare const systemClock: Clock;
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.systemClock = void 0;
4
+ exports.systemClock = {
5
+ nowMs: () => Date.now(),
6
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
7
+ };
@@ -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,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VivoaPartnerClient = exports.SDK_VERSION = void 0;
4
+ const logger_port_ts_1 = require("../ports/logger.port.js");
5
+ const signed_http_ts_1 = require("./signed-http.js");
6
+ const me_ts_1 = require("./resources/me.js");
7
+ const merchants_ts_1 = require("./resources/merchants.js");
8
+ const webhooks_ts_1 = require("./resources/webhooks.js");
9
+ const fleet_ts_1 = require("./resources/fleet.js");
10
+ const provision_store_ts_1 = require("./use-cases/provision-store.js");
11
+ const verify_webhook_ts_1 = require("../webhooks/verify-webhook.js");
12
+ exports.SDK_VERSION = '0.1.0';
13
+ class VivoaPartnerClient {
14
+ me;
15
+ merchants;
16
+ webhooks;
17
+ fleet;
18
+ provisioning;
19
+ crypto;
20
+ constructor(deps) {
21
+ const http = new signed_http_ts_1.SignedHttpClient({
22
+ baseUrl: deps.baseUrl,
23
+ apiKey: deps.apiKey,
24
+ apiSecret: deps.apiSecret,
25
+ transport: deps.transport,
26
+ crypto: deps.crypto,
27
+ clock: deps.clock,
28
+ logger: deps.logger ?? logger_port_ts_1.silentLogger,
29
+ timeoutMs: deps.timeoutMs ?? 30_000,
30
+ maxRetries: deps.maxRetries ?? 2,
31
+ userAgent: `vivoa-partner-sdk/${exports.SDK_VERSION}`,
32
+ });
33
+ this.crypto = deps.crypto;
34
+ this.me = new me_ts_1.MeResource(http);
35
+ this.merchants = new merchants_ts_1.MerchantsResource(http);
36
+ this.webhooks = new webhooks_ts_1.WebhooksResource(http);
37
+ this.fleet = new fleet_ts_1.FleetResource(http);
38
+ this.provisioning = new provision_store_ts_1.ProvisionStoreUseCase(this.merchants, deps.clock);
39
+ }
40
+ /**
41
+ * Ensure a merchant exists for `input.externalId`, activate it and wait until
42
+ * it is live. Safe to call again with the same externalId — it resumes.
43
+ */
44
+ provisionStore(input, options) {
45
+ return this.provisioning.execute(input, options);
46
+ }
47
+ waitUntilSettled(merchantId, options) {
48
+ return this.provisioning.waitUntilSettled(merchantId, options);
49
+ }
50
+ /** Verify + parse an incoming webhook with this client's crypto adapter. */
51
+ verifyWebhook(params) {
52
+ return (0, verify_webhook_ts_1.verifyWebhook)(this.crypto, params);
53
+ }
54
+ }
55
+ exports.VivoaPartnerClient = VivoaPartnerClient;
@@ -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
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FleetResource = void 0;
4
+ class FleetResource {
5
+ http;
6
+ constructor(http) {
7
+ this.http = http;
8
+ }
9
+ analytics() {
10
+ return this.http.request('GET', '/partner/v1/fleet/analytics');
11
+ }
12
+ }
13
+ exports.FleetResource = FleetResource;
@@ -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
+ }