@clovnet/casino-sdk 1.0.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/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/client-B3-Y0Xan.d.cts +2167 -0
- package/dist/client-BxHPrkxp.d.ts +2167 -0
- package/dist/contract-DtFe4bRy.d.cts +205 -0
- package/dist/contract-DtFe4bRy.d.ts +205 -0
- package/dist/index.cjs +2337 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +56 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +2298 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +382 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +118 -0
- package/dist/react/index.d.ts +118 -0
- package/dist/react/index.js +370 -0
- package/dist/react/index.js.map +1 -0
- package/dist/realtime/index.cjs +407 -0
- package/dist/realtime/index.cjs.map +1 -0
- package/dist/realtime/index.d.cts +6 -0
- package/dist/realtime/index.d.ts +6 -0
- package/dist/realtime/index.js +403 -0
- package/dist/realtime/index.js.map +1 -0
- package/package.json +111 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2298 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
var COOKIE = {
|
|
3
|
+
access: "cwe_access_token",
|
|
4
|
+
refresh: "cwe_refresh_token",
|
|
5
|
+
csrf: "cwe_csrf"
|
|
6
|
+
};
|
|
7
|
+
var HEADER = {
|
|
8
|
+
tenant: "x-tenant-id",
|
|
9
|
+
brand: "x-brand-id",
|
|
10
|
+
region: "x-region",
|
|
11
|
+
csrf: "x-csrf-token",
|
|
12
|
+
idempotency: "idempotency-key"
|
|
13
|
+
};
|
|
14
|
+
function resolveConfig(config) {
|
|
15
|
+
if (!config.baseUrl) throw new Error("createCasinoClient: `baseUrl` is required");
|
|
16
|
+
if (!config.tenantId) throw new Error("createCasinoClient: `tenantId` is required");
|
|
17
|
+
const fetchImpl = config.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
18
|
+
if (!fetchImpl) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"createCasinoClient: no global `fetch` found; pass `fetch` in config (Node < 18)."
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
baseUrl: stripTrailingSlash(config.baseUrl),
|
|
25
|
+
wsUrl: config.wsUrl ?? deriveWsUrl(config.baseUrl),
|
|
26
|
+
tenantId: config.tenantId,
|
|
27
|
+
brandId: config.brandId,
|
|
28
|
+
region: config.region,
|
|
29
|
+
defaultCurrency: (config.defaultCurrency ?? "EUR").toUpperCase(),
|
|
30
|
+
fetch: fetchImpl,
|
|
31
|
+
onAuthError: config.onAuthError,
|
|
32
|
+
autoRefresh: config.autoRefresh ?? true,
|
|
33
|
+
cookieJar: config.cookieJar,
|
|
34
|
+
timeoutMs: config.timeoutMs ?? 3e4
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function stripTrailingSlash(url) {
|
|
38
|
+
return url.replace(/\/+$/, "");
|
|
39
|
+
}
|
|
40
|
+
function deriveWsUrl(baseUrl) {
|
|
41
|
+
return `${stripTrailingSlash(baseUrl).replace(/^http/, "ws")}/realtime`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/core/cookies.ts
|
|
45
|
+
var isBrowser = () => typeof document !== "undefined" && typeof document.cookie === "string";
|
|
46
|
+
var CookieJar = class {
|
|
47
|
+
store = /* @__PURE__ */ new Map();
|
|
48
|
+
/** Read a cookie value by name, or undefined. */
|
|
49
|
+
get(name) {
|
|
50
|
+
return this.store.get(name);
|
|
51
|
+
}
|
|
52
|
+
/** Serialize all cookies into a `Cookie` request-header value. */
|
|
53
|
+
header() {
|
|
54
|
+
if (this.store.size === 0) return void 0;
|
|
55
|
+
return [...this.store.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
56
|
+
}
|
|
57
|
+
/** Clear the jar (e.g. on logout). */
|
|
58
|
+
clear() {
|
|
59
|
+
this.store.clear();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Ingest `set-cookie` header(s) from a response. Only the `name=value` pair is
|
|
63
|
+
* retained (attributes like Path/HttpOnly are ignored — this jar is a dev/SSR
|
|
64
|
+
* convenience, not a spec-complete cookie store). A `Max-Age<=0` or an `Expires`
|
|
65
|
+
* in the past deletes the cookie; `Max-Age` wins when both are present.
|
|
66
|
+
*/
|
|
67
|
+
ingest(setCookie) {
|
|
68
|
+
if (!setCookie) return;
|
|
69
|
+
const lines = Array.isArray(setCookie) ? setCookie : splitSetCookie(setCookie);
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const [pair, ...attrs] = line.split(";");
|
|
72
|
+
if (!pair) continue;
|
|
73
|
+
const eq = pair.indexOf("=");
|
|
74
|
+
if (eq < 0) continue;
|
|
75
|
+
const name = pair.slice(0, eq).trim();
|
|
76
|
+
const value = pair.slice(eq + 1).trim();
|
|
77
|
+
if (!name) continue;
|
|
78
|
+
if (isExpired(attrs)) this.store.delete(name);
|
|
79
|
+
else this.store.set(name, value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
function isExpired(attrs) {
|
|
84
|
+
let expires;
|
|
85
|
+
for (const attr of attrs) {
|
|
86
|
+
const eq = attr.indexOf("=");
|
|
87
|
+
const key = (eq === -1 ? attr : attr.slice(0, eq)).trim().toLowerCase();
|
|
88
|
+
const value = eq === -1 ? void 0 : attr.slice(eq + 1).trim();
|
|
89
|
+
if (key === "max-age" && value !== void 0) return Number(value) <= 0;
|
|
90
|
+
if (key === "expires" && value !== void 0) expires = value;
|
|
91
|
+
}
|
|
92
|
+
if (expires !== void 0) {
|
|
93
|
+
const time = Date.parse(expires);
|
|
94
|
+
if (!Number.isNaN(time)) return time <= Date.now();
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
function splitSetCookie(header) {
|
|
99
|
+
const out = [];
|
|
100
|
+
let start = 0;
|
|
101
|
+
for (let i = 0; i < header.length; i++) {
|
|
102
|
+
if (header[i] === ",") {
|
|
103
|
+
const ahead = header.slice(i + 1, i + 12).toLowerCase();
|
|
104
|
+
const before = header.slice(start, i);
|
|
105
|
+
if (/expires=\w{3}$/i.test(before.trim()) || /^\s*\d{1,2}-\w{3}/.test(ahead)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
out.push(header.slice(start, i).trim());
|
|
109
|
+
start = i + 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
out.push(header.slice(start).trim());
|
|
113
|
+
return out.filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
function readCookie(name, jar) {
|
|
116
|
+
if (isBrowser()) {
|
|
117
|
+
const match = document.cookie.split(";").map((c) => c.trim()).find((c) => c.startsWith(`${name}=`));
|
|
118
|
+
if (match) return decodeURIComponent(match.slice(name.length + 1));
|
|
119
|
+
}
|
|
120
|
+
return jar?.get(name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/core/errors.ts
|
|
124
|
+
function isAppErrorEnvelope(value) {
|
|
125
|
+
if (typeof value !== "object" || value === null) return false;
|
|
126
|
+
const err = value.error;
|
|
127
|
+
return typeof err === "object" && err !== null && typeof err.code === "string" && typeof err.message === "string";
|
|
128
|
+
}
|
|
129
|
+
var CasinoSdkError = class extends Error {
|
|
130
|
+
code;
|
|
131
|
+
status;
|
|
132
|
+
details;
|
|
133
|
+
constructor(code, message, status = 0, details) {
|
|
134
|
+
super(message);
|
|
135
|
+
this.name = new.target.name;
|
|
136
|
+
this.code = code;
|
|
137
|
+
this.status = status;
|
|
138
|
+
if (details !== void 0) this.details = details;
|
|
139
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
var AuthError = class extends CasinoSdkError {
|
|
143
|
+
};
|
|
144
|
+
var ForbiddenError = class extends CasinoSdkError {
|
|
145
|
+
};
|
|
146
|
+
var ValidationError = class extends CasinoSdkError {
|
|
147
|
+
};
|
|
148
|
+
var NotFoundError = class extends CasinoSdkError {
|
|
149
|
+
};
|
|
150
|
+
var ConflictError = class extends CasinoSdkError {
|
|
151
|
+
};
|
|
152
|
+
var InsufficientFundsError = class extends CasinoSdkError {
|
|
153
|
+
};
|
|
154
|
+
var UnprocessableError = class extends CasinoSdkError {
|
|
155
|
+
};
|
|
156
|
+
var OperationDeniedError = class extends CasinoSdkError {
|
|
157
|
+
};
|
|
158
|
+
var RateLimitError = class extends CasinoSdkError {
|
|
159
|
+
};
|
|
160
|
+
var FlowDelegatedError = class extends ConflictError {
|
|
161
|
+
/** The plugin that owns the delegated flow (from `details.pluginKey`). */
|
|
162
|
+
pluginKey;
|
|
163
|
+
/** The delegated flow kind (from `details.flow`), e.g. `"deposit"`. */
|
|
164
|
+
flow;
|
|
165
|
+
/**
|
|
166
|
+
* Set (best-effort, by the SDK) when the ext catalog shows an action named
|
|
167
|
+
* after the flow — `sdk.ext(pluginKey).call(continueWith.actionKey, …)` is
|
|
168
|
+
* then the way to continue. Left `undefined` when the catalog lookup fails;
|
|
169
|
+
* the original error is never masked.
|
|
170
|
+
*/
|
|
171
|
+
continueWith;
|
|
172
|
+
constructor(code, message, status = 0, details) {
|
|
173
|
+
super(code, message, status, details);
|
|
174
|
+
const d = typeof details === "object" && details !== null ? details : {};
|
|
175
|
+
this.pluginKey = typeof d.pluginKey === "string" ? d.pluginKey : "";
|
|
176
|
+
this.flow = typeof d.flow === "string" ? d.flow : void 0;
|
|
177
|
+
}
|
|
178
|
+
/** Sugar for `sdk.ext(this.pluginKey)` — the plugin client to continue the flow with. */
|
|
179
|
+
resolve(sdk) {
|
|
180
|
+
if (!this.pluginKey) {
|
|
181
|
+
throw new Error("FlowDelegatedError.resolve(): the error carried no details.pluginKey.");
|
|
182
|
+
}
|
|
183
|
+
return sdk.ext(this.pluginKey);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
var RgBlockedError = class extends CasinoSdkError {
|
|
187
|
+
};
|
|
188
|
+
var LimitExceededError = class extends CasinoSdkError {
|
|
189
|
+
};
|
|
190
|
+
var ServerError = class extends CasinoSdkError {
|
|
191
|
+
};
|
|
192
|
+
var NetworkError = class extends CasinoSdkError {
|
|
193
|
+
};
|
|
194
|
+
var NotImplementedError = class extends CasinoSdkError {
|
|
195
|
+
constructor(message) {
|
|
196
|
+
super("NOT_IMPLEMENTED", message, 0);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var PluginNotEnabledError = class extends NotFoundError {
|
|
200
|
+
pluginKey;
|
|
201
|
+
constructor(pluginKey, options) {
|
|
202
|
+
super(
|
|
203
|
+
options?.code ?? "PLUGIN_NOT_ENABLED",
|
|
204
|
+
options?.message ?? `Plugin "${pluginKey}" is not enabled for this tenant.`,
|
|
205
|
+
options?.status ?? 0,
|
|
206
|
+
options?.details
|
|
207
|
+
);
|
|
208
|
+
this.pluginKey = pluginKey;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
var PluginActionNotFoundError = class extends NotFoundError {
|
|
212
|
+
pluginKey;
|
|
213
|
+
actionKey;
|
|
214
|
+
constructor(pluginKey, actionKey) {
|
|
215
|
+
super(
|
|
216
|
+
"PLUGIN_ACTION_NOT_FOUND",
|
|
217
|
+
`Plugin "${pluginKey}" declares no action "${actionKey}" in its catalog.`,
|
|
218
|
+
0
|
|
219
|
+
);
|
|
220
|
+
this.pluginKey = pluginKey;
|
|
221
|
+
this.actionKey = actionKey;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
var PluginUnavailableError = class extends ServerError {
|
|
225
|
+
};
|
|
226
|
+
var PluginVersionMismatchError = class extends ConflictError {
|
|
227
|
+
pluginKey;
|
|
228
|
+
expectedRange;
|
|
229
|
+
actualVersion;
|
|
230
|
+
constructor(pluginKey, expectedRange, actualVersion) {
|
|
231
|
+
super(
|
|
232
|
+
"PLUGIN_VERSION_MISMATCH",
|
|
233
|
+
`Plugin "${pluginKey}" is installed at ${actualVersion}, but this client expects ${expectedRange}.`,
|
|
234
|
+
0,
|
|
235
|
+
{ pluginKey, expectedRange, actualVersion }
|
|
236
|
+
);
|
|
237
|
+
this.pluginKey = pluginKey;
|
|
238
|
+
this.expectedRange = expectedRange;
|
|
239
|
+
this.actualVersion = actualVersion;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
var CODE_TO_CLASS = {
|
|
243
|
+
VALIDATION_ERROR: ValidationError,
|
|
244
|
+
TENANT_CONTEXT_ERROR: ValidationError,
|
|
245
|
+
UNAUTHORIZED: AuthError,
|
|
246
|
+
REALTIME_UNAUTHORIZED: AuthError,
|
|
247
|
+
REALTIME_AUTH: AuthError,
|
|
248
|
+
FORBIDDEN: ForbiddenError,
|
|
249
|
+
NOT_FOUND: NotFoundError,
|
|
250
|
+
CONFLICT: ConflictError,
|
|
251
|
+
INVALID_STATE_TRANSITION: ConflictError,
|
|
252
|
+
INVALID_WITHDRAWAL_STATE: ConflictError,
|
|
253
|
+
INSUFFICIENT_FUNDS: InsufficientFundsError,
|
|
254
|
+
DEPOSIT_DENIED: OperationDeniedError,
|
|
255
|
+
WITHDRAWAL_DENIED: OperationDeniedError,
|
|
256
|
+
DEPOSIT_LIMIT_EXCEEDED: OperationDeniedError,
|
|
257
|
+
WITHDRAWAL_LIMIT_EXCEEDED: OperationDeniedError,
|
|
258
|
+
RATE_LIMITED: RateLimitError,
|
|
259
|
+
FLOW_DELEGATED: FlowDelegatedError,
|
|
260
|
+
// Plugin handler timed out / crashed (plugin actions & ext routes).
|
|
261
|
+
PLUGIN_ROUTE_ERROR: PluginUnavailableError,
|
|
262
|
+
PLUGIN_ROUTE_TIMEOUT: PluginUnavailableError,
|
|
263
|
+
RG_BLOCKED: RgBlockedError,
|
|
264
|
+
LIMIT_EXCEEDED: LimitExceededError,
|
|
265
|
+
// 422 — a KYC document upload was rejected (bad format/size/expiry). Mapped to
|
|
266
|
+
// ValidationError (not the status-422 InsufficientFundsError fallback).
|
|
267
|
+
KYC_DOCUMENT_INVALID: ValidationError,
|
|
268
|
+
// 409 conflict-family domain codes.
|
|
269
|
+
KYC_REQUEST_STATE: ConflictError,
|
|
270
|
+
PROFILE_FIELD_LOCKED: ConflictError,
|
|
271
|
+
BALANCE_REMAINING: ConflictError,
|
|
272
|
+
WITHDRAWAL_PENDING: ConflictError
|
|
273
|
+
};
|
|
274
|
+
function mapEnvelope(status, body) {
|
|
275
|
+
if (isAppErrorEnvelope(body)) {
|
|
276
|
+
const { code, message, details } = body.error;
|
|
277
|
+
const ByCode = CODE_TO_CLASS[code];
|
|
278
|
+
if (ByCode) return new ByCode(code, message, status, details);
|
|
279
|
+
return mapByStatus(status, code, message, details);
|
|
280
|
+
}
|
|
281
|
+
return mapByStatus(status, statusCodeName(status), `HTTP ${status}`, body);
|
|
282
|
+
}
|
|
283
|
+
function mapByStatus(status, code, message, details) {
|
|
284
|
+
if (status === 401) return new AuthError(code, message, status, details);
|
|
285
|
+
if (status === 403) return new ForbiddenError(code, message, status, details);
|
|
286
|
+
if (status === 404) return new NotFoundError(code, message, status, details);
|
|
287
|
+
if (status === 409) return new ConflictError(code, message, status, details);
|
|
288
|
+
if (status === 422) return new UnprocessableError(code, message, status, details);
|
|
289
|
+
if (status === 429) return new RateLimitError(code, message, status, details);
|
|
290
|
+
if (status >= 500) return new ServerError(code, message, status, details);
|
|
291
|
+
return new CasinoSdkError(code, message, status, details);
|
|
292
|
+
}
|
|
293
|
+
function statusCodeName(status) {
|
|
294
|
+
if (status === 400) return "VALIDATION_ERROR";
|
|
295
|
+
if (status === 401) return "UNAUTHORIZED";
|
|
296
|
+
if (status === 403) return "FORBIDDEN";
|
|
297
|
+
if (status === 404) return "NOT_FOUND";
|
|
298
|
+
if (status === 409) return "CONFLICT";
|
|
299
|
+
if (status === 422) return "UNPROCESSABLE";
|
|
300
|
+
if (status === 429) return "RATE_LIMITED";
|
|
301
|
+
if (status >= 500) return "INTERNAL_ERROR";
|
|
302
|
+
return "REQUEST_ERROR";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/core/idempotency.ts
|
|
306
|
+
function generateIdempotencyKey(prefix = "idem") {
|
|
307
|
+
const uuid = randomUuid();
|
|
308
|
+
return `${prefix}-${uuid}`;
|
|
309
|
+
}
|
|
310
|
+
function randomUuid() {
|
|
311
|
+
const c = globalThis.crypto;
|
|
312
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
313
|
+
if (c?.getRandomValues) {
|
|
314
|
+
const bytes = new Uint8Array(16);
|
|
315
|
+
c.getRandomValues(bytes);
|
|
316
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
317
|
+
}
|
|
318
|
+
let out = "";
|
|
319
|
+
for (let i = 0; i < 32; i++) out += Math.floor(Math.random() * 16).toString(16);
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/core/http.ts
|
|
324
|
+
var HttpClient = class {
|
|
325
|
+
constructor(config) {
|
|
326
|
+
this.config = config;
|
|
327
|
+
this.jar = config.cookieJar ?? new CookieJar();
|
|
328
|
+
}
|
|
329
|
+
config;
|
|
330
|
+
jar;
|
|
331
|
+
refresh = null;
|
|
332
|
+
refreshInflight = null;
|
|
333
|
+
enrich = null;
|
|
334
|
+
/** Wired by the client after the auth module exists (avoids a circular import). */
|
|
335
|
+
setRefreshHandler(fn) {
|
|
336
|
+
this.refresh = fn;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Wired by the client: a best-effort pass over every decoded error before it is
|
|
340
|
+
* thrown (e.g. populating `FlowDelegatedError.continueWith` from the ext
|
|
341
|
+
* catalog). Whatever it does or throws, the original error is what propagates.
|
|
342
|
+
*/
|
|
343
|
+
setErrorEnricher(fn) {
|
|
344
|
+
this.enrich = fn;
|
|
345
|
+
}
|
|
346
|
+
get(path, options) {
|
|
347
|
+
return this.request("GET", path, options);
|
|
348
|
+
}
|
|
349
|
+
post(path, options) {
|
|
350
|
+
return this.request("POST", path, options);
|
|
351
|
+
}
|
|
352
|
+
put(path, options) {
|
|
353
|
+
return this.request("PUT", path, options);
|
|
354
|
+
}
|
|
355
|
+
patch(path, options) {
|
|
356
|
+
return this.request("PATCH", path, options);
|
|
357
|
+
}
|
|
358
|
+
delete(path, options) {
|
|
359
|
+
return this.request("DELETE", path, options);
|
|
360
|
+
}
|
|
361
|
+
async request(method, path, options = {}) {
|
|
362
|
+
const response = await this.performFetch(method, path, options);
|
|
363
|
+
if (!response.ok) {
|
|
364
|
+
const err = await this.toError(response, options);
|
|
365
|
+
if (response.status === 401 && !options._noRefresh) this.config.onAuthError?.(err);
|
|
366
|
+
throw err;
|
|
367
|
+
}
|
|
368
|
+
return this.parseBody(response);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* GET with ETag revalidation: sends `If-None-Match` when `etag` is given and
|
|
372
|
+
* treats `304 Not Modified` as success (empty `data`). Used for endpoints that
|
|
373
|
+
* declare HTTP caching, e.g. the ext plugin catalog.
|
|
374
|
+
*/
|
|
375
|
+
async conditionalGet(path, options = {}) {
|
|
376
|
+
const requestOptions = {
|
|
377
|
+
...options.etag !== void 0 ? { headers: { "if-none-match": options.etag } } : {},
|
|
378
|
+
...options.signal ? { signal: options.signal } : {},
|
|
379
|
+
...options._noEnrich !== void 0 ? { _noEnrich: options._noEnrich } : {}
|
|
380
|
+
};
|
|
381
|
+
const response = await this.performFetch("GET", path, requestOptions);
|
|
382
|
+
if (response.status === 304) {
|
|
383
|
+
return { notModified: true, ...readCacheMeta(response) };
|
|
384
|
+
}
|
|
385
|
+
if (!response.ok) {
|
|
386
|
+
const err = await this.toError(response, requestOptions);
|
|
387
|
+
if (response.status === 401) this.config.onAuthError?.(err);
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
const data = await this.parseBody(response);
|
|
391
|
+
return {
|
|
392
|
+
notModified: false,
|
|
393
|
+
...data !== void 0 ? { data } : {},
|
|
394
|
+
...readCacheMeta(response)
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** The shared pipeline: headers, CSRF, idempotency, cookies, fetch, 401→refresh→retry. */
|
|
398
|
+
async performFetch(method, path, options = {}) {
|
|
399
|
+
const url = this.buildUrl(path, options.query);
|
|
400
|
+
const isMutation = method !== "GET";
|
|
401
|
+
const headers = new Headers();
|
|
402
|
+
headers.set(HEADER.tenant, this.config.tenantId);
|
|
403
|
+
if (this.config.brandId) headers.set(HEADER.brand, this.config.brandId);
|
|
404
|
+
if (this.config.region) headers.set(HEADER.region, this.config.region);
|
|
405
|
+
headers.set("accept", "application/json");
|
|
406
|
+
const wantCsrf = options.csrf ?? isMutation;
|
|
407
|
+
if (wantCsrf) {
|
|
408
|
+
const token = readCookie(COOKIE.csrf, this.jar);
|
|
409
|
+
if (token) headers.set(HEADER.csrf, token);
|
|
410
|
+
}
|
|
411
|
+
let body = options.body;
|
|
412
|
+
if (options.idempotent) {
|
|
413
|
+
const key = options.idempotencyKey ?? generateIdempotencyKey();
|
|
414
|
+
headers.set(HEADER.idempotency, key);
|
|
415
|
+
if (!options.idempotencyHeaderOnly) {
|
|
416
|
+
if (isPlainObject(body)) body = { ...body, idempotencyKey: key };
|
|
417
|
+
else if (body === void 0) body = { idempotencyKey: key };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
let serialized;
|
|
421
|
+
if (isFormData(body)) {
|
|
422
|
+
serialized = body;
|
|
423
|
+
} else if (body !== void 0) {
|
|
424
|
+
headers.set("content-type", "application/json");
|
|
425
|
+
serialized = JSON.stringify(body);
|
|
426
|
+
}
|
|
427
|
+
if (options.headers) {
|
|
428
|
+
for (const [name, value] of Object.entries(options.headers)) headers.set(name, value);
|
|
429
|
+
}
|
|
430
|
+
if (!isBrowser()) {
|
|
431
|
+
const cookie = this.jar.header();
|
|
432
|
+
if (cookie) headers.set("cookie", cookie);
|
|
433
|
+
}
|
|
434
|
+
const timeoutMs = options.timeoutMs ?? this.config.timeoutMs;
|
|
435
|
+
const signal = withTimeout(options.signal, timeoutMs);
|
|
436
|
+
let response;
|
|
437
|
+
try {
|
|
438
|
+
const doFetch = this.config.fetch;
|
|
439
|
+
response = await doFetch(url, {
|
|
440
|
+
method,
|
|
441
|
+
headers,
|
|
442
|
+
credentials: "include",
|
|
443
|
+
...serialized !== void 0 ? { body: serialized } : {},
|
|
444
|
+
...signal ? { signal } : {}
|
|
445
|
+
});
|
|
446
|
+
} catch (cause) {
|
|
447
|
+
if (cause?.name === "TimeoutError") {
|
|
448
|
+
throw new NetworkError(
|
|
449
|
+
"NETWORK_TIMEOUT",
|
|
450
|
+
`Request to ${method} ${path} timed out after ${timeoutMs}ms`,
|
|
451
|
+
0,
|
|
452
|
+
cause
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
throw new NetworkError(
|
|
456
|
+
"NETWORK_ERROR",
|
|
457
|
+
`Request to ${method} ${path} failed: ${cause?.message ?? "unknown"}`,
|
|
458
|
+
0,
|
|
459
|
+
cause
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (!isBrowser()) {
|
|
463
|
+
const setCookie = readSetCookie(response.headers);
|
|
464
|
+
warnOnAuthIdentitySwitch(this.jar, setCookie);
|
|
465
|
+
this.jar.ingest(setCookie);
|
|
466
|
+
}
|
|
467
|
+
const refresh = this.refresh;
|
|
468
|
+
if (response.status === 401 && this.config.autoRefresh && refresh && !options._isRetry && !options._noRefresh) {
|
|
469
|
+
try {
|
|
470
|
+
await this.runRefresh(refresh);
|
|
471
|
+
return this.performFetch(method, path, { ...options, _isRetry: true });
|
|
472
|
+
} catch {
|
|
473
|
+
const err = await this.toError(response, options);
|
|
474
|
+
this.config.onAuthError?.(err);
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return response;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Run the wired refresh handler with single-flight dedupe (same pattern as the
|
|
482
|
+
* ext catalog): concurrent 401s all await ONE in-flight `POST /auth/player/refresh`.
|
|
483
|
+
* With rotating refresh tokens, parallel refreshes would present an already-rotated
|
|
484
|
+
* token and get the whole session revoked.
|
|
485
|
+
*/
|
|
486
|
+
runRefresh(refresh) {
|
|
487
|
+
if (this.refreshInflight) return this.refreshInflight;
|
|
488
|
+
const running = Promise.resolve().then(refresh).finally(() => {
|
|
489
|
+
if (this.refreshInflight === running) this.refreshInflight = null;
|
|
490
|
+
});
|
|
491
|
+
this.refreshInflight = running;
|
|
492
|
+
return running;
|
|
493
|
+
}
|
|
494
|
+
async parseBody(response) {
|
|
495
|
+
if (response.status === 204) return void 0;
|
|
496
|
+
const text = await response.text();
|
|
497
|
+
if (!text) return void 0;
|
|
498
|
+
try {
|
|
499
|
+
return JSON.parse(text);
|
|
500
|
+
} catch {
|
|
501
|
+
return text;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async toError(response, options) {
|
|
505
|
+
let parsed;
|
|
506
|
+
try {
|
|
507
|
+
parsed = JSON.parse(await response.text());
|
|
508
|
+
} catch {
|
|
509
|
+
parsed = void 0;
|
|
510
|
+
}
|
|
511
|
+
const err = mapEnvelope(response.status, parsed);
|
|
512
|
+
if (this.enrich && !options?._noEnrich) {
|
|
513
|
+
try {
|
|
514
|
+
await this.enrich(err);
|
|
515
|
+
} catch {
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return err;
|
|
519
|
+
}
|
|
520
|
+
buildUrl(path, query) {
|
|
521
|
+
const url = new URL(`${this.config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
522
|
+
if (query) {
|
|
523
|
+
for (const [key, value] of Object.entries(query)) {
|
|
524
|
+
if (value === void 0 || value === null) continue;
|
|
525
|
+
if (Array.isArray(value)) for (const v of value) url.searchParams.append(key, v);
|
|
526
|
+
else url.searchParams.set(key, String(value));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return url.toString();
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
function withTimeout(caller, timeoutMs) {
|
|
533
|
+
if (!timeoutMs || timeoutMs <= 0 || typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") {
|
|
534
|
+
return caller;
|
|
535
|
+
}
|
|
536
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
537
|
+
if (!caller) return timeout;
|
|
538
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any([caller, timeout]);
|
|
539
|
+
return caller;
|
|
540
|
+
}
|
|
541
|
+
function isPlainObject(value) {
|
|
542
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !isFormData(value);
|
|
543
|
+
}
|
|
544
|
+
function isFormData(value) {
|
|
545
|
+
return typeof FormData !== "undefined" && value instanceof FormData;
|
|
546
|
+
}
|
|
547
|
+
function readCacheMeta(response) {
|
|
548
|
+
const etag = response.headers.get("etag");
|
|
549
|
+
const cacheControl = response.headers.get("cache-control");
|
|
550
|
+
const match = cacheControl ? /(?:^|[,\s])max-age=(\d+)/i.exec(cacheControl) : null;
|
|
551
|
+
const maxAge = match?.[1] !== void 0 ? Number(match[1]) : void 0;
|
|
552
|
+
return {
|
|
553
|
+
...etag !== null ? { etag } : {},
|
|
554
|
+
...maxAge !== void 0 && Number.isFinite(maxAge) ? { maxAgeSeconds: maxAge } : {}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function warnOnAuthIdentitySwitch(jar, setCookie) {
|
|
558
|
+
if (typeof process !== "undefined" && process.env["NODE_ENV"] === "production") return;
|
|
559
|
+
if (!setCookie) return;
|
|
560
|
+
const previous = jar.get(COOKIE.access);
|
|
561
|
+
if (!previous) return;
|
|
562
|
+
const previousSub = jwtSub(previous);
|
|
563
|
+
if (previousSub === void 0) return;
|
|
564
|
+
const accessCookie = new RegExp(`(?:^|,\\s*)${COOKIE.access}=([^;,\\s]+)`);
|
|
565
|
+
const lines = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
566
|
+
for (const line of lines) {
|
|
567
|
+
const match = accessCookie.exec(line);
|
|
568
|
+
if (!match?.[1]) continue;
|
|
569
|
+
const incomingSub = jwtSub(match[1]);
|
|
570
|
+
if (incomingSub !== void 0 && incomingSub !== previousSub) {
|
|
571
|
+
console.warn(
|
|
572
|
+
"[casino-sdk] SECURITY: this client's cookie jar already holds a session for another player, and a response just switched it to a different identity. On the server, never share one client (or one CookieJar) across users' requests \u2014 module-scope clients are browser-only. Scope one per request with `client.withCookies(new CookieJar())`. See docs/guides/react.md."
|
|
573
|
+
);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function jwtSub(token) {
|
|
579
|
+
const payload = token.split(".")[1];
|
|
580
|
+
if (!payload) return void 0;
|
|
581
|
+
try {
|
|
582
|
+
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
583
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
584
|
+
const decoded = typeof atob === "function" ? atob(padded) : Buffer.from(padded, "base64").toString("binary");
|
|
585
|
+
const claims = JSON.parse(decoded);
|
|
586
|
+
return typeof claims.sub === "string" ? claims.sub : void 0;
|
|
587
|
+
} catch {
|
|
588
|
+
return void 0;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function readSetCookie(headers) {
|
|
592
|
+
const getter = headers.getSetCookie;
|
|
593
|
+
if (typeof getter === "function") {
|
|
594
|
+
const all = getter.call(headers);
|
|
595
|
+
if (all.length) return all;
|
|
596
|
+
}
|
|
597
|
+
return headers.get("set-cookie");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/core/session.ts
|
|
601
|
+
var Session = class {
|
|
602
|
+
player = null;
|
|
603
|
+
profile = null;
|
|
604
|
+
fallbackCurrency;
|
|
605
|
+
constructor(fallbackCurrency) {
|
|
606
|
+
this.fallbackCurrency = fallbackCurrency;
|
|
607
|
+
}
|
|
608
|
+
setPlayer(player, profile) {
|
|
609
|
+
this.player = player;
|
|
610
|
+
if (profile !== void 0) this.profile = profile;
|
|
611
|
+
if (player === null) this.profile = null;
|
|
612
|
+
}
|
|
613
|
+
clear() {
|
|
614
|
+
this.player = null;
|
|
615
|
+
this.profile = null;
|
|
616
|
+
}
|
|
617
|
+
get playerId() {
|
|
618
|
+
return this.player?.id;
|
|
619
|
+
}
|
|
620
|
+
/** Resolve a playerId, preferring an explicit override; throws if neither exists. */
|
|
621
|
+
requirePlayerId(override) {
|
|
622
|
+
const id = override ?? this.player?.id;
|
|
623
|
+
if (!id) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
"No authenticated player. Call `auth.login()`/`auth.me()` first, or pass an explicit playerId."
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return id;
|
|
629
|
+
}
|
|
630
|
+
/** Resolve a currency: override → profile → configured default. */
|
|
631
|
+
resolveCurrency(override) {
|
|
632
|
+
return (override ?? this.profile?.currency ?? this.fallbackCurrency).toUpperCase();
|
|
633
|
+
}
|
|
634
|
+
snapshot() {
|
|
635
|
+
return {
|
|
636
|
+
player: this.player,
|
|
637
|
+
profile: this.profile,
|
|
638
|
+
currency: this.resolveCurrency()
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// src/modules/affiliate.ts
|
|
644
|
+
var AffiliateModule = class {
|
|
645
|
+
constructor(http) {
|
|
646
|
+
this.http = http;
|
|
647
|
+
}
|
|
648
|
+
http;
|
|
649
|
+
/**
|
|
650
|
+
* Record an affiliate click. Returns the `clickId` join key — persist it
|
|
651
|
+
* (cookie/localStorage) and carry it into signup so the registration can be
|
|
652
|
+
* attributed to the click.
|
|
653
|
+
*/
|
|
654
|
+
trackClick(input) {
|
|
655
|
+
return this.http.post("/affiliate/track/click", { body: input });
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// src/modules/auth.ts
|
|
660
|
+
var AuthModule = class {
|
|
661
|
+
constructor(http, session, baseUrl) {
|
|
662
|
+
this.http = http;
|
|
663
|
+
this.session = session;
|
|
664
|
+
this.baseUrl = baseUrl;
|
|
665
|
+
}
|
|
666
|
+
http;
|
|
667
|
+
session;
|
|
668
|
+
baseUrl;
|
|
669
|
+
/** Create an account and start a session. */
|
|
670
|
+
async signup(input) {
|
|
671
|
+
const res = await this.http.post("/auth/player/signup", { body: input });
|
|
672
|
+
this.session.setPlayer(res.player);
|
|
673
|
+
return res;
|
|
674
|
+
}
|
|
675
|
+
/** Log in with email + password and start a session. */
|
|
676
|
+
async login(input) {
|
|
677
|
+
const res = await this.http.post("/auth/player/login", { body: input });
|
|
678
|
+
this.session.setPlayer(res.player);
|
|
679
|
+
return res;
|
|
680
|
+
}
|
|
681
|
+
/** End the session and clear the cached player. */
|
|
682
|
+
async logout() {
|
|
683
|
+
try {
|
|
684
|
+
await this.http.post("/auth/player/logout");
|
|
685
|
+
} finally {
|
|
686
|
+
this.session.clear();
|
|
687
|
+
this.http.jar.clear();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Rotate the session using the refresh cookie. Called automatically by the HTTP
|
|
692
|
+
* layer on a 401; you rarely call it directly.
|
|
693
|
+
*
|
|
694
|
+
* The request is marked `_noRefresh`: a 401 here (fully logged out) must throw
|
|
695
|
+
* immediately instead of re-entering the 401 → refresh path it is part of.
|
|
696
|
+
*/
|
|
697
|
+
async refresh() {
|
|
698
|
+
const res = await this.http.post("/auth/player/refresh", { _noRefresh: true });
|
|
699
|
+
this.session.setPlayer(res.player);
|
|
700
|
+
return res;
|
|
701
|
+
}
|
|
702
|
+
/** Current player + profile. Refreshes the session cache (incl. default currency). */
|
|
703
|
+
async me() {
|
|
704
|
+
const res = await this.http.get("/auth/player/me");
|
|
705
|
+
this.session.setPlayer(res.player, res.profile);
|
|
706
|
+
return res;
|
|
707
|
+
}
|
|
708
|
+
social = {
|
|
709
|
+
/**
|
|
710
|
+
* Build the URL that begins a social login. Send the browser here (full
|
|
711
|
+
* navigation, not fetch) so the runtime can redirect to the provider.
|
|
712
|
+
*/
|
|
713
|
+
startUrl: (provider, options) => {
|
|
714
|
+
const url = new URL(`${this.baseUrl}/auth/player/social/${provider}/start`);
|
|
715
|
+
if (options?.redirectUri) url.searchParams.set("redirect_uri", options.redirectUri);
|
|
716
|
+
return url.toString();
|
|
717
|
+
},
|
|
718
|
+
/**
|
|
719
|
+
* Complete the OAuth callback (provider → your app) by exchanging `code`+`state`.
|
|
720
|
+
* Use this if your app handles the callback route itself; otherwise the runtime
|
|
721
|
+
* handles `/auth/player/social/:provider/callback` directly.
|
|
722
|
+
*/
|
|
723
|
+
callback: async (provider, params) => {
|
|
724
|
+
const res = await this.http.get(`/auth/player/social/${provider}/callback`, {
|
|
725
|
+
query: params,
|
|
726
|
+
csrf: false
|
|
727
|
+
});
|
|
728
|
+
this.session.setPlayer(res.player);
|
|
729
|
+
return res;
|
|
730
|
+
},
|
|
731
|
+
/** Link a social account to the logged-in player. */
|
|
732
|
+
link: (provider, code) => this.http.post(`/auth/player/social/${provider}/link`, { body: { code } }),
|
|
733
|
+
/** Unlink a previously-linked social account. */
|
|
734
|
+
unlink: (provider, socialAccountId) => this.http.delete(`/auth/player/social/${provider}/${socialAccountId}`)
|
|
735
|
+
};
|
|
736
|
+
/**
|
|
737
|
+
* Session management — the "your devices" surface. The runtime scopes every call
|
|
738
|
+
* to the authenticated player (the id comes from the verified principal, never a
|
|
739
|
+
* path), so a player can only see and revoke their own sessions.
|
|
740
|
+
*/
|
|
741
|
+
sessions = {
|
|
742
|
+
/** Active sessions — the player's currently signed-in devices. */
|
|
743
|
+
list: async () => {
|
|
744
|
+
const res = await this.http.get("/auth/player/sessions");
|
|
745
|
+
return res.sessions;
|
|
746
|
+
},
|
|
747
|
+
/**
|
|
748
|
+
* Full session history — active plus revoked/expired, newest first. Paginated:
|
|
749
|
+
* `limit` 1–100 (default 50), `offset` ≥ 0; `pagination.hasMore` says whether
|
|
750
|
+
* another page exists.
|
|
751
|
+
*/
|
|
752
|
+
history: async (query = {}) => this.http.get("/auth/player/sessions/history", {
|
|
753
|
+
query: { limit: query.limit, offset: query.offset }
|
|
754
|
+
}),
|
|
755
|
+
/** Disconnect (revoke) one of the player's own sessions. */
|
|
756
|
+
revoke: (sessionId) => this.http.delete(`/auth/player/sessions/${encodeURIComponent(sessionId)}`),
|
|
757
|
+
/**
|
|
758
|
+
* Revoke every session. By default the current one survives ("sign out
|
|
759
|
+
* everywhere else"); pass `{ exceptCurrent: false }` to revoke this device
|
|
760
|
+
* too (the runtime then also clears the auth cookies).
|
|
761
|
+
*/
|
|
762
|
+
revokeAll: (options) => (
|
|
763
|
+
// The runtime parses this with `z.coerce.boolean()`, where ANY non-empty
|
|
764
|
+
// string (including "false") coerces to true — only the empty string
|
|
765
|
+
// reads as false. Omit for the default (true), send "" for false.
|
|
766
|
+
this.http.delete("/auth/player/sessions", {
|
|
767
|
+
query: { exceptCurrent: options?.exceptCurrent === false ? "" : void 0 }
|
|
768
|
+
})
|
|
769
|
+
)
|
|
770
|
+
};
|
|
771
|
+
/**
|
|
772
|
+
* Login history (successful and failed attempts, newest first). Paginated:
|
|
773
|
+
* `limit` 1–100 (default 50), `offset` ≥ 0.
|
|
774
|
+
*/
|
|
775
|
+
loginHistory(query = {}) {
|
|
776
|
+
return this.http.get("/auth/player/login-history", {
|
|
777
|
+
query: { limit: query.limit, offset: query.offset }
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/** Password management. The reset flow is anonymous; `change` needs a session. */
|
|
781
|
+
password = {
|
|
782
|
+
/**
|
|
783
|
+
* Change the password (requires the current one). The runtime revokes every
|
|
784
|
+
* OTHER session; this one survives. Rate-limited 10/h per player.
|
|
785
|
+
*/
|
|
786
|
+
change: (input) => this.http.post("/auth/player/password/change", { body: input }),
|
|
787
|
+
/**
|
|
788
|
+
* Request a password-reset email. Always resolves `{ success: true }`
|
|
789
|
+
* (anti-enumeration) — even for unknown emails or while rate-limited.
|
|
790
|
+
*/
|
|
791
|
+
requestReset: (email) => this.http.post("/auth/player/password/reset/request", { body: { email } }),
|
|
792
|
+
/**
|
|
793
|
+
* Complete a reset with the emailed token. Revokes ALL sessions — the
|
|
794
|
+
* player must log in again.
|
|
795
|
+
*/
|
|
796
|
+
confirmReset: (input) => this.http.post("/auth/player/password/reset/confirm", { body: input })
|
|
797
|
+
};
|
|
798
|
+
/**
|
|
799
|
+
* Email/phone verification. `request` sends the token/OTP (429 with
|
|
800
|
+
* `details.resendIn` inside the cooldown); `confirm` verifies it.
|
|
801
|
+
*/
|
|
802
|
+
verification = {
|
|
803
|
+
email: {
|
|
804
|
+
request: () => this.http.post("/auth/player/email/verification/request"),
|
|
805
|
+
/** Confirm with the emailed link `token` XOR the 6-digit `code` (code needs a session). */
|
|
806
|
+
confirm: (input) => this.http.post("/auth/player/email/verification/confirm", { body: input })
|
|
807
|
+
},
|
|
808
|
+
phone: {
|
|
809
|
+
/** Pass `phone` to verify a NEW number (applied on confirm). */
|
|
810
|
+
request: (phone) => this.http.post("/auth/player/phone/verification/request", {
|
|
811
|
+
body: phone !== void 0 ? { phone } : {}
|
|
812
|
+
}),
|
|
813
|
+
confirm: (code) => this.http.post("/auth/player/phone/verification/confirm", { body: { code } })
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// src/core/money.ts
|
|
819
|
+
var MONEY_SCALE = 4;
|
|
820
|
+
var FACTOR = 10 ** MONEY_SCALE;
|
|
821
|
+
function decimalToMinor(value) {
|
|
822
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
823
|
+
if (!Number.isFinite(n)) {
|
|
824
|
+
throw new TypeError(`decimalToMinor: not a finite number: ${String(value)}`);
|
|
825
|
+
}
|
|
826
|
+
return Math.round(n * FACTOR);
|
|
827
|
+
}
|
|
828
|
+
function minorStringToMinor(value) {
|
|
829
|
+
if (!/^-?\d+$/.test(value)) {
|
|
830
|
+
throw new TypeError(`minorStringToMinor: not a minor-unit integer string: ${String(value)}`);
|
|
831
|
+
}
|
|
832
|
+
const n = Number(value);
|
|
833
|
+
if (!Number.isSafeInteger(n)) {
|
|
834
|
+
throw new TypeError(
|
|
835
|
+
`minorStringToMinor: value exceeds the safe integer range: ${String(value)}`
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
return n;
|
|
839
|
+
}
|
|
840
|
+
function minorToAmount(minor) {
|
|
841
|
+
if (!Number.isInteger(minor)) {
|
|
842
|
+
throw new TypeError(`minorToAmount: expected an integer, got ${String(minor)}`);
|
|
843
|
+
}
|
|
844
|
+
return minor / FACTOR;
|
|
845
|
+
}
|
|
846
|
+
function minorToDecimal(minor) {
|
|
847
|
+
if (!Number.isInteger(minor)) {
|
|
848
|
+
throw new TypeError(`minorToDecimal: expected an integer, got ${String(minor)}`);
|
|
849
|
+
}
|
|
850
|
+
const negative = minor < 0;
|
|
851
|
+
const abs = Math.abs(minor);
|
|
852
|
+
const whole = Math.trunc(abs / FACTOR);
|
|
853
|
+
const frac = String(abs % FACTOR).padStart(MONEY_SCALE, "0");
|
|
854
|
+
return `${negative ? "-" : ""}${whole}.${frac}`;
|
|
855
|
+
}
|
|
856
|
+
function formatMoney(minor, currency, options) {
|
|
857
|
+
const major = minor / FACTOR;
|
|
858
|
+
return new Intl.NumberFormat(options?.locale, {
|
|
859
|
+
style: "currency",
|
|
860
|
+
currency,
|
|
861
|
+
...options?.minimumFractionDigits !== void 0 ? { minimumFractionDigits: options.minimumFractionDigits } : {},
|
|
862
|
+
...options?.maximumFractionDigits !== void 0 ? { maximumFractionDigits: options.maximumFractionDigits } : {}
|
|
863
|
+
}).format(major);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/modules/cashier.ts
|
|
867
|
+
var CashierModule = class {
|
|
868
|
+
constructor(http, session) {
|
|
869
|
+
this.http = http;
|
|
870
|
+
this.session = session;
|
|
871
|
+
}
|
|
872
|
+
http;
|
|
873
|
+
session;
|
|
874
|
+
/**
|
|
875
|
+
* Initiate a deposit. Follow `result.nextAction` for any PSP redirect/3DS step;
|
|
876
|
+
* a `result.challenge` means a gate plugin requires a step-up (e.g. KYC) first.
|
|
877
|
+
*/
|
|
878
|
+
async deposit(input) {
|
|
879
|
+
const playerId = this.session.requirePlayerId(input.playerId);
|
|
880
|
+
const currency = this.session.resolveCurrency(input.currency);
|
|
881
|
+
const raw = await this.http.post("/cashier/deposits", {
|
|
882
|
+
idempotent: true,
|
|
883
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
884
|
+
body: {
|
|
885
|
+
playerId,
|
|
886
|
+
currency,
|
|
887
|
+
amount: minorToAmount(input.amount),
|
|
888
|
+
providerKey: input.providerKey,
|
|
889
|
+
methodKey: input.methodKey,
|
|
890
|
+
instrumentId: input.instrumentId,
|
|
891
|
+
baseCurrency: input.baseCurrency,
|
|
892
|
+
returnUrl: input.returnUrl,
|
|
893
|
+
metadata: input.metadata
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
return withBuckets(raw);
|
|
897
|
+
}
|
|
898
|
+
/** Request a withdrawal. May land in `pending_review` depending on risk rules. */
|
|
899
|
+
async withdraw(input) {
|
|
900
|
+
const playerId = this.session.requirePlayerId(input.playerId);
|
|
901
|
+
const currency = this.session.resolveCurrency(input.currency);
|
|
902
|
+
const raw = await this.http.post("/cashier/withdrawals", {
|
|
903
|
+
idempotent: true,
|
|
904
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
905
|
+
body: {
|
|
906
|
+
playerId,
|
|
907
|
+
currency,
|
|
908
|
+
amount: minorToAmount(input.amount),
|
|
909
|
+
providerKey: input.providerKey,
|
|
910
|
+
methodKey: input.methodKey,
|
|
911
|
+
instrumentId: input.instrumentId,
|
|
912
|
+
baseCurrency: input.baseCurrency,
|
|
913
|
+
metadata: input.metadata
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
return withBuckets(raw);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* The payment methods available to the player (`GET /player/cashier/methods`),
|
|
920
|
+
* sorted for display; `limits` in minor units. Cached 60s server-side.
|
|
921
|
+
*/
|
|
922
|
+
async listMethods(options = {}) {
|
|
923
|
+
const res = await this.http.get("/player/cashier/methods", {
|
|
924
|
+
query: { currency: options.currency }
|
|
925
|
+
});
|
|
926
|
+
return res.methods.map(toMethod);
|
|
927
|
+
}
|
|
928
|
+
/** Deposit history, newest first, amounts in minor units. */
|
|
929
|
+
async deposits(query = {}) {
|
|
930
|
+
const res = await this.http.get("/player/cashier/deposits", {
|
|
931
|
+
query: {
|
|
932
|
+
status: query.status,
|
|
933
|
+
providerKey: query.providerKey,
|
|
934
|
+
...rangeQuery(query)
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
return { deposits: res.deposits.map(toDeposit), pagination: res.pagination };
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* One deposit with its wallet linkage and any pending `nextAction`
|
|
941
|
+
* (`GET /player/cashier/deposits/:id`). Realtime `wallet.deposit` remains the
|
|
942
|
+
* push channel; this is the authoritative re-read.
|
|
943
|
+
*/
|
|
944
|
+
async depositStatus(depositId) {
|
|
945
|
+
const raw = await this.http.get(
|
|
946
|
+
`/player/cashier/deposits/${encodeURIComponent(depositId)}`
|
|
947
|
+
);
|
|
948
|
+
return toDeposit(raw);
|
|
949
|
+
}
|
|
950
|
+
/** Withdrawal history, newest first, amounts in minor units. */
|
|
951
|
+
async withdrawals(query = {}) {
|
|
952
|
+
const res = await this.http.get("/player/cashier/withdrawals", {
|
|
953
|
+
query: { status: query.status, ...rangeQuery(query) }
|
|
954
|
+
});
|
|
955
|
+
return { withdrawals: res.withdrawals.map(toWithdrawal), pagination: res.pagination };
|
|
956
|
+
}
|
|
957
|
+
/** One withdrawal with its reserve/settle/release wallet-transaction legs. */
|
|
958
|
+
async withdrawalStatus(withdrawalId) {
|
|
959
|
+
const raw = await this.http.get(
|
|
960
|
+
`/player/cashier/withdrawals/${encodeURIComponent(withdrawalId)}`
|
|
961
|
+
);
|
|
962
|
+
return toWithdrawal(raw);
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Cancel a withdrawal that hasn't been picked up yet (`requested`/
|
|
966
|
+
* `pending_review` only — anything later is a `ConflictError` with code
|
|
967
|
+
* `INVALID_WITHDRAWAL_STATE`). Releases the reserved funds; idempotent
|
|
968
|
+
* (a replay returns `idempotent: true` without balances).
|
|
969
|
+
*/
|
|
970
|
+
async cancelWithdrawal(withdrawalId) {
|
|
971
|
+
const raw = await this.http.post(
|
|
972
|
+
`/player/cashier/withdrawals/${encodeURIComponent(withdrawalId)}/cancel`
|
|
973
|
+
);
|
|
974
|
+
return withBuckets(raw);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
function rangeQuery(query) {
|
|
978
|
+
return {
|
|
979
|
+
from: toIso(query.from),
|
|
980
|
+
to: toIso(query.to),
|
|
981
|
+
limit: query.limit,
|
|
982
|
+
offset: query.offset
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function toIso(value) {
|
|
986
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
987
|
+
}
|
|
988
|
+
function toDeposit(raw) {
|
|
989
|
+
const { amountMinor, ...rest } = raw;
|
|
990
|
+
return { ...rest, amount: minorStringToMinor(amountMinor) };
|
|
991
|
+
}
|
|
992
|
+
var toWithdrawal = toDeposit;
|
|
993
|
+
function toMethod(raw) {
|
|
994
|
+
const { limits, ...rest } = raw;
|
|
995
|
+
return {
|
|
996
|
+
...rest,
|
|
997
|
+
limits: {
|
|
998
|
+
min: minorStringToMinor(limits.minMinor),
|
|
999
|
+
max: minorStringToMinor(limits.maxMinor),
|
|
1000
|
+
currency: limits.currency
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function toBuckets(raw) {
|
|
1005
|
+
return {
|
|
1006
|
+
cash: decimalToMinor(raw.cash),
|
|
1007
|
+
bonus: decimalToMinor(raw.bonus),
|
|
1008
|
+
locked: decimalToMinor(raw.locked)
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function withBuckets(raw) {
|
|
1012
|
+
const { balances, ...rest } = raw;
|
|
1013
|
+
return balances ? { ...rest, balances: toBuckets(balances) } : { ...rest };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/modules/wallet.ts
|
|
1017
|
+
var WalletModule = class {
|
|
1018
|
+
constructor(http, session) {
|
|
1019
|
+
this.http = http;
|
|
1020
|
+
this.session = session;
|
|
1021
|
+
}
|
|
1022
|
+
http;
|
|
1023
|
+
session;
|
|
1024
|
+
/** Current balance across the cash/bonus/locked buckets, in minor units. */
|
|
1025
|
+
async getBalance(options) {
|
|
1026
|
+
const playerId = this.session.requirePlayerId(options?.playerId);
|
|
1027
|
+
const currency = this.session.resolveCurrency(options?.currency);
|
|
1028
|
+
const raw = await this.http.get("/wallet/balance", {
|
|
1029
|
+
query: { playerId, currency }
|
|
1030
|
+
});
|
|
1031
|
+
return {
|
|
1032
|
+
playerId: raw.playerId,
|
|
1033
|
+
currency: raw.currency,
|
|
1034
|
+
cash: decimalToMinor(raw.cashBalance),
|
|
1035
|
+
bonus: decimalToMinor(raw.bonusBalance),
|
|
1036
|
+
locked: decimalToMinor(raw.lockedBalance),
|
|
1037
|
+
total: decimalToMinor(raw.total),
|
|
1038
|
+
status: raw.status,
|
|
1039
|
+
walletId: raw.walletId
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
/** Paginated transaction history (newest first), amounts in minor units. */
|
|
1043
|
+
async transactions(query = {}) {
|
|
1044
|
+
const playerId = this.session.requirePlayerId(query.playerId);
|
|
1045
|
+
const raw = await this.http.get("/wallet/transactions", {
|
|
1046
|
+
query: {
|
|
1047
|
+
playerId,
|
|
1048
|
+
walletId: query.walletId,
|
|
1049
|
+
type: query.type,
|
|
1050
|
+
status: query.status,
|
|
1051
|
+
currency: query.currency,
|
|
1052
|
+
limit: query.limit,
|
|
1053
|
+
offset: query.offset
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
return { ...raw, rows: raw.rows.map(toTransaction) };
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Paginated append-only ledger (newest first) — one row per bucket movement,
|
|
1060
|
+
* with before/after balance snapshots, amounts in minor units. Filter by
|
|
1061
|
+
* `transactionId` to see the legs of a single transaction.
|
|
1062
|
+
*/
|
|
1063
|
+
async ledger(query = {}) {
|
|
1064
|
+
const playerId = this.session.requirePlayerId(query.playerId);
|
|
1065
|
+
const raw = await this.http.get("/wallet/ledger", {
|
|
1066
|
+
query: {
|
|
1067
|
+
playerId,
|
|
1068
|
+
walletId: query.walletId,
|
|
1069
|
+
walletType: query.walletType,
|
|
1070
|
+
transactionId: query.transactionId,
|
|
1071
|
+
limit: query.limit,
|
|
1072
|
+
offset: query.offset
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
return { ...raw, rows: raw.rows.map(toLedgerEntry) };
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Fetch a single transaction by id.
|
|
1079
|
+
*
|
|
1080
|
+
* @remarks Not available to players yet — the runtime route
|
|
1081
|
+
* `GET /wallet/transactions/:id` requires staff permission. Tracked on the
|
|
1082
|
+
* roadmap; use {@link transactions} to page history in the meantime.
|
|
1083
|
+
*/
|
|
1084
|
+
getTransaction(_id) {
|
|
1085
|
+
return Promise.reject(
|
|
1086
|
+
new NotImplementedError(
|
|
1087
|
+
"wallet.getTransaction: runtime exposes single-transaction reads to staff only. Use wallet.transactions() until a player-facing endpoint ships."
|
|
1088
|
+
)
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
function toTransaction(raw) {
|
|
1093
|
+
return { ...raw, amount: decimalToMinor(raw.amount) };
|
|
1094
|
+
}
|
|
1095
|
+
function toLedgerEntry(raw) {
|
|
1096
|
+
return {
|
|
1097
|
+
...raw,
|
|
1098
|
+
amount: decimalToMinor(raw.amount),
|
|
1099
|
+
balanceBefore: decimalToMinor(raw.balanceBefore),
|
|
1100
|
+
balanceAfter: decimalToMinor(raw.balanceAfter)
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/modules/catalog.ts
|
|
1105
|
+
var CatalogModule = class {
|
|
1106
|
+
constructor(http) {
|
|
1107
|
+
this.http = http;
|
|
1108
|
+
}
|
|
1109
|
+
http;
|
|
1110
|
+
/** The default lobby, filtered by geo/currency/device. */
|
|
1111
|
+
lobby(query = {}) {
|
|
1112
|
+
return this.http.get("/catalog/lobby", { query: { ...query } });
|
|
1113
|
+
}
|
|
1114
|
+
/** Search/filter games by text, provider, category, and feature flags. */
|
|
1115
|
+
searchGames(query = {}) {
|
|
1116
|
+
return this.http.get("/catalog/games", {
|
|
1117
|
+
query: {
|
|
1118
|
+
country: query.country,
|
|
1119
|
+
subdivision: query.subdivision,
|
|
1120
|
+
currency: query.currency,
|
|
1121
|
+
locale: query.locale,
|
|
1122
|
+
device: query.device,
|
|
1123
|
+
limit: query.limit,
|
|
1124
|
+
offset: query.offset,
|
|
1125
|
+
q: query.q,
|
|
1126
|
+
provider: query.provider,
|
|
1127
|
+
category: query.category,
|
|
1128
|
+
hasDemo: boolParam(query.hasDemo),
|
|
1129
|
+
isVirtual: boolParam(query.isVirtual),
|
|
1130
|
+
bonusBuy: boolParam(query.bonusBuy),
|
|
1131
|
+
megaways: boolParam(query.megaways)
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
/** Trending games over a window (`"all"` by default), most popular first. */
|
|
1136
|
+
async trending(query = {}) {
|
|
1137
|
+
const res = await this.http.get("/catalog/trending", {
|
|
1138
|
+
query: { ...query }
|
|
1139
|
+
});
|
|
1140
|
+
return res.games;
|
|
1141
|
+
}
|
|
1142
|
+
/** The localized category tree (recursive `children`). */
|
|
1143
|
+
async categories(options) {
|
|
1144
|
+
const res = await this.http.get("/catalog/categories", {
|
|
1145
|
+
query: { locale: options?.locale }
|
|
1146
|
+
});
|
|
1147
|
+
return res.categories;
|
|
1148
|
+
}
|
|
1149
|
+
/** All enabled providers for this brand/region. */
|
|
1150
|
+
async providers(query = {}) {
|
|
1151
|
+
const res = await this.http.get("/catalog/providers", {
|
|
1152
|
+
query: { ...query }
|
|
1153
|
+
});
|
|
1154
|
+
return res.providers;
|
|
1155
|
+
}
|
|
1156
|
+
/** A single game by internal id or slug. */
|
|
1157
|
+
game(idOrSlug, query = {}) {
|
|
1158
|
+
return this.http.get(`/catalog/games/${encodeURIComponent(idOrSlug)}`, {
|
|
1159
|
+
query: { ...query }
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Games suggested alongside the given game. Takes the internal game **id** only
|
|
1164
|
+
* (an unknown id — including a slug — yields an empty list, not a 404).
|
|
1165
|
+
*/
|
|
1166
|
+
async suggested(gameId, query = {}) {
|
|
1167
|
+
const res = await this.http.get(
|
|
1168
|
+
`/catalog/games/${encodeURIComponent(gameId)}/suggested`,
|
|
1169
|
+
{ query: { ...query } }
|
|
1170
|
+
);
|
|
1171
|
+
return res.games;
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
function boolParam(value) {
|
|
1175
|
+
if (value === void 0) return void 0;
|
|
1176
|
+
return value ? "1" : "0";
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// src/core/json-schema.ts
|
|
1180
|
+
function checkJsonSchema(value, schema, path = "") {
|
|
1181
|
+
if (typeof schema !== "object" || schema === null) return [];
|
|
1182
|
+
const node = schema;
|
|
1183
|
+
const problems = [];
|
|
1184
|
+
if (node.type !== void 0) {
|
|
1185
|
+
const allowed = Array.isArray(node.type) ? node.type : [node.type];
|
|
1186
|
+
if (!allowed.some((t) => matchesType(value, t))) {
|
|
1187
|
+
problems.push({ path, message: `expected ${allowed.join(" | ")}, got ${typeName(value)}` });
|
|
1188
|
+
return problems;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (node.enum !== void 0 && !node.enum.some((candidate) => sameJson(candidate, value))) {
|
|
1192
|
+
problems.push({ path, message: `expected one of ${JSON.stringify(node.enum)}` });
|
|
1193
|
+
}
|
|
1194
|
+
if (isRecord(value)) {
|
|
1195
|
+
for (const key of node.required ?? []) {
|
|
1196
|
+
if (!(key in value) || value[key] === void 0) {
|
|
1197
|
+
problems.push({ path: joinPath(path, key), message: "required property is missing" });
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (node.properties) {
|
|
1201
|
+
for (const [key, propSchema] of Object.entries(node.properties)) {
|
|
1202
|
+
if (key in value && value[key] !== void 0) {
|
|
1203
|
+
problems.push(...checkJsonSchema(value[key], propSchema, joinPath(path, key)));
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (Array.isArray(value) && node.items !== void 0 && !Array.isArray(node.items)) {
|
|
1209
|
+
value.forEach((item, i) => {
|
|
1210
|
+
problems.push(...checkJsonSchema(item, node.items, `${path}[${i}]`));
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
return problems;
|
|
1214
|
+
}
|
|
1215
|
+
function matchesType(value, type) {
|
|
1216
|
+
switch (type) {
|
|
1217
|
+
case "object":
|
|
1218
|
+
return isRecord(value);
|
|
1219
|
+
case "array":
|
|
1220
|
+
return Array.isArray(value);
|
|
1221
|
+
case "string":
|
|
1222
|
+
return typeof value === "string";
|
|
1223
|
+
case "number":
|
|
1224
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1225
|
+
case "integer":
|
|
1226
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
1227
|
+
case "boolean":
|
|
1228
|
+
return typeof value === "boolean";
|
|
1229
|
+
case "null":
|
|
1230
|
+
return value === null;
|
|
1231
|
+
default:
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function sameJson(a, b) {
|
|
1236
|
+
if (Object.is(a, b)) return true;
|
|
1237
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
1238
|
+
try {
|
|
1239
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1240
|
+
} catch {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function typeName(value) {
|
|
1245
|
+
if (value === null) return "null";
|
|
1246
|
+
if (Array.isArray(value)) return "array";
|
|
1247
|
+
return typeof value;
|
|
1248
|
+
}
|
|
1249
|
+
function isRecord(value) {
|
|
1250
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1251
|
+
}
|
|
1252
|
+
function joinPath(path, key) {
|
|
1253
|
+
return path ? `${path}.${key}` : key;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/core/env.ts
|
|
1257
|
+
function isDevEnvironment() {
|
|
1258
|
+
try {
|
|
1259
|
+
return typeof process === "undefined" || process.env.NODE_ENV !== "production";
|
|
1260
|
+
} catch {
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/modules/ext.ts
|
|
1266
|
+
var CATALOG_PATH = "/api/ext/_catalog";
|
|
1267
|
+
var DEFAULT_CATALOG_MAX_AGE_S = 60;
|
|
1268
|
+
function createExtModule(http) {
|
|
1269
|
+
let cache = null;
|
|
1270
|
+
let inflight = null;
|
|
1271
|
+
const clients = /* @__PURE__ */ new Map();
|
|
1272
|
+
const warnedDeprecated = /* @__PURE__ */ new Set();
|
|
1273
|
+
function cacheUntil(maxAgeSeconds) {
|
|
1274
|
+
return Date.now() + (maxAgeSeconds ?? DEFAULT_CATALOG_MAX_AGE_S) * 1e3;
|
|
1275
|
+
}
|
|
1276
|
+
async function fetchCatalog(force) {
|
|
1277
|
+
const etag = !force && cache ? cache.etag : void 0;
|
|
1278
|
+
const res = await http.conditionalGet(CATALOG_PATH, {
|
|
1279
|
+
...etag !== void 0 ? { etag } : {},
|
|
1280
|
+
_noEnrich: true
|
|
1281
|
+
});
|
|
1282
|
+
if (res.notModified && cache) {
|
|
1283
|
+
cache = { ...cache, expiresAt: cacheUntil(res.maxAgeSeconds) };
|
|
1284
|
+
return cache.catalog;
|
|
1285
|
+
}
|
|
1286
|
+
if (res.data === void 0) {
|
|
1287
|
+
throw new ServerError(
|
|
1288
|
+
"EXT_CATALOG_EMPTY",
|
|
1289
|
+
"The plugin catalog endpoint returned no body.",
|
|
1290
|
+
0
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
cache = { catalog: res.data, etag: res.etag, expiresAt: cacheUntil(res.maxAgeSeconds) };
|
|
1294
|
+
return res.data;
|
|
1295
|
+
}
|
|
1296
|
+
function catalog(opts = {}) {
|
|
1297
|
+
if (!opts.force) {
|
|
1298
|
+
if (cache && Date.now() < cache.expiresAt) return Promise.resolve(cache.catalog);
|
|
1299
|
+
if (inflight) return inflight;
|
|
1300
|
+
}
|
|
1301
|
+
const fetching = fetchCatalog(opts.force ?? false).finally(() => {
|
|
1302
|
+
if (inflight === fetching) inflight = null;
|
|
1303
|
+
});
|
|
1304
|
+
inflight = fetching;
|
|
1305
|
+
return fetching;
|
|
1306
|
+
}
|
|
1307
|
+
async function plugin(pluginKey) {
|
|
1308
|
+
try {
|
|
1309
|
+
return await http.get(`${CATALOG_PATH}/${encodeURIComponent(pluginKey)}`, {
|
|
1310
|
+
_noEnrich: true
|
|
1311
|
+
});
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
throw remapExtError(err, pluginKey);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async function resolveDescriptor(pluginKey, actionKey) {
|
|
1317
|
+
const cat = await catalog();
|
|
1318
|
+
const entry = cat.plugins.find((p) => p.pluginKey === pluginKey);
|
|
1319
|
+
if (!entry) throw new PluginNotEnabledError(pluginKey);
|
|
1320
|
+
const action = entry.actions.find((a) => a.key === actionKey);
|
|
1321
|
+
if (!action) throw new PluginActionNotFoundError(pluginKey, actionKey);
|
|
1322
|
+
return action;
|
|
1323
|
+
}
|
|
1324
|
+
async function callAction(pluginKey, actionKey, input, opts = {}) {
|
|
1325
|
+
const action = await resolveDescriptor(pluginKey, actionKey);
|
|
1326
|
+
if (isDevEnvironment()) {
|
|
1327
|
+
if (action.deprecated !== void 0) {
|
|
1328
|
+
const key = `${pluginKey}.${actionKey}`;
|
|
1329
|
+
if (!warnedDeprecated.has(key)) {
|
|
1330
|
+
warnedDeprecated.add(key);
|
|
1331
|
+
console.warn(`[casino-sdk] ext action "${key}" is deprecated: ${action.deprecated}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
warnOnSchemaMismatch(pluginKey, action, input);
|
|
1335
|
+
}
|
|
1336
|
+
const routed = routeInput(action, input);
|
|
1337
|
+
const autoIdempotent = action.kind === "mutation" && action.idempotent === true;
|
|
1338
|
+
const idempotencyKey = opts.idempotencyKey ?? (autoIdempotent ? generateIdempotencyKey() : void 0);
|
|
1339
|
+
try {
|
|
1340
|
+
return await http.request(action.method, routed.path, {
|
|
1341
|
+
...routed.query !== void 0 ? { query: routed.query } : {},
|
|
1342
|
+
...routed.body !== void 0 ? { body: routed.body } : {},
|
|
1343
|
+
...idempotencyKey !== void 0 ? { idempotent: true, idempotencyHeaderOnly: true, idempotencyKey } : {},
|
|
1344
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
1345
|
+
});
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
throw remapExtError(err, pluginKey);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async function rawRequest(pluginKey, method, path, init = {}) {
|
|
1351
|
+
const sub = path.startsWith("/") ? path : `/${path}`;
|
|
1352
|
+
try {
|
|
1353
|
+
return await http.request(method, `/api/ext/${encodeURIComponent(pluginKey)}${sub}`, {
|
|
1354
|
+
...init.query !== void 0 ? { query: toQuery(init.query) } : {},
|
|
1355
|
+
...init.body !== void 0 ? { body: init.body } : {},
|
|
1356
|
+
...init.idempotencyKey !== void 0 ? { idempotent: true, idempotencyHeaderOnly: true, idempotencyKey: init.idempotencyKey } : {},
|
|
1357
|
+
...init.signal ? { signal: init.signal } : {}
|
|
1358
|
+
});
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
throw remapExtError(err, pluginKey);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function pluginClient(pluginKey) {
|
|
1364
|
+
const existing = clients.get(pluginKey);
|
|
1365
|
+
if (existing) return existing;
|
|
1366
|
+
const client = {
|
|
1367
|
+
pluginKey,
|
|
1368
|
+
call: (actionKey, input, opts) => callAction(pluginKey, actionKey, input, opts),
|
|
1369
|
+
request: (method, path, init) => rawRequest(pluginKey, method, path, init)
|
|
1370
|
+
};
|
|
1371
|
+
clients.set(pluginKey, client);
|
|
1372
|
+
return client;
|
|
1373
|
+
}
|
|
1374
|
+
return Object.assign(pluginClient, { catalog, plugin });
|
|
1375
|
+
}
|
|
1376
|
+
function routeInput(action, input) {
|
|
1377
|
+
let path = action.path;
|
|
1378
|
+
const isObjectInput = isPlainObject2(input);
|
|
1379
|
+
const rest = isObjectInput ? { ...input } : void 0;
|
|
1380
|
+
if (rest && path.includes(":")) {
|
|
1381
|
+
path = path.replace(/:([A-Za-z0-9_]+)/g, (segment, name) => {
|
|
1382
|
+
if (!(name in rest)) return segment;
|
|
1383
|
+
const value = rest[name];
|
|
1384
|
+
if (value === void 0 || value === null) {
|
|
1385
|
+
delete rest[name];
|
|
1386
|
+
return segment;
|
|
1387
|
+
}
|
|
1388
|
+
delete rest[name];
|
|
1389
|
+
return encodeURIComponent(String(value));
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
if (action.method === "GET" || action.method === "DELETE") {
|
|
1393
|
+
if (input !== void 0 && !isObjectInput && isDevEnvironment()) {
|
|
1394
|
+
console.warn(
|
|
1395
|
+
`[casino-sdk] ext action "${action.key}": ${action.method} input must be a flat object; ignoring ${describeValue(input)}.`
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
path,
|
|
1400
|
+
...rest && Object.keys(rest).length > 0 ? { query: toQuery(rest) } : {}
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
if (rest) return { path, body: rest };
|
|
1404
|
+
return { path, ...input !== void 0 ? { body: input } : {} };
|
|
1405
|
+
}
|
|
1406
|
+
function toQuery(record) {
|
|
1407
|
+
const query = {};
|
|
1408
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1409
|
+
if (value === void 0 || value === null) continue;
|
|
1410
|
+
if (Array.isArray(value)) {
|
|
1411
|
+
query[key] = value.map((v) => String(v));
|
|
1412
|
+
} else if (typeof value === "object") {
|
|
1413
|
+
if (isDevEnvironment()) {
|
|
1414
|
+
console.warn(
|
|
1415
|
+
`[casino-sdk] ext query param "${key}" is an object; query schemas are flat \u2014 dropping it.`
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
} else {
|
|
1419
|
+
query[key] = value;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return query;
|
|
1423
|
+
}
|
|
1424
|
+
function warnOnSchemaMismatch(pluginKey, action, input) {
|
|
1425
|
+
if (action.input === void 0) return;
|
|
1426
|
+
const value = input === void 0 ? schemaExpectsObject(action.input) ? {} : void 0 : input;
|
|
1427
|
+
if (value === void 0) return;
|
|
1428
|
+
const problems = checkJsonSchema(value, action.input);
|
|
1429
|
+
if (problems.length > 0) {
|
|
1430
|
+
console.warn(
|
|
1431
|
+
`[casino-sdk] input for ext action "${pluginKey}.${action.key}" does not match its declared schema (the server stays authoritative): ` + problems.map((p) => `${p.path || "$"}: ${p.message}`).join("; ")
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
function schemaExpectsObject(schema) {
|
|
1436
|
+
if (typeof schema !== "object" || schema === null) return false;
|
|
1437
|
+
const node = schema;
|
|
1438
|
+
if (node.type === "object") return true;
|
|
1439
|
+
if (Array.isArray(node.type) && node.type.includes("object")) return true;
|
|
1440
|
+
return node.type === void 0 && node.properties !== void 0;
|
|
1441
|
+
}
|
|
1442
|
+
function isPlainObject2(value) {
|
|
1443
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1444
|
+
}
|
|
1445
|
+
function describeValue(value) {
|
|
1446
|
+
return Array.isArray(value) ? "an array" : `a ${typeof value}`;
|
|
1447
|
+
}
|
|
1448
|
+
function remapExtError(err, pluginKey) {
|
|
1449
|
+
if (!(err instanceof CasinoSdkError)) return err;
|
|
1450
|
+
if (err instanceof NotFoundError && !(err instanceof PluginNotEnabledError) && !(err instanceof PluginActionNotFoundError)) {
|
|
1451
|
+
return new PluginNotEnabledError(pluginKey, {
|
|
1452
|
+
code: err.code,
|
|
1453
|
+
message: err.message,
|
|
1454
|
+
status: err.status,
|
|
1455
|
+
details: err.details
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
if (err instanceof ServerError && err.status === 504 && !(err instanceof PluginUnavailableError)) {
|
|
1459
|
+
return new PluginUnavailableError(err.code, err.message, err.status, err.details);
|
|
1460
|
+
}
|
|
1461
|
+
return err;
|
|
1462
|
+
}
|
|
1463
|
+
function assertPluginVersion(catalogEntry, expectedRange) {
|
|
1464
|
+
const expected = parseMajor(expectedRange);
|
|
1465
|
+
if (expected === null) {
|
|
1466
|
+
throw new TypeError(
|
|
1467
|
+
`assertPluginVersion: cannot parse a major version out of expected range "${expectedRange}".`
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
const actual = parseMajor(catalogEntry.version);
|
|
1471
|
+
if (actual === null || actual !== expected) {
|
|
1472
|
+
throw new PluginVersionMismatchError(
|
|
1473
|
+
catalogEntry.pluginKey,
|
|
1474
|
+
expectedRange,
|
|
1475
|
+
catalogEntry.version
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
function parseMajor(version) {
|
|
1480
|
+
const match = /^\s*[\^~=v]*\s*(\d+)/.exec(version);
|
|
1481
|
+
return match?.[1] !== void 0 ? Number(match[1]) : null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/modules/games.ts
|
|
1485
|
+
function assertLaunchUrl(url) {
|
|
1486
|
+
let parsed;
|
|
1487
|
+
try {
|
|
1488
|
+
parsed = new URL(url);
|
|
1489
|
+
} catch {
|
|
1490
|
+
throw new ValidationError("LAUNCH_URL_INVALID", `launchUrl is not a valid URL: ${url}`, 0);
|
|
1491
|
+
}
|
|
1492
|
+
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
|
|
1493
|
+
if (parsed.protocol === "https:" || parsed.protocol === "http:" && loopback) return url;
|
|
1494
|
+
throw new ValidationError(
|
|
1495
|
+
"LAUNCH_URL_INVALID",
|
|
1496
|
+
`launchUrl must be https: (got "${parsed.protocol}"). Refusing to expose it to the game iframe.`,
|
|
1497
|
+
0,
|
|
1498
|
+
{ url }
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
var GamesModule = class {
|
|
1502
|
+
constructor(http, session) {
|
|
1503
|
+
this.http = http;
|
|
1504
|
+
this.session = session;
|
|
1505
|
+
}
|
|
1506
|
+
http;
|
|
1507
|
+
session;
|
|
1508
|
+
/**
|
|
1509
|
+
* @deprecated The runtime removed the SlotServ aggregation route
|
|
1510
|
+
* (`POST /plugins/slotserv/launch`) along with its baked-in plugins
|
|
1511
|
+
* (runtime `16d4e5d`, 2026-07-10) — this now always throws
|
|
1512
|
+
* {@link NotImplementedError}. Launch through a tenant-registered provider
|
|
1513
|
+
* adapter instead: {@link providerSession}. It returns the same
|
|
1514
|
+
* `{ launchUrl, sessionId }` shape.
|
|
1515
|
+
*/
|
|
1516
|
+
async launch(gameId, _input = {}) {
|
|
1517
|
+
throw new NotImplementedError(
|
|
1518
|
+
`games.launch("${gameId}"): the runtime no longer serves POST /plugins/slotserv/launch (the SlotServ aggregation plugin was removed). Use games.providerSession(provider, gameId) \u2014 provider adapters are registered per tenant (built-in "fake" for dev, or plugin-contributed).`
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Open a game session with a specific provider adapter via the generic
|
|
1523
|
+
* `POST /providers/:provider/session` route (provider-agnostic counterpart of
|
|
1524
|
+
* {@link launch}). Currency defaults from the session.
|
|
1525
|
+
*/
|
|
1526
|
+
async providerSession(provider, gameId, options = {}) {
|
|
1527
|
+
const playerId = this.session.requirePlayerId(options.playerId);
|
|
1528
|
+
const currency = this.session.resolveCurrency(options.currency);
|
|
1529
|
+
const result = await this.http.post(
|
|
1530
|
+
`/providers/${encodeURIComponent(provider)}/session`,
|
|
1531
|
+
{ body: { playerId, gameId, currency } }
|
|
1532
|
+
);
|
|
1533
|
+
assertLaunchUrl(result.launchUrl);
|
|
1534
|
+
return result;
|
|
1535
|
+
}
|
|
1536
|
+
/** Bet history (`GET /player/bets`), newest first, amounts in minor units. */
|
|
1537
|
+
async bets(query = {}) {
|
|
1538
|
+
const res = await this.http.get(
|
|
1539
|
+
"/player/bets",
|
|
1540
|
+
{
|
|
1541
|
+
query: {
|
|
1542
|
+
gameId: query.gameId,
|
|
1543
|
+
providerKey: query.providerKey,
|
|
1544
|
+
status: query.status,
|
|
1545
|
+
from: toIso2(query.from),
|
|
1546
|
+
to: toIso2(query.to),
|
|
1547
|
+
limit: query.limit,
|
|
1548
|
+
offset: query.offset
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
);
|
|
1552
|
+
return { bets: res.bets.map(toBet), pagination: res.pagination };
|
|
1553
|
+
}
|
|
1554
|
+
/** One bet plus its round-linked wallet transactions (`GET /player/bets/:betId`). */
|
|
1555
|
+
async bet(betId) {
|
|
1556
|
+
const raw = await this.http.get(`/player/bets/${encodeURIComponent(betId)}`);
|
|
1557
|
+
return { ...toBet(raw), walletTransactions: raw.walletTransactions };
|
|
1558
|
+
}
|
|
1559
|
+
/** Game-session history with per-session bet/win totals in minor units. */
|
|
1560
|
+
async gameSessions(query = {}) {
|
|
1561
|
+
const res = await this.http.get("/player/game-sessions", {
|
|
1562
|
+
query: {
|
|
1563
|
+
from: toIso2(query.from),
|
|
1564
|
+
to: toIso2(query.to),
|
|
1565
|
+
limit: query.limit,
|
|
1566
|
+
offset: query.offset
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
return { sessions: res.sessions.map(toGameSession), pagination: res.pagination };
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
function toIso2(value) {
|
|
1573
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1574
|
+
}
|
|
1575
|
+
function toBet(raw) {
|
|
1576
|
+
const { betAmountMinor, winAmountMinor, ...rest } = raw;
|
|
1577
|
+
return {
|
|
1578
|
+
...rest,
|
|
1579
|
+
betAmount: minorStringToMinor(betAmountMinor),
|
|
1580
|
+
winAmount: winAmountMinor === null ? null : minorStringToMinor(winAmountMinor)
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
function toGameSession(raw) {
|
|
1584
|
+
const { betTotalMinor, winTotalMinor, ...rest } = raw;
|
|
1585
|
+
return {
|
|
1586
|
+
...rest,
|
|
1587
|
+
betTotal: minorStringToMinor(betTotalMinor),
|
|
1588
|
+
winTotal: minorStringToMinor(winTotalMinor)
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// src/modules/kyc.ts
|
|
1593
|
+
var KycModule = class {
|
|
1594
|
+
constructor(http) {
|
|
1595
|
+
this.http = http;
|
|
1596
|
+
}
|
|
1597
|
+
http;
|
|
1598
|
+
/** Verification level, per-gate requirements, and email/phone verification flags. */
|
|
1599
|
+
status() {
|
|
1600
|
+
return this.http.get("/player/kyc/status");
|
|
1601
|
+
}
|
|
1602
|
+
/** Open KYC requests (draft/submitted/in_review/needs_more) with their checklists. */
|
|
1603
|
+
async requirements() {
|
|
1604
|
+
const res = await this.http.get("/player/kyc/requirements");
|
|
1605
|
+
return res.requests;
|
|
1606
|
+
}
|
|
1607
|
+
/** All requests, newest first. `limit` 1–100 (default 50). */
|
|
1608
|
+
history(query = {}) {
|
|
1609
|
+
return this.http.get("/player/kyc/history", {
|
|
1610
|
+
query: { limit: query.limit, offset: query.offset }
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Upload a document. With `requestId` it attaches to that request (which must
|
|
1615
|
+
* be `draft`/`needs_more`); without, it is a proactive re-verification
|
|
1616
|
+
* upload. Rejections are `ValidationError` with code `KYC_DOCUMENT_INVALID`
|
|
1617
|
+
* (format/size/expiry — see `details`).
|
|
1618
|
+
*
|
|
1619
|
+
* Large files on slow uplinks can outlive the client's default request
|
|
1620
|
+
* timeout (30s) — raise or disable it per call via `options.timeoutMs`
|
|
1621
|
+
* (`0` disables).
|
|
1622
|
+
*/
|
|
1623
|
+
uploadDocument(input, options = {}) {
|
|
1624
|
+
const form = new FormData();
|
|
1625
|
+
form.append("documentTypeKey", input.documentTypeKey);
|
|
1626
|
+
if (input.expiryDate !== void 0) form.append("expiryDate", input.expiryDate);
|
|
1627
|
+
const fileName = input.fileName ?? (typeof File !== "undefined" && input.file instanceof File ? input.file.name : "document");
|
|
1628
|
+
form.append("file", input.file, fileName);
|
|
1629
|
+
const path = input.requestId !== void 0 ? `/player/kyc/requests/${encodeURIComponent(input.requestId)}/documents` : "/player/kyc/documents";
|
|
1630
|
+
return this.http.post(path, {
|
|
1631
|
+
body: form,
|
|
1632
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
1633
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Submit a request for review. Every `required` checklist item needs an
|
|
1638
|
+
* uploaded/approved document first — otherwise `ValidationError` with
|
|
1639
|
+
* `details.missing` listing the absent `documentTypeKey`s.
|
|
1640
|
+
*/
|
|
1641
|
+
submit(requestId) {
|
|
1642
|
+
return this.http.post(`/player/kyc/requests/${encodeURIComponent(requestId)}/submit`);
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
// src/modules/player.ts
|
|
1647
|
+
var PlayerModule = class {
|
|
1648
|
+
constructor(http, auth) {
|
|
1649
|
+
this.http = http;
|
|
1650
|
+
this.auth = auth;
|
|
1651
|
+
}
|
|
1652
|
+
http;
|
|
1653
|
+
auth;
|
|
1654
|
+
/** The logged-in player's identity + profile (via `/auth/player/me`). */
|
|
1655
|
+
getProfile() {
|
|
1656
|
+
return this.auth.me();
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* The full profile row plus which identity fields are KYC-locked
|
|
1660
|
+
* (`GET /player/profile`). `profile` is null until one exists.
|
|
1661
|
+
*/
|
|
1662
|
+
profile() {
|
|
1663
|
+
return this.http.get("/player/profile");
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Patch the profile (`PATCH /player/profile`). At least one field; `null`
|
|
1667
|
+
* clears. Identity fields (`firstName`/`lastName`/`birthDate`/`country`)
|
|
1668
|
+
* throw `ConflictError` (`PROFILE_FIELD_LOCKED`) after KYC approval.
|
|
1669
|
+
*/
|
|
1670
|
+
updateProfile(patch) {
|
|
1671
|
+
return this.http.patch("/player/profile", { body: patch });
|
|
1672
|
+
}
|
|
1673
|
+
/** Preferences (locale, display currency, marketing consents, reality check). */
|
|
1674
|
+
getPreferences() {
|
|
1675
|
+
return this.http.get("/player/preferences");
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Update preferences (`PUT /player/preferences`, partial — at least one
|
|
1679
|
+
* field). Marketing changes are recorded on the consent trail; set
|
|
1680
|
+
* `realityCheckMinutes: null` to disable the reality check.
|
|
1681
|
+
*/
|
|
1682
|
+
setPreferences(patch) {
|
|
1683
|
+
return this.http.put("/player/preferences", { body: patch });
|
|
1684
|
+
}
|
|
1685
|
+
/** The append-only marketing-consent trail, newest first. */
|
|
1686
|
+
consents(query = {}) {
|
|
1687
|
+
return this.http.get("/player/consents", {
|
|
1688
|
+
query: { limit: query.limit, offset: query.offset }
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Active responsible-gaming limits, values in minor units (minutes for
|
|
1693
|
+
* `session_time`). `pending` carries a ratcheted increase/removal and when it
|
|
1694
|
+
* applies.
|
|
1695
|
+
*/
|
|
1696
|
+
async getLimits() {
|
|
1697
|
+
const res = await this.http.get("/player/limits");
|
|
1698
|
+
return res.limits.map(toLimit);
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Set/remove limits (`PUT /player/limits`). `value` in minor units (minutes
|
|
1702
|
+
* for `session_time`); `null` removes. New limits and decreases apply
|
|
1703
|
+
* immediately; increases/removals apply after the 24h ratchet cooldown
|
|
1704
|
+
* (returned in `pending`).
|
|
1705
|
+
*/
|
|
1706
|
+
async setLimits(limits) {
|
|
1707
|
+
const res = await this.http.put("/player/limits", {
|
|
1708
|
+
body: {
|
|
1709
|
+
limits: limits.map((l) => ({
|
|
1710
|
+
kind: l.kind,
|
|
1711
|
+
period: l.period,
|
|
1712
|
+
value: l.value === null ? null : toLimitString(l.value),
|
|
1713
|
+
currency: l.currency
|
|
1714
|
+
}))
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
return res.limits.map(toLimit);
|
|
1718
|
+
}
|
|
1719
|
+
/** Responsible-gaming actions beyond limits. */
|
|
1720
|
+
rg = {
|
|
1721
|
+
/**
|
|
1722
|
+
* Start a cooling-off period (blocks gameplay + deposits; login and
|
|
1723
|
+
* withdrawals stay). Extend-only — a shorter period than the active one is
|
|
1724
|
+
* a `ConflictError`.
|
|
1725
|
+
*/
|
|
1726
|
+
coolOff: (period) => this.http.post("/player/rg/cool-off", { body: { period } }),
|
|
1727
|
+
/**
|
|
1728
|
+
* Self-exclude. Irreversible player-side; revokes every session (including
|
|
1729
|
+
* this one). `until` is null for `"permanent"`.
|
|
1730
|
+
*/
|
|
1731
|
+
selfExclude: (period) => this.http.post("/player/rg/self-exclude", { body: { period } }),
|
|
1732
|
+
/** Acknowledge a reality-check prompt (audit trail only). */
|
|
1733
|
+
acknowledgeRealityCheck: (gameSessionId) => this.http.post("/player/rg/reality-check/ack", {
|
|
1734
|
+
body: gameSessionId !== void 0 ? { gameSessionId } : {}
|
|
1735
|
+
})
|
|
1736
|
+
};
|
|
1737
|
+
/** Presence signals. */
|
|
1738
|
+
presence = {
|
|
1739
|
+
/**
|
|
1740
|
+
* Tell the runtime this player is still here (`POST
|
|
1741
|
+
* /player/presence/heartbeat`, 204). Any authenticated request already
|
|
1742
|
+
* refreshes presence server-side — call this only for clients that idle
|
|
1743
|
+
* while "in play" (e.g. a game iframe generates provider callbacks, not
|
|
1744
|
+
* player API traffic). Best-effort on the server: a storage blip never
|
|
1745
|
+
* fails the request; presence decays via TTL and heals on the next call.
|
|
1746
|
+
*/
|
|
1747
|
+
heartbeat: () => this.http.post("/player/presence/heartbeat")
|
|
1748
|
+
};
|
|
1749
|
+
/** Account lifecycle. */
|
|
1750
|
+
account = {
|
|
1751
|
+
/**
|
|
1752
|
+
* Close the account (idempotent). Requires zero balances and no pending
|
|
1753
|
+
* withdrawal — otherwise `ConflictError` with code `BALANCE_REMAINING` or
|
|
1754
|
+
* `WITHDRAWAL_PENDING`. Revokes every session.
|
|
1755
|
+
*/
|
|
1756
|
+
close: (reason) => this.http.post("/player/account/close", {
|
|
1757
|
+
body: reason !== void 0 ? { reason } : {}
|
|
1758
|
+
}),
|
|
1759
|
+
/**
|
|
1760
|
+
* Request a GDPR data export (202; one active at a time — a second request
|
|
1761
|
+
* while pending/processing is a `ConflictError`). The download link is
|
|
1762
|
+
* delivered out-of-band.
|
|
1763
|
+
*/
|
|
1764
|
+
requestDataExport: () => this.http.post("/player/account/export"),
|
|
1765
|
+
/** Latest export request, or `{ status: "none" }` if never requested. */
|
|
1766
|
+
dataExportStatus: () => this.http.get("/player/account/export")
|
|
1767
|
+
};
|
|
1768
|
+
};
|
|
1769
|
+
function toLimit(raw) {
|
|
1770
|
+
return {
|
|
1771
|
+
kind: raw.kind,
|
|
1772
|
+
period: raw.period,
|
|
1773
|
+
value: minorStringToMinor(raw.value),
|
|
1774
|
+
currency: raw.currency,
|
|
1775
|
+
activeFrom: raw.activeFrom,
|
|
1776
|
+
pending: raw.pending ? {
|
|
1777
|
+
value: raw.pending.value === null ? null : minorStringToMinor(raw.pending.value),
|
|
1778
|
+
removal: raw.pending.removal,
|
|
1779
|
+
activeAt: raw.pending.activeAt
|
|
1780
|
+
} : null
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
function toLimitString(value) {
|
|
1784
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1785
|
+
throw new TypeError(
|
|
1786
|
+
`player.setLimits: value must be a positive safe integer (minor units, or minutes for session_time), got ${String(value)}`
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
return String(value);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/realtime/transport.ts
|
|
1793
|
+
function resolveWebSocket(injected) {
|
|
1794
|
+
if (injected) return injected;
|
|
1795
|
+
const g = globalThis;
|
|
1796
|
+
if (g.WebSocket) return g.WebSocket;
|
|
1797
|
+
throw new Error(
|
|
1798
|
+
"No WebSocket implementation available. In Node, pass `WebSocketImpl` (e.g. `import WebSocket from 'ws'`) in the realtime options."
|
|
1799
|
+
);
|
|
1800
|
+
}
|
|
1801
|
+
var WS_OPEN = 1;
|
|
1802
|
+
|
|
1803
|
+
// src/realtime/client.ts
|
|
1804
|
+
var DEFAULTS = { baseMs: 500, maxMs: 15e3, factor: 2, dedupeWindow: 512, heartbeatMs: 25e3 };
|
|
1805
|
+
var MAX_PENDING_CONNECT_ATTEMPTS = 5;
|
|
1806
|
+
var PONG_DEADLINE_INTERVALS = 2;
|
|
1807
|
+
var READY_DEADLINE_MS = 1e4;
|
|
1808
|
+
function createRealtimeClient(options) {
|
|
1809
|
+
return new RealtimeClientImpl(options);
|
|
1810
|
+
}
|
|
1811
|
+
var RealtimeClientImpl = class {
|
|
1812
|
+
constructor(options) {
|
|
1813
|
+
this.options = options;
|
|
1814
|
+
this.seen = new SeenSet(options.dedupeWindow ?? DEFAULTS.dedupeWindow);
|
|
1815
|
+
}
|
|
1816
|
+
options;
|
|
1817
|
+
state = "idle";
|
|
1818
|
+
socket = null;
|
|
1819
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1820
|
+
active = /* @__PURE__ */ new Set();
|
|
1821
|
+
/**
|
|
1822
|
+
* Channels the app declared via `subscribe()` (as opposed to interest implied by
|
|
1823
|
+
* an `on()` handler). A channel is torn down only when BOTH interests are gone:
|
|
1824
|
+
* `unsubscribe()` must not kill a channel that still has a handler, and removing
|
|
1825
|
+
* the last handler must not kill a channel the app explicitly subscribed.
|
|
1826
|
+
*/
|
|
1827
|
+
explicit = /* @__PURE__ */ new Set();
|
|
1828
|
+
seen;
|
|
1829
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
1830
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
1831
|
+
attempt = 0;
|
|
1832
|
+
lastPongAt = 0;
|
|
1833
|
+
heartbeatTimer = null;
|
|
1834
|
+
reconnectTimer = null;
|
|
1835
|
+
readyDeadlineTimer = null;
|
|
1836
|
+
intentionalClose = false;
|
|
1837
|
+
/**
|
|
1838
|
+
* Teardown epoch. `disconnect()` bumps it; an `open()` that suspended (awaiting
|
|
1839
|
+
* the auth credential) before the bump must abandon its cycle when it resumes —
|
|
1840
|
+
* otherwise it resurrects an authenticated "zombie" socket after logout.
|
|
1841
|
+
*/
|
|
1842
|
+
epoch = 0;
|
|
1843
|
+
lastOccurredAt;
|
|
1844
|
+
idCounter = 0;
|
|
1845
|
+
connectResolve = null;
|
|
1846
|
+
connectReject = null;
|
|
1847
|
+
connectPromise = null;
|
|
1848
|
+
connect() {
|
|
1849
|
+
if (this.state === "ready") return Promise.resolve();
|
|
1850
|
+
if (this.connectPromise) return this.connectPromise;
|
|
1851
|
+
this.intentionalClose = false;
|
|
1852
|
+
const pending = new Promise((resolve, reject) => {
|
|
1853
|
+
this.connectResolve = resolve;
|
|
1854
|
+
this.connectReject = reject;
|
|
1855
|
+
});
|
|
1856
|
+
const tracked = pending.finally(() => {
|
|
1857
|
+
if (this.connectPromise === tracked) this.connectPromise = null;
|
|
1858
|
+
});
|
|
1859
|
+
this.connectPromise = tracked;
|
|
1860
|
+
if (this.state !== "reconnecting") void this.open();
|
|
1861
|
+
return tracked;
|
|
1862
|
+
}
|
|
1863
|
+
async disconnect() {
|
|
1864
|
+
this.intentionalClose = true;
|
|
1865
|
+
this.epoch++;
|
|
1866
|
+
this.clearTimers();
|
|
1867
|
+
this.failConnect(new Error("realtime: disconnect() before the connection became ready"));
|
|
1868
|
+
if (this.socket) {
|
|
1869
|
+
try {
|
|
1870
|
+
this.socket.close(1e3, "client disconnect");
|
|
1871
|
+
} catch {
|
|
1872
|
+
}
|
|
1873
|
+
this.socket = null;
|
|
1874
|
+
}
|
|
1875
|
+
this.setState("closed");
|
|
1876
|
+
}
|
|
1877
|
+
on(channel, handler) {
|
|
1878
|
+
let set = this.handlers.get(channel);
|
|
1879
|
+
if (!set) {
|
|
1880
|
+
set = /* @__PURE__ */ new Set();
|
|
1881
|
+
this.handlers.set(channel, set);
|
|
1882
|
+
}
|
|
1883
|
+
set.add(handler);
|
|
1884
|
+
if (!this.active.has(channel)) this.markActive([channel]);
|
|
1885
|
+
return () => {
|
|
1886
|
+
const handlers = this.handlers.get(channel);
|
|
1887
|
+
handlers?.delete(handler);
|
|
1888
|
+
if (handlers && handlers.size === 0) {
|
|
1889
|
+
this.handlers.delete(channel);
|
|
1890
|
+
if (!this.explicit.has(channel)) this.teardown([channel]);
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
async subscribe(channels) {
|
|
1895
|
+
for (const c of channels) this.explicit.add(c);
|
|
1896
|
+
this.markActive(channels);
|
|
1897
|
+
}
|
|
1898
|
+
async unsubscribe(channels) {
|
|
1899
|
+
for (const c of channels) this.explicit.delete(c);
|
|
1900
|
+
this.teardown(channels.filter((c) => !this.handlers.get(c)?.size));
|
|
1901
|
+
}
|
|
1902
|
+
async withSubscription(channels, scope) {
|
|
1903
|
+
await this.subscribe(channels);
|
|
1904
|
+
try {
|
|
1905
|
+
return await scope();
|
|
1906
|
+
} finally {
|
|
1907
|
+
await this.unsubscribe(channels);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
activeChannels() {
|
|
1911
|
+
return [...this.active];
|
|
1912
|
+
}
|
|
1913
|
+
/** Register interest and subscribe on the wire when connected. */
|
|
1914
|
+
markActive(channels) {
|
|
1915
|
+
for (const c of channels) this.active.add(c);
|
|
1916
|
+
if (this.state === "ready") {
|
|
1917
|
+
this.send({ type: "subscribe", channels: [...channels], id: this.nextId() });
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
/** Drop interest and unsubscribe on the wire when connected. */
|
|
1921
|
+
teardown(channels) {
|
|
1922
|
+
if (channels.length === 0) return;
|
|
1923
|
+
for (const c of channels) this.active.delete(c);
|
|
1924
|
+
if (this.state === "ready") {
|
|
1925
|
+
this.send({ type: "unsubscribe", channels: [...channels], id: this.nextId() });
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
onStateChange(handler) {
|
|
1929
|
+
this.stateListeners.add(handler);
|
|
1930
|
+
return () => this.stateListeners.delete(handler);
|
|
1931
|
+
}
|
|
1932
|
+
onError(handler) {
|
|
1933
|
+
this.errorListeners.add(handler);
|
|
1934
|
+
return () => this.errorListeners.delete(handler);
|
|
1935
|
+
}
|
|
1936
|
+
// ── internals ────────────────────────────────────────────────────────────────
|
|
1937
|
+
async open() {
|
|
1938
|
+
const epoch = this.epoch;
|
|
1939
|
+
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
1940
|
+
let ticket;
|
|
1941
|
+
try {
|
|
1942
|
+
const cred = await this.options.getAuthCredential?.();
|
|
1943
|
+
if (cred && "ticket" in cred) ticket = cred.ticket;
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
if (epoch !== this.epoch) return;
|
|
1946
|
+
this.emitError({ code: "REALTIME_AUTH", message: err.message });
|
|
1947
|
+
this.failConnect(err instanceof Error ? err : new Error(String(err)));
|
|
1948
|
+
this.scheduleReconnect();
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (epoch !== this.epoch) return;
|
|
1952
|
+
let socket;
|
|
1953
|
+
try {
|
|
1954
|
+
const WebSocketImpl = resolveWebSocket(this.options.WebSocketImpl);
|
|
1955
|
+
socket = new WebSocketImpl(this.options.url);
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
this.failConnect(err);
|
|
1958
|
+
this.scheduleReconnect();
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
this.socket = socket;
|
|
1962
|
+
this.armReadyDeadline(socket);
|
|
1963
|
+
socket.onopen = () => {
|
|
1964
|
+
if (socket !== this.socket) return;
|
|
1965
|
+
if (ticket) this.send({ type: "auth", token: ticket });
|
|
1966
|
+
};
|
|
1967
|
+
socket.onmessage = (ev) => {
|
|
1968
|
+
if (socket !== this.socket) return;
|
|
1969
|
+
this.onMessage(ev.data);
|
|
1970
|
+
};
|
|
1971
|
+
socket.onerror = () => {
|
|
1972
|
+
if (socket !== this.socket) return;
|
|
1973
|
+
this.emitError({ code: "REALTIME_SOCKET", message: "socket error" });
|
|
1974
|
+
};
|
|
1975
|
+
socket.onclose = (ev) => this.onClose(socket, ev?.code);
|
|
1976
|
+
}
|
|
1977
|
+
onMessage(raw) {
|
|
1978
|
+
if (typeof raw !== "string") return;
|
|
1979
|
+
let frame;
|
|
1980
|
+
try {
|
|
1981
|
+
frame = JSON.parse(raw);
|
|
1982
|
+
} catch {
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
switch (frame.type) {
|
|
1986
|
+
case "ready":
|
|
1987
|
+
this.onReady(frame);
|
|
1988
|
+
break;
|
|
1989
|
+
case "event":
|
|
1990
|
+
this.onEvent(frame);
|
|
1991
|
+
break;
|
|
1992
|
+
case "error":
|
|
1993
|
+
this.emitError({
|
|
1994
|
+
code: frame.code,
|
|
1995
|
+
message: frame.message,
|
|
1996
|
+
...isRecord2(frame.details) ? { details: frame.details } : {}
|
|
1997
|
+
});
|
|
1998
|
+
break;
|
|
1999
|
+
case "reconnect":
|
|
2000
|
+
try {
|
|
2001
|
+
this.socket?.close(1e3, frame.reason);
|
|
2002
|
+
} catch {
|
|
2003
|
+
}
|
|
2004
|
+
break;
|
|
2005
|
+
case "pong":
|
|
2006
|
+
this.lastPongAt = Date.now();
|
|
2007
|
+
break;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
onReady(frame) {
|
|
2011
|
+
this.attempt = 0;
|
|
2012
|
+
this.clearReadyDeadline();
|
|
2013
|
+
this.setState("ready");
|
|
2014
|
+
this.startHeartbeat(frame.heartbeatMs || this.options.heartbeatMs || DEFAULTS.heartbeatMs);
|
|
2015
|
+
if (this.active.size > 0) {
|
|
2016
|
+
this.send({ type: "subscribe", channels: [...this.active], id: this.nextId() });
|
|
2017
|
+
}
|
|
2018
|
+
const ctx = this.lastOccurredAt !== void 0 ? { since: this.lastOccurredAt } : {};
|
|
2019
|
+
void Promise.resolve(this.options.resync?.(ctx)).catch(() => {
|
|
2020
|
+
});
|
|
2021
|
+
this.connectResolve?.();
|
|
2022
|
+
this.connectResolve = null;
|
|
2023
|
+
this.connectReject = null;
|
|
2024
|
+
}
|
|
2025
|
+
onEvent(frame) {
|
|
2026
|
+
if (this.seen.has(frame.eventId)) return;
|
|
2027
|
+
this.seen.add(frame.eventId);
|
|
2028
|
+
this.lastOccurredAt = frame.occurredAt;
|
|
2029
|
+
const handlers = this.handlers.get(frame.channel);
|
|
2030
|
+
if (!handlers) return;
|
|
2031
|
+
const event = frame;
|
|
2032
|
+
for (const handler of handlers) {
|
|
2033
|
+
try {
|
|
2034
|
+
handler(event);
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
this.emitError({ code: "REALTIME_HANDLER", message: err.message });
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
onClose(socket, code) {
|
|
2041
|
+
if (socket !== this.socket) return;
|
|
2042
|
+
this.clearTimers();
|
|
2043
|
+
this.socket = null;
|
|
2044
|
+
if (this.intentionalClose) {
|
|
2045
|
+
this.setState("closed");
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
this.scheduleReconnect(code);
|
|
2049
|
+
}
|
|
2050
|
+
scheduleReconnect(_code) {
|
|
2051
|
+
if (this.intentionalClose) return;
|
|
2052
|
+
this.setState("reconnecting");
|
|
2053
|
+
if (this.connectReject && this.attempt + 1 >= MAX_PENDING_CONNECT_ATTEMPTS) {
|
|
2054
|
+
this.failConnect(
|
|
2055
|
+
new Error(
|
|
2056
|
+
`realtime: connection not ready after ${MAX_PENDING_CONNECT_ATTEMPTS} attempts (still retrying in the background)`
|
|
2057
|
+
)
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
const delay = this.backoffDelay(this.attempt++);
|
|
2061
|
+
this.reconnectTimer = setTimeout(() => void this.open(), delay);
|
|
2062
|
+
}
|
|
2063
|
+
backoffDelay(attempt) {
|
|
2064
|
+
const { baseMs, maxMs, factor } = {
|
|
2065
|
+
baseMs: this.options.backoff?.baseMs ?? DEFAULTS.baseMs,
|
|
2066
|
+
maxMs: this.options.backoff?.maxMs ?? DEFAULTS.maxMs,
|
|
2067
|
+
factor: this.options.backoff?.factor ?? DEFAULTS.factor
|
|
2068
|
+
};
|
|
2069
|
+
const ceiling = Math.min(maxMs, baseMs * factor ** attempt);
|
|
2070
|
+
return Math.floor(Math.random() * ceiling);
|
|
2071
|
+
}
|
|
2072
|
+
startHeartbeat(intervalMs) {
|
|
2073
|
+
this.clearHeartbeat();
|
|
2074
|
+
this.lastPongAt = Date.now();
|
|
2075
|
+
this.heartbeatTimer = setInterval(() => {
|
|
2076
|
+
const socket = this.socket;
|
|
2077
|
+
if (!socket || socket.readyState !== WS_OPEN) return;
|
|
2078
|
+
if (Date.now() - this.lastPongAt >= intervalMs * PONG_DEADLINE_INTERVALS) {
|
|
2079
|
+
try {
|
|
2080
|
+
socket.close(4e3, "pong deadline");
|
|
2081
|
+
} catch {
|
|
2082
|
+
}
|
|
2083
|
+
this.onClose(socket);
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
this.send({ type: "ping" });
|
|
2087
|
+
}, intervalMs);
|
|
2088
|
+
}
|
|
2089
|
+
clearHeartbeat() {
|
|
2090
|
+
if (this.heartbeatTimer) {
|
|
2091
|
+
clearInterval(this.heartbeatTimer);
|
|
2092
|
+
this.heartbeatTimer = null;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
/** Reap a socket that upgrades but never becomes `ready` (see READY_DEADLINE_MS). */
|
|
2096
|
+
armReadyDeadline(socket) {
|
|
2097
|
+
this.clearReadyDeadline();
|
|
2098
|
+
const deadlineMs = this.options.heartbeatMs ?? READY_DEADLINE_MS;
|
|
2099
|
+
this.readyDeadlineTimer = setTimeout(() => {
|
|
2100
|
+
if (socket !== this.socket || this.state === "ready") return;
|
|
2101
|
+
try {
|
|
2102
|
+
socket.close(4e3, "ready deadline");
|
|
2103
|
+
} catch {
|
|
2104
|
+
}
|
|
2105
|
+
this.onClose(socket);
|
|
2106
|
+
}, deadlineMs);
|
|
2107
|
+
}
|
|
2108
|
+
clearReadyDeadline() {
|
|
2109
|
+
if (this.readyDeadlineTimer) {
|
|
2110
|
+
clearTimeout(this.readyDeadlineTimer);
|
|
2111
|
+
this.readyDeadlineTimer = null;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
clearTimers() {
|
|
2115
|
+
this.clearHeartbeat();
|
|
2116
|
+
this.clearReadyDeadline();
|
|
2117
|
+
if (this.reconnectTimer) {
|
|
2118
|
+
clearTimeout(this.reconnectTimer);
|
|
2119
|
+
this.reconnectTimer = null;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
send(frame) {
|
|
2123
|
+
if (!this.socket || this.socket.readyState !== WS_OPEN) return;
|
|
2124
|
+
try {
|
|
2125
|
+
this.socket.send(JSON.stringify(frame));
|
|
2126
|
+
} catch (err) {
|
|
2127
|
+
this.emitError({ code: "REALTIME_SEND", message: err.message });
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
setState(state) {
|
|
2131
|
+
if (this.state === state) return;
|
|
2132
|
+
this.state = state;
|
|
2133
|
+
for (const l of this.stateListeners) {
|
|
2134
|
+
try {
|
|
2135
|
+
l(state);
|
|
2136
|
+
} catch {
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
emitError(error) {
|
|
2141
|
+
for (const l of this.errorListeners) {
|
|
2142
|
+
try {
|
|
2143
|
+
l(error);
|
|
2144
|
+
} catch {
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
failConnect(err) {
|
|
2149
|
+
this.connectReject?.(err);
|
|
2150
|
+
this.connectResolve = null;
|
|
2151
|
+
this.connectReject = null;
|
|
2152
|
+
this.connectPromise = null;
|
|
2153
|
+
}
|
|
2154
|
+
nextId() {
|
|
2155
|
+
return `c${++this.idCounter}`;
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
var SeenSet = class {
|
|
2159
|
+
constructor(max) {
|
|
2160
|
+
this.max = max;
|
|
2161
|
+
}
|
|
2162
|
+
max;
|
|
2163
|
+
set = /* @__PURE__ */ new Set();
|
|
2164
|
+
has(id) {
|
|
2165
|
+
return this.set.has(id);
|
|
2166
|
+
}
|
|
2167
|
+
add(id) {
|
|
2168
|
+
this.set.add(id);
|
|
2169
|
+
if (this.set.size > this.max) {
|
|
2170
|
+
const oldest = this.set.values().next().value;
|
|
2171
|
+
if (oldest !== void 0) this.set.delete(oldest);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
function isRecord2(v) {
|
|
2176
|
+
return typeof v === "object" && v !== null;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// src/modules/realtime.ts
|
|
2180
|
+
var RealtimeModule = class {
|
|
2181
|
+
constructor(http, wsUrl, options = {}) {
|
|
2182
|
+
this.http = http;
|
|
2183
|
+
this.wsUrl = wsUrl;
|
|
2184
|
+
this.options = options;
|
|
2185
|
+
}
|
|
2186
|
+
http;
|
|
2187
|
+
wsUrl;
|
|
2188
|
+
options;
|
|
2189
|
+
client = null;
|
|
2190
|
+
/** Fetch a single-use socket ticket from the gateway. */
|
|
2191
|
+
ticket() {
|
|
2192
|
+
return this.http.post("/realtime/ticket");
|
|
2193
|
+
}
|
|
2194
|
+
ensure() {
|
|
2195
|
+
if (this.client) return this.client;
|
|
2196
|
+
this.client = createRealtimeClient({
|
|
2197
|
+
url: this.wsUrl,
|
|
2198
|
+
getAuthCredential: async () => {
|
|
2199
|
+
const { ticket } = await this.ticket();
|
|
2200
|
+
return { ticket };
|
|
2201
|
+
},
|
|
2202
|
+
...this.options.resync ? { resync: this.options.resync } : {},
|
|
2203
|
+
...this.options.backoff ? { backoff: this.options.backoff } : {},
|
|
2204
|
+
...this.options.heartbeatMs !== void 0 ? { heartbeatMs: this.options.heartbeatMs } : {},
|
|
2205
|
+
...this.options.dedupeWindow !== void 0 ? { dedupeWindow: this.options.dedupeWindow } : {},
|
|
2206
|
+
...this.options.WebSocketImpl ? { WebSocketImpl: this.options.WebSocketImpl } : {}
|
|
2207
|
+
});
|
|
2208
|
+
return this.client;
|
|
2209
|
+
}
|
|
2210
|
+
get state() {
|
|
2211
|
+
return this.client?.state ?? "idle";
|
|
2212
|
+
}
|
|
2213
|
+
/** Open the socket and authenticate. Call once after login. */
|
|
2214
|
+
connect() {
|
|
2215
|
+
return this.ensure().connect();
|
|
2216
|
+
}
|
|
2217
|
+
/** Close intentionally (call on logout). Stops auto-reconnect. */
|
|
2218
|
+
async disconnect() {
|
|
2219
|
+
if (this.client) await this.client.disconnect();
|
|
2220
|
+
}
|
|
2221
|
+
/** Subscribe to a channel with a typed handler. Returns an unsubscribe fn. */
|
|
2222
|
+
on(channel, handler) {
|
|
2223
|
+
return this.ensure().on(channel, handler);
|
|
2224
|
+
}
|
|
2225
|
+
subscribe(channels) {
|
|
2226
|
+
return this.ensure().subscribe(channels);
|
|
2227
|
+
}
|
|
2228
|
+
unsubscribe(channels) {
|
|
2229
|
+
return this.ensure().unsubscribe(channels);
|
|
2230
|
+
}
|
|
2231
|
+
withSubscription(channels, scope) {
|
|
2232
|
+
return this.ensure().withSubscription(channels, scope);
|
|
2233
|
+
}
|
|
2234
|
+
activeChannels() {
|
|
2235
|
+
return this.client?.activeChannels() ?? [];
|
|
2236
|
+
}
|
|
2237
|
+
onStateChange(handler) {
|
|
2238
|
+
return this.ensure().onStateChange(handler);
|
|
2239
|
+
}
|
|
2240
|
+
onError(handler) {
|
|
2241
|
+
return this.ensure().onError(handler);
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
// src/client.ts
|
|
2246
|
+
function createCasinoClient(config) {
|
|
2247
|
+
const resolved = resolveConfig(config);
|
|
2248
|
+
const http = new HttpClient(resolved);
|
|
2249
|
+
const session = new Session(resolved.defaultCurrency);
|
|
2250
|
+
const auth = new AuthModule(http, session, resolved.baseUrl);
|
|
2251
|
+
http.setRefreshHandler(async () => {
|
|
2252
|
+
await auth.refresh();
|
|
2253
|
+
});
|
|
2254
|
+
const ext = createExtModule(http);
|
|
2255
|
+
http.setErrorEnricher(async (error) => {
|
|
2256
|
+
if (!(error instanceof FlowDelegatedError) || !error.pluginKey || error.flow === void 0) {
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const cat = await ext.catalog();
|
|
2260
|
+
const entry = cat.plugins.find((p) => p.pluginKey === error.pluginKey);
|
|
2261
|
+
const action = entry?.actions.find((a) => a.key === error.flow);
|
|
2262
|
+
if (action) error.continueWith = { actionKey: action.key };
|
|
2263
|
+
});
|
|
2264
|
+
return {
|
|
2265
|
+
affiliate: new AffiliateModule(http),
|
|
2266
|
+
auth,
|
|
2267
|
+
cashier: new CashierModule(http, session),
|
|
2268
|
+
wallet: new WalletModule(http, session),
|
|
2269
|
+
catalog: new CatalogModule(http),
|
|
2270
|
+
ext,
|
|
2271
|
+
games: new GamesModule(http, session),
|
|
2272
|
+
kyc: new KycModule(http),
|
|
2273
|
+
player: new PlayerModule(http, auth),
|
|
2274
|
+
realtime: new RealtimeModule(http, resolved.wsUrl, config.realtime ?? {}),
|
|
2275
|
+
getSession: () => session.snapshot(),
|
|
2276
|
+
withCookies: (jar) => createCasinoClient({ ...config, cookieJar: jar })
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// src/realtime/contract.ts
|
|
2281
|
+
function isExtChannel(value) {
|
|
2282
|
+
return value.startsWith("ext.") && value.length > 4;
|
|
2283
|
+
}
|
|
2284
|
+
var REALTIME_CHANNELS = [
|
|
2285
|
+
"wallet.balance",
|
|
2286
|
+
"wallet.deposit",
|
|
2287
|
+
"wallet.withdrawal",
|
|
2288
|
+
"gaming",
|
|
2289
|
+
"bonus",
|
|
2290
|
+
"player"
|
|
2291
|
+
];
|
|
2292
|
+
|
|
2293
|
+
// src/index.ts
|
|
2294
|
+
var RUNTIME_API_VERSION = "2026-07-27";
|
|
2295
|
+
|
|
2296
|
+
export { AuthError, COOKIE, CasinoSdkError, ConflictError, CookieJar, FlowDelegatedError, ForbiddenError, HEADER, InsufficientFundsError, LimitExceededError, MONEY_SCALE, NetworkError, NotFoundError, NotImplementedError, OperationDeniedError, PluginActionNotFoundError, PluginNotEnabledError, PluginUnavailableError, PluginVersionMismatchError, REALTIME_CHANNELS, RUNTIME_API_VERSION, RateLimitError, RgBlockedError, ServerError, UnprocessableError, ValidationError, assertLaunchUrl, assertPluginVersion, checkJsonSchema, createCasinoClient, decimalToMinor, formatMoney, generateIdempotencyKey, isAppErrorEnvelope, isExtChannel, minorStringToMinor, minorToAmount, minorToDecimal };
|
|
2297
|
+
//# sourceMappingURL=index.js.map
|
|
2298
|
+
//# sourceMappingURL=index.js.map
|