@taphubhq/sdk-core 0.25.6 → 0.26.1

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/dist/index.js CHANGED
@@ -151,6 +151,36 @@ var AgencyPairModule = class {
151
151
  }
152
152
  };
153
153
 
154
+ // src/transport/graphql/wrongRegionRetry.ts
155
+ var WRONG_REGION_CODE = "Auth_WrongRegion";
156
+ function wrongRegionTarget(err) {
157
+ if (!(err instanceof TaphubError) || err.code !== WRONG_REGION_CODE) return null;
158
+ const details = err.details;
159
+ const meta = details?.errors?.[0]?.extensions?.meta;
160
+ const homeRegion = meta?.homeRegion;
161
+ return typeof homeRegion === "string" && homeRegion !== "" ? homeRegion : null;
162
+ }
163
+ function withWrongRegionRetry(transport, setRegion) {
164
+ async function retryOnce(call) {
165
+ try {
166
+ return await call();
167
+ } catch (err) {
168
+ const homeRegion = wrongRegionTarget(err);
169
+ if (homeRegion === null) throw err;
170
+ setRegion(homeRegion);
171
+ return await call();
172
+ }
173
+ }
174
+ return {
175
+ request(query, variables, opts) {
176
+ return retryOnce(() => transport.request(query, variables, opts));
177
+ },
178
+ publicRequest(query, variables, opts) {
179
+ return retryOnce(() => transport.publicRequest(query, variables, opts));
180
+ }
181
+ };
182
+ }
183
+
154
184
  // src/modules/auth/normalise.ts
