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