155
185
  function normaliseGoogleResponse(body) {
156
186
  return {
@@ -222,6 +252,7 @@ function normaliseUserLoginResponse(body) {
222
252
  throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
223
253
  }
224
254
  const wallet = node.user.defaultWallet;
255
+ const homeRegion = node.homeRegion ?? void 0;
225
256
  return {
226
257
  accessToken: node.accessToken,
227
258
  user: {
@@ -236,9 +267,17 @@ function normaliseUserLoginResponse(body) {
236
267
  isEnabled: wallet.isEnable
237
268
  }
238
269
  },
239
- isDemo: false
270
+ isDemo: false,
271
+ homeRegion
240
272
  };
241
273
  }
274
+ function normaliseRefreshResponse(body) {
275
+ const node = body.refreshTapHubToken;
276
+ if (!node || typeof node.accessToken !== "string" || node.accessToken === "" || typeof node.refreshToken !== "string" || node.refreshToken === "") {
277
+ throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
278
+ }
279
+ return { taphubToken: node.accessToken, refreshToken: node.refreshToken };
280
+ }
242
281
  function validateLoginBody(body) {
243
282
  if (typeof body.access_token !== "string" || body.access_token === "") {
244
283
  throw new TaphubServerError("Invalid response from server", {
@@ -267,10 +306,21 @@ var CREATE_DEMO_USER_MUTATION = `
267
306
  }
268
307
  }
269
308
  `;
309
+ var REFRESH_TAPHUB_TOKEN_MUTATION = `
310
+ mutation refreshTapHubToken($refreshToken: String!, $clientMeta: JSON) {
311
+ refreshTapHubToken(refreshToken: $refreshToken, clientMeta: $clientMeta) {
312
+ accessToken
313
+ refreshToken
314
+ }
315
+ }
316
+ `;
270
317
  var USER_LOGIN_MUTATION = `
271
318
  mutation UserLogin($input: LoginInput!) {
272
319
  userLogin(input: $input) {
273
320
  accessToken
321
+ # multi-geo (infra-260715): home region sourced from the global region-directory
322
+ # (first join stores the supplied region; the stored region wins thereafter).
323
+ homeRegion
274
324
  user {
275
325
  id
276
326
  agencyUid
@@ -292,16 +342,32 @@ var AuthModule = class {
292
342
  #rest;
293
343
  #graphql;
294
344
  #graphqlUser;
345
+ #graphqlUserData;
295
346
  #setToken;
347
+ #isDemo;
296
348
  #agencyId;
349
+ #getRegion;
350
+ #setRegion;
297
351
  #onLoginSuccess;
298
352
  #onLogout;
353
+ /**
354
+ * Single-flight cache for {@link refreshTapHubToken}, keyed by the region the
355
+ * in-flight refresh was started under. Concurrent callers in the SAME region
356
+ * share the one network round-trip; a region change mid-flight (getRegion now
357
+ * differs) means the next caller starts a FRESH refresh targeting the new
358
+ * region rather than reusing a promise bound to the old cluster (task 2.3).
359
+ */
360
+ #refreshInflight = null;
299
361
  constructor(deps) {
300
362
  this.#rest = deps.rest;
301
363
  this.#graphql = deps.graphql;
302
364
  this.#graphqlUser = deps.graphqlUser;
365
+ this.#graphqlUserData = deps.graphqlUserData ?? deps.graphqlUser;
303
366
  this.#setToken = deps.setToken;
367
+ this.#isDemo = deps.isDemo ?? (() => false);
304
368
  this.#agencyId = deps.agencyId;
369
+ this.#getRegion = deps.getRegion;
370
+ this.#setRegion = deps.setRegion;
305
371
  this.#onLoginSuccess = deps.onLoginSuccess;
306
372
  this.#onLogout = deps.onLogout;
307
373
  }
@@ -356,14 +422,35 @@ var AuthModule = class {
356
422
  return result;
357
423
  }
358
424
  async loginWithSession(sessionToken, opts) {
359
- const variables = { input: { sessionToken, agencyId: this.#agencyId } };
425
+ const currentRegion = opts?.region ?? this.#getRegion?.() ?? void 0;
426
+ try {
427
+ return await this.#sendLogin(sessionToken, currentRegion, opts);
428
+ } catch (err) {
429
+ const homeRegion = wrongRegionTarget(err);
430
+ if (homeRegion === null || !this.#setRegion) throw err;
431
+ this.#setRegion(homeRegion);
432
+ return await this.#sendLogin(sessionToken, homeRegion, opts);
433
+ }
434
+ }
435
+ /**
436
+ * Single userLogin attempt against the (region-resolved) user-service endpoint.
437
+ * On success it stamps the token + home region and fires onLoginSuccess. Split
438
+ * out so {@link loginWithSession} can re-invoke it once after an
439
+ * Auth_WrongRegion region switch without duplicating the post-login wiring.
440
+ */
441
+ async #sendLogin(sessionToken, region, opts) {
442
+ const input = { sessionToken, agencyId: this.#agencyId };
443
+ if (region !== void 0 && region !== null) {
444
+ input.region = region;
445
+ }
446
+ const variables = { input };
360
447
  const body = await this.#graphqlUser.request(
361
448
  USER_LOGIN_MUTATION,
362
449
  variables,
363
450
  opts
364
451
  );
365
452
  const result = normaliseUserLoginResponse(body);
366
- this.#setToken(result.accessToken, { isDemo: false });
453
+ this.#setToken(result.accessToken, { isDemo: false, homeRegion: result.homeRegion });
367
454
  if (this.#onLoginSuccess) {
368
455
  try {
369
456
  await this.#onLoginSuccess();
@@ -372,6 +459,60 @@ var AuthModule = class {
372
459
  }
373
460
  return result;
374
461
  }
462
+ /**
463
+ * Exchange a refresh token for a rotated (taphubToken, refreshToken) pair via
464
+ * the user-service (region-routed through `graphqlUserData`, so a user pinned
465
+ * to a non-default region refreshes against THEIR cluster — the bug this change
466
+ * fixes). On success the SDK's own token is updated and the rotated pair is
467
+ * returned so the caller can persist it (rotation contract: present the NEW
468
+ * refresh token next time).
469
+ *
470
+ * - **Demo-skips-refresh:** demo users have no refresh path and reject with
471
+ * `Refresh_DemoNotSupported` (no network call). The caller reacts by clearing
472
+ * auth / re-creating the demo user.
473
+ * - **Single-flight:** concurrent callers share one in-flight request, keyed by
474
+ * the current region. A region change mid-flight re-targets the next caller.
475
+ * - **Wrong-region backstop:** `graphqlUserData` retries once on
476
+ * `Auth_WrongRegion`, so a stale region self-corrects.
477
+ *
478
+ * Invalid/expired/reused refresh tokens surface as the transport's typed error
479
+ * (e.g. `ErrRefreshTokenInvalid`); network failures surface as
480
+ * `TaphubNetworkError`. The caller decides how to react (clear vs retry-later).
481
+ */
482
+ refreshTapHubToken(refreshToken, opts) {
483
+ if (this.#isDemo()) {
484
+ return Promise.reject(
485
+ new TaphubValidationError("demo users have no refresh path", {
486
+ code: "Refresh_DemoNotSupported"
487
+ })
488
+ );
489
+ }
490
+ const region = this.#getRegion?.() ?? null;
491
+ if (this.#refreshInflight && this.#refreshInflight.region === region) {
492
+ return this.#refreshInflight.promise;
493
+ }
494
+ const promise = this.#doRefresh(refreshToken, opts).finally(() => {
495
+ if (this.#refreshInflight?.promise === promise) {
496
+ this.#refreshInflight = null;
497
+ }
498
+ });
499
+ this.#refreshInflight = { region, promise };
500
+ return promise;
501
+ }
502
+ async #doRefresh(refreshToken, opts) {
503
+ const variables = { refreshToken };
504
+ if (opts?.clientMeta !== void 0) {
505
+ variables.clientMeta = opts.clientMeta;
506
+ }
507
+ const body = await this.#graphqlUserData.request(
508
+ REFRESH_TAPHUB_TOKEN_MUTATION,
509
+ variables,
510
+ opts?.signal ? { signal: opts.signal } : void 0
511
+ );
512
+ const result = normaliseRefreshResponse(body);
513
+ this.#setToken(result.taphubToken, { isDemo: false });
514
+ return result;
515
+ }
375
516
  async logout() {
376
517
  this.#setToken(null);
377
518
  if (this.#onLogout) {
@@ -1036,18 +1177,30 @@ function normaliseCandles(list) {
1036
1177
  coefMults: c.coefMults
1037
1178
  }));
1038
1179
  }
1180
+ function isAgencyComposite(id) {
1181
+ return id.includes(":");
1182
+ }
1183
+ function stripAgencyPrefix(id) {
1184
+ return id.slice(id.indexOf(":") + 1);
1185
+ }
1039
1186
  function normalisePairInfo(node) {
1040
1187
  if (typeof node.id !== "string" || node.id === "" || typeof node.pair !== "string" || node.pair === "") {
1041
1188
  throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
1042
1189
  }
1190
+ const composite = isAgencyComposite(node.id);
1043
1191
  return {
1044
- id: node.id,
1192
+ id: composite ? stripAgencyPrefix(node.id) : node.id,
1045
1193
  pair: node.pair,
1046
- gameplayId: node.gameplayId,
1047
- gameplayName: node.gameplayName,
1194
+ // available-pairs-retired-fields: the server no longer sends these; default
1195
+ // rather than emit `undefined` through a non-optional entity property.
1196
+ gameplayId: node.gameplayId ?? "",
1197
+ gameplayName: node.gameplayName ?? "",
1048
1198
  source: node.source,
1049
1199
  // REVIEW[bid-260602]: maps node.agencyPairId (was: node.gameId)
1050
- agencyPairId: node.agencyPairId ?? null
1200
+ // Fall back to the wire `id` only when it IS the composite — otherwise stay
1201
+ // null, as the retired field did, so nobody builds an MQTT topic out of a
1202
+ // catalog id.
1203
+ agencyPairId: node.agencyPairId ?? (composite ? node.id : null)
1051
1204
  };
1052
1205
  }
1053
1206
 
@@ -1073,10 +1226,7 @@ var BUILDER_AVAILABLE_GAME_PAIRS_QUERY = `query BuilderAvailableGamePairs($gamep
1073
1226
  builderAvailableGamePairs(gameplayId: $gameplayId) {
1074
1227
  id
1075
1228
  pair
1076
- gameplayId
1077
- gameplayName
1078
1229
  source
1079
- agencyPairId
1080
1230
  }
1081
1231
  }`;
1082
1232
 
@@ -1121,8 +1271,11 @@ var PairModule = class {
1121
1271
  /**
1122
1272
  * Returns available game pairs, optionally filtered by gameplay.
1123
1273
  *
1124
- * When called with a valid JWT (authenticated builder), each entry includes
1125
- * `gameId` use it directly as the MQTT topic `game/{gameId}/candle`.
1274
+ * Each entry's `id` is the bare catalog pair id, safe to pass straight back
1275
+ * as a `pairId` argument. When called with a valid JWT (authenticated
1276
+ * builder), `agencyPairId` additionally carries the agency composite — use
1277
+ * THAT one as the MQTT topic `game/{gameId}/candle`. It is `null` for
1278
+ * anonymous callers.
1126
1279
  *
1127
1280
  * @example
1128
1281
  * const pairs = await client.pair.availableGamePairs({ gameplayId: 'taptrading' });
@@ -1172,6 +1325,9 @@ function walletBalanceTopic(userId) {
1172
1325
  function agencyPairStatsTopic(aid, pairId) {
1173
1326
  return `public/agency/${aid}/pair/${pairId}/stats`;
1174
1327
  }
1328
+ function userMigrationTopic(userId) {
1329
+ return `user/${userId}/migration`;
1330
+ }
1175
1331
  function userBidsWildcardTopic(userId) {
1176
1332
  return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
1177
1333
  }
@@ -1198,12 +1354,14 @@ function userScopedTopicsFor(gameId, userId) {
1198
1354
  return USER_SCOPED_SUFFIXES.map((s) => topicFor(gameId, s, userId));
1199
1355
  }
1200
1356
  function createMqttTransport(endpoint, opts = {}) {
1357
+ const resolveEndpoint = () => typeof endpoint === "function" ? endpoint() : endpoint;
1201
1358
  let client = null;
1202
1359
  const subscriptions = /* @__PURE__ */ new Map();
1203
1360
  const candleSubscriptions = /* @__PURE__ */ new Map();
1204
1361
  const statsSubscriptions = /* @__PURE__ */ new Map();
1205
1362
  const walletSubscriptions = /* @__PURE__ */ new Map();
1206
1363
  const userBidsSubscriptions = /* @__PURE__ */ new Map();
1364
+ const migrationSubscriptions = /* @__PURE__ */ new Map();
1207
1365
  const { onLifecycle, auth } = opts;
1208
1366
  let connectStartedAt = 0;
1209
1367
  function fireLifecycle(event) {
@@ -1216,7 +1374,7 @@ function createMqttTransport(endpoint, opts = {}) {
1216
1374
  function ensureConnected() {
1217
1375
  if (client) return client;
1218
1376
  connectStartedAt = Date.now();
1219
- client = mqtt.connect(endpoint, {
1377
+ client = mqtt.connect(resolveEndpoint(), {
1220
1378
  clean: true,
1221
1379
  reconnectPeriod: 2e3,
1222
1380
  connectTimeout: 1e4,
@@ -1257,6 +1415,9 @@ function createMqttTransport(endpoint, opts = {}) {
1257
1415
  for (const entry of userBidsSubscriptions.values()) {
1258
1416
  c.subscribe(entry.pattern, { qos: 1 });
1259
1417
  }
1418
+ for (const entry of migrationSubscriptions.values()) {
1419
+ c.subscribe(entry.topic, { qos: 1 });
1420
+ }
1260
1421
  fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
1261
1422
  });
1262
1423
  client.on("reconnect", () => {
@@ -1305,6 +1466,20 @@ function createMqttTransport(endpoint, opts = {}) {
1305
1466
  walletSub.onMessage(receivedTopic, payload2);
1306
1467
  return;
1307
1468
  }
1469
+ const migrationSub = [...migrationSubscriptions.values()].find(
1470
+ (s) => s.topic === receivedTopic
1471
+ );
1472
+ if (migrationSub) {
1473
+ let payload2;
1474
+ try {
1475
+ payload2 = JSON.parse(message.toString());
1476
+ } catch {
1477
+ migrationSub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
1478
+ return;
1479
+ }
1480
+ migrationSub.onMessage(receivedTopic, payload2);
1481
+ return;
1482
+ }
1308
1483
  let userBidsPayload;
1309
1484
  for (const sub of userBidsSubscriptions.values()) {
1310
1485
  if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
@@ -1418,6 +1593,19 @@ function createMqttTransport(endpoint, opts = {}) {
1418
1593
  if (client) client.unsubscribe(entry.pattern);
1419
1594
  userBidsSubscriptions.delete(userId);
1420
1595
  },
1596
+ subscribeMigration(userId, onMessage, onError) {
1597
+ if (migrationSubscriptions.has(userId)) return;
1598
+ const topic = userMigrationTopic(userId);
1599
+ const mqttClient = ensureConnected();
1600
+ migrationSubscriptions.set(userId, { topic, onMessage, onError });
1601
+ mqttClient.subscribe(topic, { qos: 1 });
1602
+ },
1603
+ unsubscribeMigration(userId) {
1604
+ const entry = migrationSubscriptions.get(userId);
1605
+ if (!entry) return;
1606
+ if (client) client.unsubscribe(entry.topic);
1607
+ migrationSubscriptions.delete(userId);
1608
+ },
1421
1609
  unsubscribeAll(gameId, userId) {
1422
1610
  const matches = entriesForGame(gameId);
1423
1611
  if (matches.length === 0) return;
@@ -1442,6 +1630,12 @@ function createMqttTransport(endpoint, opts = {}) {
1442
1630
  }
1443
1631
  subscriptions.delete(subKey(target.gameId, target.userId));
1444
1632
  },
1633
+ reconnect() {
1634
+ if (!client) return;
1635
+ client.end(true);
1636
+ client = null;
1637
+ ensureConnected();
1638
+ },
1445
1639
  close() {
1446
1640
  if (client) {
1447
1641
  for (const sub of subscriptions.values()) {
@@ -1461,12 +1655,16 @@ function createMqttTransport(endpoint, opts = {}) {
1461
1655
  for (const entry of userBidsSubscriptions.values()) {
1462
1656
  client.unsubscribe(entry.pattern);
1463
1657
  }
1658
+ for (const entry of migrationSubscriptions.values()) {
1659
+ client.unsubscribe(entry.topic);
1660
+ }
1464
1661
  }
1465
1662
  subscriptions.clear();
1466
1663
  candleSubscriptions.clear();
1467
1664
  statsSubscriptions.clear();
1468
1665
  walletSubscriptions.clear();
1469
1666
  userBidsSubscriptions.clear();
1667
+ migrationSubscriptions.clear();
1470
1668
  if (client) {
1471
1669
  client.end(true);
1472
1670
  client = null;
@@ -1476,7 +1674,7 @@ function createMqttTransport(endpoint, opts = {}) {
1476
1674
  }
1477
1675
 
1478
1676
  // src/modules/realtime/index.ts
1479
- import EventEmitter4 from "eventemitter3";
1677
+ import EventEmitter5 from "eventemitter3";
1480
1678
 
1481
1679
  // src/modules/realtime/GameChannel.ts
1482
1680
  import EventEmitter from "eventemitter3";
@@ -1488,9 +1686,9 @@ var GameChannel = class extends EventEmitter {
1488
1686
  }
1489
1687
  };
1490
1688
 
1491
- // src/modules/realtime/UserBidsChannel.ts
1689
+ // src/modules/realtime/MigrationChannel.ts
1492
1690
  import EventEmitter2 from "eventemitter3";
1493
- var UserBidsChannel = class extends EventEmitter2 {
1691
+ var MigrationChannel = class extends EventEmitter2 {
1494
1692
  userId;
1495
1693
  constructor(userId) {
1496
1694
  super();
@@ -1498,9 +1696,19 @@ var UserBidsChannel = class extends EventEmitter2 {
1498
1696
  }
1499
1697
  };
1500
1698
 
1501
- // src/modules/realtime/WalletChannel.ts
1699
+ // src/modules/realtime/UserBidsChannel.ts
1502
1700
  import EventEmitter3 from "eventemitter3";
1503
- var WalletChannel = class extends EventEmitter3 {
1701
+ var UserBidsChannel = class extends EventEmitter3 {
1702
+ userId;
1703
+ constructor(userId) {
1704
+ super();
1705
+ this.userId = userId;
1706
+ }
1707
+ };
1708
+
1709
+ // src/modules/realtime/WalletChannel.ts
1710
+ import EventEmitter4 from "eventemitter3";
1711
+ var WalletChannel = class extends EventEmitter4 {
1504
1712
  userId;
1505
1713
  constructor(userId) {
1506
1714
  super();
@@ -1591,6 +1799,19 @@ function mapWireWalletBalance(raw) {
1591
1799
  reason: p.reason ?? ""
1592
1800
  };
1593
1801
  }
1802
+ function mapWireMigrationCompleted(raw) {
1803
+ if (raw === null || typeof raw !== "object") return null;
1804
+ const p = raw;
1805
+ if (p.type !== "migration_completed") return null;
1806
+ if (typeof p.toRegion !== "string" || p.toRegion === "") return null;
1807
+ return {
1808
+ type: "migration_completed",
1809
+ migrationId: p.migrationId,
1810
+ fromRegion: p.fromRegion,
1811
+ toRegion: p.toRegion,
1812
+ completedAt: p.completedAt
1813
+ };
1814
+ }
1594
1815
  function mapWireConfig(raw) {
1595
1816
  const p = raw;
1596
1817
  return {
@@ -1631,13 +1852,15 @@ function mapWireToEvent(topic, payload) {
1631
1852
  function normaliseUserId2(userId) {
1632
1853
  return userId && userId !== "" ? userId : null;
1633
1854
  }
1634
- var RealtimeModule = class extends EventEmitter4 {
1855
+ var RealtimeModule = class extends EventEmitter5 {
1635
1856
  #transport;
1636
1857
  #entries = /* @__PURE__ */ new Map();
1637
1858
  #walletEntries = /* @__PURE__ */ new Map();
1638
1859
  // keyed by userId
1639
1860
  #userBidsEntries = /* @__PURE__ */ new Map();
1640
1861
  // keyed by userId
1862
+ #migrationEntries = /* @__PURE__ */ new Map();
1863
+ // keyed by userId
1641
1864
  #agencyId;
1642
1865
  constructor(mqttEndpointOrOptions) {
1643
1866
  super();
@@ -1826,6 +2049,57 @@ var RealtimeModule = class extends EventEmitter4 {
1826
2049
  entry.channel.removeAllListeners();
1827
2050
  this.#transport.unsubscribeUserBids(cleanUserId);
1828
2051
  }
2052
+ /**
2053
+ * Subscribe to the user-scoped migration completion stream for `userId`,
2054
+ * topic `user/{userId}/migration` (ux-260730). Returns a `MigrationChannel`
2055
+ * that emits `migrationCompleted` when the user's home-region migration
2056
+ * finishes. Malformed or schema-mismatched payloads are dropped silently.
2057
+ *
2058
+ * Reference-counted by `userId`, mirroring `subscribeWallet`. Intended
2059
+ * lifecycle (design D7): hold the channel only while the client is in the
2060
+ * migrating state; unsubscribe on completion/logout. The publisher retains
2061
+ * the completion message, so subscribing after the fact still delivers it.
2062
+ */
2063
+ subscribeMigration(userId) {
2064
+ const cleanUserId = normaliseUserId2(userId);
2065
+ if (!cleanUserId) {
2066
+ throw new TaphubError("userId is required to subscribe to migration events", {
2067
+ code: "UserIdRequired"
2068
+ });
2069
+ }
2070
+ const existing = this.#migrationEntries.get(cleanUserId);
2071
+ if (existing) {
2072
+ existing.refcount += 1;
2073
+ return existing.channel;
2074
+ }
2075
+ const channel = new MigrationChannel(cleanUserId);
2076
+ const onMessage = (_topic, payload) => {
2077
+ const mapped = mapWireMigrationCompleted(payload);
2078
+ if (!mapped) return;
2079
+ channel.emit("migrationCompleted", mapped);
2080
+ };
2081
+ const onError = (err) => {
2082
+ channel.emit("error", err);
2083
+ };
2084
+ this.#migrationEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
2085
+ this.#transport.subscribeMigration(cleanUserId, onMessage, onError);
2086
+ return channel;
2087
+ }
2088
+ /**
2089
+ * Decrement the migration subscription refcount for `userId`. Tears down the
2090
+ * MQTT topic and removes the channel only at zero. No-op if absent.
2091
+ */
2092
+ unsubscribeMigration(userId) {
2093
+ const cleanUserId = normaliseUserId2(userId);
2094
+ if (!cleanUserId) return;
2095
+ const entry = this.#migrationEntries.get(cleanUserId);
2096
+ if (!entry) return;
2097
+ entry.refcount -= 1;
2098
+ if (entry.refcount > 0) return;
2099
+ this.#migrationEntries.delete(cleanUserId);
2100
+ entry.channel.removeAllListeners();
2101
+ this.#transport.unsubscribeMigration(cleanUserId);
2102
+ }
1829
2103
  /**
1830
2104
  * Subscribe to public market candle data for a pair, keyed by pairId
1831
2105
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1868,6 +2142,15 @@ var RealtimeModule = class extends EventEmitter4 {
1868
2142
  if (!this.#agencyId) return;
1869
2143
  this.#transport.unsubscribeAgencyPairStats(this.#agencyId, pairId);
1870
2144
  }
2145
+ /**
2146
+ * Reconnect the MQTT transport, re-resolving its (possibly region-scoped)
2147
+ * endpoint. All active subscriptions are preserved and re-declared on the new
2148
+ * connection (multi-geo home-region switch). No-op when not currently
2149
+ * connected. Delegates to the transport's `reconnect`.
2150
+ */
2151
+ reconnect() {
2152
+ this.#transport.reconnect();
2153
+ }
1871
2154
  disconnect() {
1872
2155
  for (const entry of this.#entries.values()) {
1873
2156
  entry.channel.removeAllListeners();
@@ -1881,6 +2164,10 @@ var RealtimeModule = class extends EventEmitter4 {
1881
2164
  entry.channel.removeAllListeners();
1882
2165
  }
1883
2166
  this.#userBidsEntries.clear();
2167
+ for (const entry of this.#migrationEntries.values()) {
2168
+ entry.channel.removeAllListeners();
2169
+ }
2170
+ this.#migrationEntries.clear();
1884
2171
  this.#transport.close();
1885
2172
  }
1886
2173
  };
@@ -1973,6 +2260,9 @@ var LIST_ENABLED_CURRENCIES_QUERY = `query ListEnabledCurrencies($input: ListEna
1973
2260
  listEnabledCurrencies(input: $input) { code unit unitSymbol }
1974
2261
  }`;
1975
2262
  var MY_WALLET_BY_CURRENCY_QUERY = "query MyWalletByCurrency($currency: String!) { myWalletByCurrency(currency: $currency) { id amount currency isEnable } }";
2263
+ var REQUEST_REGION_MIGRATION_MUTATION = `mutation RequestRegionMigration($input: RequestRegionMigrationInput!) {
2264
+ requestRegionMigration(input: $input) { migrationId }
2265
+ }`;
1976
2266
  var USER_PNL_QUERY = `query UserPnL($period: String) {
1977
2267
  userPnL(period: $period) {
1978
2268
  gain total_wagered total_payout total_bids total_wins pnlRank volRank
@@ -2022,6 +2312,29 @@ var UserModule = class {
2022
2312
  );
2023
2313
  return normaliseMyWalletByCurrencyResponse(body);
2024
2314
  }
2315
+ /**
2316
+ * Start migrating the authenticated user's home region (multi-geo Phase 2).
2317
+ * User-service validates the JWT, checks the feature flag, and forwards to the
2318
+ * region directory (cooldown / single-flight / region-set validation happen
2319
+ * server-side). Typed rejections keep their `extensions.code`
2320
+ * (Migration_Unavailable, Migration_CooldownActive, …) on the error's `code`
2321
+ * so hosts can localise them.
2322
+ */
2323
+ async requestRegionMigration(toRegion, opts) {
2324
+ const body = await this.#graphqlUser.request(
2325
+ REQUEST_REGION_MIGRATION_MUTATION,
2326
+ { input: { toRegion } },
2327
+ opts
2328
+ );
2329
+ const migrationId = body?.requestRegionMigration?.migrationId;
2330
+ if (typeof migrationId !== "string" || migrationId === "") {
2331
+ throw new TaphubServerError("Invalid response from server", {
2332
+ code: "INVALID_RESPONSE",
2333
+ details: body
2334
+ });
2335
+ }
2336
+ return { migrationId };
2337
+ }
2025
2338
  get currencies() {
2026
2339
  return this.#currencies;
2027
2340
  }
@@ -2472,6 +2785,21 @@ function createRestProbe(monitor) {
2472
2785
  };
2473
2786
  }
2474
2787
 
2788
+ // src/region.ts
2789
+ var KNOWN_REGIONS = ["sg", "eu", "jp"];
2790
+ var DEFAULT_REGION = "sg";
2791
+ function isKnownRegion(region) {
2792
+ return region != null && KNOWN_REGIONS.includes(region);
2793
+ }
2794
+ function resolveRegionBaseUrl(region, domains, fallbackBaseUrl) {
2795
+ if (region == null || region === "") {
2796
+ return fallbackBaseUrl;
2797
+ }
2798
+ const map = domains ?? {};
2799
+ const effectiveRegion = isKnownRegion(region) ? region : DEFAULT_REGION;
2800
+ return map[effectiveRegion] ?? map[DEFAULT_REGION] ?? fallbackBaseUrl;
2801
+ }
2802
+
2475
2803
  // src/storage/index.ts
2476
2804
  var MemoryAdapter = class {
2477
2805
  store = /* @__PURE__ */ new Map();
@@ -2528,6 +2856,11 @@ var TaphubStorage = {
2528
2856
  }
2529
2857
  };
2530
2858
 
2859
+ // src/transport/shared/baseUrl.ts
2860
+ function resolveBaseUrl(input) {
2861
+ return typeof input === "function" ? input() : input;
2862
+ }
2863
+
2531
2864
  // src/transport/shared/errors.ts
2532
2865
  function mapNetworkErrorToTaphubError(err) {
2533
2866
  if (err instanceof DOMException && err.name === "AbortError") {
@@ -2683,7 +3016,7 @@ function createGraphQLTransport(deps) {
2683
3016
  }
2684
3017
  async function execute(token, query, variables, opts) {
2685
3018
  const fetchImpl = resolveFetch();
2686
- const base = baseUrl.replace(/\/+$/, "");
3019
+ const base = resolveBaseUrl(baseUrl).replace(/\/+$/, "");
2687
3020
  const op = extractOp(query);
2688
3021
  const url = `${base}?${op}`;
2689
3022
  const headers = buildHeaders({ token, hasBody: true, agencyId });
@@ -2827,7 +3160,7 @@ function createRestTransport(deps) {
2827
3160
  }
2828
3161
  async function request(method, path, body, opts) {
2829
3162
  const fetchImpl = resolveFetch();
2830
- const url = buildUrl(baseUrl, path);
3163
+ const url = buildUrl(resolveBaseUrl(baseUrl), path);
2831
3164
  const token = getToken();
2832
3165
  const hasBody = body !== void 0;
2833
3166
  const headers = buildHeaders({ token, hasBody });
@@ -2897,6 +3230,9 @@ function tokenStorageKey(agencyId) {
2897
3230
  function isDemoStorageKey(agencyId) {
2898
3231
  return `taphub:${agencyId}:isDemo`;
2899
3232
  }
3233
+ function regionStorageKey(agencyId) {
3234
+ return `taphub:${agencyId}:region`;
3235
+ }
2900
3236
  var TaphubClient = class {
2901
3237
  agencyId;
2902
3238
  endpoint;
@@ -2923,11 +3259,29 @@ var TaphubClient = class {
2923
3259
  bus;
2924
3260
  #token;
2925
3261
  #isDemo;
3262
+ /** User's home region (multi-geo, design D4). null → default base URL. */
3263
+ #region;
2926
3264
  #tokenKey;
2927
3265
  #isDemoKey;
3266
+ #regionKey;
3267
+ /** Region → base URL map (config, never hardcoded). undefined → always use `endpoint`. */
3268
+ #regionDomains;
3269
+ /** Fixed MQTT broker endpoint (fallback for region resolution). undefined → no realtime. */
3270
+ #mqttEndpoint;
3271
+ /** Region → MQTT endpoint map (config, never hardcoded). undefined → always use `#mqttEndpoint`. */
3272
+ #regionMqttEndpoints;
2928
3273
  #rest;
2929
3274
  #graphql;
2930
3275
  #graphqlUser;
3276
+ /**
3277
+ * Auth_WrongRegion-retrying variants of the two GraphQL transports, used by the
3278
+ * user-scoped DATA modules (user / bid) and token refresh. A data call that
3279
+ * lands on the wrong cluster switches the region and retries once (multi-geo
3280
+ * backstop). Login keeps the raw transports — it runs its own wrong-region
3281
+ * retry, so wrapping there would double-retry.
3282
+ */
3283
+ #graphqlData;
3284
+ #graphqlUserData;
2931
3285
  constructor(config) {
2932
3286
  if (!config.agencyId) {
2933
3287
  throw new TaphubValidationError("agencyId is required", {
@@ -2952,39 +3306,62 @@ var TaphubClient = class {
2952
3306
  const restProbe = createRestProbe(this.network);
2953
3307
  this.#tokenKey = tokenStorageKey(this.agencyId);
2954
3308
  this.#isDemoKey = isDemoStorageKey(this.agencyId);
3309
+ this.#regionKey = regionStorageKey(this.agencyId);
3310
+ this.#regionDomains = config.regionDomains;
3311
+ this.#mqttEndpoint = config.mqttEndpoint;
3312
+ this.#regionMqttEndpoints = config.regionMqttEndpoints;
2955
3313
  this.#token = this.storage.get(this.#tokenKey);
2956
3314
  this.#isDemo = this.storage.get(this.#isDemoKey) === "1";
3315
+ this.#region = this.storage.get(this.#regionKey);
2957
3316
  this.#rest = createRestTransport({
2958
- baseUrl: this.endpoint,
3317
+ // Thunk: re-resolved per request so a post-login region change re-targets the
3318
+ // origin without rebuilding the transport. Resolves to `endpoint` when no
3319
+ // region domains are configured (pre-multi-geo behaviour unchanged).
3320
+ baseUrl: () => this.#originForRegion(),
2959
3321
  getToken: () => this.getToken(),
2960
3322
  fetch: config.fetch,
2961
3323
  onRequest: restProbe
2962
3324
  });
2963
- const baseApi = this.endpoint.replace(/\/+$/, "");
2964
3325
  this.#graphql = createGraphQLTransport({
2965
- baseUrl: `${baseApi}/grid-api/grid-gql`,
3326
+ baseUrl: () => `${this.#originForRegion()}/grid-api/grid-gql`,
2966
3327
  getToken: () => this.getToken(),
2967
3328
  agencyId: this.agencyId,
2968
3329
  fetch: config.fetch,
2969
3330
  onRequest: graphqlProbe
2970
3331
  });
2971
3332
  this.#graphqlUser = createGraphQLTransport({
2972
- baseUrl: `${baseApi}/taphub-user-service/th-user-gql`,
3333
+ baseUrl: () => `${this.#originForRegion()}/taphub-user-service/th-user-gql`,
2973
3334
  getToken: () => this.getToken(),
2974
3335
  agencyId: this.agencyId,
2975
3336
  fetch: config.fetch,
2976
3337
  onRequest: graphqlProbe
2977
3338
  });
3339
+ this.#graphqlData = withWrongRegionRetry(this.#graphql, (region) => this.#setRegion(region));
3340
+ this.#graphqlUserData = withWrongRegionRetry(
3341
+ this.#graphqlUser,
3342
+ (region) => this.#setRegion(region)
3343
+ );
2978
3344
  this.user = new UserModule({
2979
- graphql: this.#graphql,
2980
- graphqlUser: this.#graphqlUser
3345
+ graphql: this.#graphqlData,
3346
+ graphqlUser: this.#graphqlUserData
2981
3347
  });
2982
3348
  this.auth = new AuthModule({
2983
3349
  rest: this.#rest,
2984
3350
  graphql: this.#graphql,
2985
3351
  graphqlUser: this.#graphqlUser,
3352
+ // Region-routed + wrong-region-retrying transport for token refresh only.
3353
+ graphqlUserData: this.#graphqlUserData,
2986
3354
  setToken: (t, opts) => this.setToken(t, opts),
3355
+ isDemo: () => this.isDemo(),
2987
3356
  agencyId: this.agencyId,
3357
+ // Multi-geo: login sends the client's current (persisted) region; the server
3358
+ // pins it on first join and returns the stored home region thereafter.
3359
+ getRegion: () => this.getRegion(),
3360
+ // Multi-geo: a login that lands on the wrong cluster is rejected with
3361
+ // Auth_WrongRegion + the user's homeRegion; the auth module silently
3362
+ // switches the client's region here and retries once. Kept separate from
3363
+ // setToken so a region redirect never disturbs the auth/token state.
3364
+ setRegion: (region) => this.#setRegion(region),
2988
3365
  onLoginSuccess: () => this.user.refreshCurrencies().then(() => void 0).catch(() => void 0),
2989
3366
  onLogout: () => {
2990
3367
  this.user.clearCurrencies();
@@ -2993,14 +3370,17 @@ var TaphubClient = class {
2993
3370
  const clockSync = new ClockSync();
2994
3371
  this.pair = new PairModule({ graphql: this.#graphql, clockSync });
2995
3372
  this.bid = new BidModule({
2996
- graphql: this.#graphql,
3373
+ graphql: this.#graphqlData,
2997
3374
  getClockOffset: () => clockSync.getOffset()
2998
3375
  });
2999
3376
  this.leaderboard = new LeaderboardModule({ graphql: this.#graphql });
3000
3377
  this.agencyPairs = new AgencyPairModule({ graphql: this.#graphql });
3001
3378
  this.locale = new LocaleModule({ graphql: this.#graphql });
3002
3379
  this.realtime = config.mqttEndpoint ? new RealtimeModule({
3003
- mqttEndpoint: config.mqttEndpoint,
3380
+ // Thunk: re-resolved at connect time so a post-login region change
3381
+ // re-targets the broker without rebuilding the module (multi-geo D4).
3382
+ // Resolves to `mqttEndpoint` when no region MQTT map is configured.
3383
+ mqttEndpoint: () => this.#resolveMqttEndpoint(),
3004
3384
  agencyId: this.agencyId,
3005
3385
  mqttAuth: config.mqttAuth,
3006
3386
  onMqttLifecycle: createMqttProbe(this.network)
@@ -3060,18 +3440,87 @@ var TaphubClient = class {
3060
3440
  getToken() {
3061
3441
  return this.#token;
3062
3442
  }
3443
+ /**
3444
+ * Current API origin, resolved from the home region (multi-geo, design D4).
3445
+ * Trailing slashes trimmed so the service-path suffix composes cleanly. Falls
3446
+ * back to `endpoint` when no region domains are configured or the region is
3447
+ * unknown/absent.
3448
+ */
3449
+ #originForRegion() {
3450
+ return resolveRegionBaseUrl(this.#region, this.#regionDomains, this.endpoint).replace(
3451
+ /\/+$/,
3452
+ ""
3453
+ );
3454
+ }
3455
+ /**
3456
+ * Current MQTT broker endpoint, resolved from the home region (multi-geo,
3457
+ * design D4). Falls back to the configured `mqttEndpoint` when no region MQTT
3458
+ * map is configured or the region is unknown/absent. Only meaningful when
3459
+ * `mqttEndpoint` was configured (otherwise `realtime` is undefined).
3460
+ */
3461
+ #resolveMqttEndpoint() {
3462
+ return resolveRegionBaseUrl(this.#region, this.#regionMqttEndpoints, this.#mqttEndpoint ?? "");
3463
+ }
3464
+ /** The user's home region, once resolved from a login response. */
3465
+ getRegion() {
3466
+ return this.#region;
3467
+ }
3468
+ /**
3469
+ * Set the home region and persist it (survives reloads like the token/isDemo). Shared
3470
+ * by the login path (setToken with a homeRegion) and the wrong-region redirect handler
3471
+ * (task 1.13). A pure region update — it never touches the token or isDemo, so a
3472
+ * mid-session redirect cannot disturb the auth state.
3473
+ */
3474
+ #setRegion(region) {
3475
+ if (this.#region === region) return;
3476
+ const prevMqttEndpoint = this.realtime ? this.#resolveMqttEndpoint() : null;
3477
+ this.#region = region;
3478
+ this.storage.set(this.#regionKey, region);
3479
+ if (this.realtime && prevMqttEndpoint !== null) {
3480
+ const nextMqttEndpoint = this.#resolveMqttEndpoint();
3481
+ if (nextMqttEndpoint !== prevMqttEndpoint) {
3482
+ this.realtime.reconnect();
3483
+ }
3484
+ }
3485
+ }
3486
+ /**
3487
+ * Complete a home-region migration by cutting the client over to `toRegion`
3488
+ * (ux-260730-migration-complete-notify). Intended to be called after a
3489
+ * `migrationCompleted` event (realtime `subscribeMigration`) or an equivalent
3490
+ * poll result confirmed the migration finished — by then the server-side home
3491
+ * region already points at `toRegion`.
3492
+ *
3493
+ * Purpose-named public wrapper over the private `#setRegion` primitive (the
3494
+ * generic region setter stays private so third-party builders cannot
3495
+ * arbitrarily re-target regions). Persists the region and re-targets the
3496
+ * GQL/REST transports; MQTT reconnects only when the resolved broker endpoint
3497
+ * actually changes. Region validation is lenient, matching `region.ts`: an
3498
+ * unknown region is stored as-is and resolves to the default region's
3499
+ * endpoints (enum-fallback, never throws). Idempotent for the already-current
3500
+ * region (no reconnect), and a safe endpoint-level no-op when no region
3501
+ * domain/MQTT maps are configured.
3502
+ */
3503
+ completeRegionMigration(toRegion) {
3504
+ if (toRegion == null || toRegion === "") return;
3505
+ this.#setRegion(toRegion);
3506
+ }
3063
3507
  setToken(token, opts) {
3064
3508
  this.#token = token;
3065
3509
  if (token === null) {
3066
3510
  this.#isDemo = false;
3511
+ this.#region = null;
3067
3512
  this.storage.remove(this.#tokenKey);
3068
3513
  this.storage.remove(this.#isDemoKey);
3514
+ this.storage.remove(this.#regionKey);
3069
3515
  return;
3070
3516
  }
3071
3517
  const isDemo = opts?.isDemo ?? false;
3072
3518
  this.#isDemo = isDemo;
3073
3519
  this.storage.set(this.#tokenKey, token);
3074
3520
  this.storage.set(this.#isDemoKey, isDemo ? "1" : "0");
3521
+ if (opts?.homeRegion !== void 0) {
3522
+ this.#setRegion(opts.homeRegion);
3523
+ }
3075
3524
  }
3076
3525
  isDemo() {
3077
3526
  return this.#isDemo;
@@ -3092,6 +3541,81 @@ var TaphubClient = class {
3092
3541
  }
3093
3542
  };
3094
3543
 
3544
+ // src/regionProbe.ts
3545
+ var REGION_PROBE_CACHE_KEY = "taphub:region-probe";
3546
+ var DEFAULT_PROBE_TTL_SECONDS = 3600;
3547
+ var DEFAULT_PROBE_TIMEOUT_MS = 2e3;
3548
+ var DEFAULT_PROBE_PATH = "/grid-api/grid-gql";
3549
+ var PROBE_QUERY = "{serverTime}";
3550
+ function readFreshCache(storage, key, now, ttlSeconds) {
3551
+ try {
3552
+ const raw = storage.get(key);
3553
+ if (!raw) return null;
3554
+ const parsed = JSON.parse(raw);
3555
+ if (!parsed || typeof parsed.region !== "string" || typeof parsed.ts !== "number") {
3556
+ return null;
3557
+ }
3558
+ if (now() - parsed.ts >= ttlSeconds * 1e3) return null;
3559
+ return parsed.region;
3560
+ } catch {
3561
+ return null;
3562
+ }
3563
+ }
3564
+ function writeCache(storage, key, region, now) {
3565
+ try {
3566
+ storage.set(key, JSON.stringify({ region, ts: now() }));
3567
+ } catch {
3568
+ }
3569
+ }
3570
+ async function probeNearestRegion(domains, opts = {}) {
3571
+ const {
3572
+ fetch: fetchImpl = globalThis.fetch,
3573
+ storage = autoDetectStorage(),
3574
+ ttlSeconds = DEFAULT_PROBE_TTL_SECONDS,
3575
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
3576
+ probePath = DEFAULT_PROBE_PATH,
3577
+ now = Date.now,
3578
+ cacheKey = REGION_PROBE_CACHE_KEY
3579
+ } = opts;
3580
+ const entries = Object.entries(domains ?? {}).filter(
3581
+ (entry) => typeof entry[1] === "string" && entry[1] !== ""
3582
+ );
3583
+ if (entries.length < 2) return null;
3584
+ const cached = readFreshCache(storage, cacheKey, now, ttlSeconds);
3585
+ if (cached !== null && entries.some(([region]) => region === cached)) {
3586
+ return cached;
3587
+ }
3588
+ if (typeof fetchImpl !== "function") return null;
3589
+ const controller = new AbortController();
3590
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3591
+ try {
3592
+ const winner = await Promise.any(
3593
+ entries.map(async ([region, baseUrl]) => {
3594
+ const url = baseUrl.replace(/\/+$/, "") + probePath;
3595
+ const response = await fetchImpl(url, {
3596
+ method: "POST",
3597
+ headers: { "content-type": "application/json" },
3598
+ body: JSON.stringify({ query: PROBE_QUERY }),
3599
+ signal: controller.signal
3600
+ });
3601
+ const body = await response.json();
3602
+ const serverTime = body?.data?.serverTime;
3603
+ if (typeof serverTime !== "number") {
3604
+ throw new Error(`region probe: "${region}" returned no serverTime`);
3605
+ }
3606
+ return region;
3607
+ })
3608
+ );
3609
+ writeCache(storage, cacheKey, winner, now);
3610
+ return winner;
3611
+ } catch {
3612
+ return null;
3613
+ } finally {
3614
+ clearTimeout(timer);
3615
+ controller.abort();
3616
+ }
3617
+ }
3618
+
3095
3619
  // src/modules/realtime/types.ts
3096
3620
  var CANDLE_EVENT = {
3097
3621
  NEW: "new",
@@ -3297,10 +3821,14 @@ export {
3297
3821
  BidModule,
3298
3822
  CANDLE_EVENT,
3299
3823
  DEFAULT_CHART_HISTORY_LIMIT,
3824
+ DEFAULT_PROBE_TTL_SECONDS,
3825
+ DEFAULT_REGION,
3826
+ KNOWN_REGIONS,
3300
3827
  LeaderboardModule,
3301
3828
  LocaleModule,
3302
3829
  NetworkQualityMonitor,
3303
3830
  PairModule,
3831
+ REGION_PROBE_CACHE_KEY,
3304
3832
  RealtimeModule,
3305
3833
  TaphubAuthError,
3306
3834
  TaphubClient,
@@ -3321,6 +3849,7 @@ export {
3321
3849
  computeBaseline,
3322
3850
  errorFunction,
3323
3851
  isCancelled,
3852
+ isKnownRegion,
3324
3853
  isLoss,
3325
3854
  isPending,
3326
3855
  isTerminal,
@@ -3329,5 +3858,7 @@ export {
3329
3858
  normalPDF,
3330
3859
  normaliseLang,
3331
3860
  pairIdFromBidResultTopic,
3861
+ probeNearestRegion,
3862
+ resolveRegionBaseUrl,
3332
3863
  roundCoefToSignificantDigits
3333
3864
  };