@flexemarkets/fm-sdk 0.0.11 → 0.1.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/client.js CHANGED
@@ -7,8 +7,10 @@ import { readFileSync, existsSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { basename, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { orderedSecurities, toOrderType, toSide, unitGrid } from "./types.js";
11
+ import { toInstant } from "./timestamps.js";
10
12
  import { EventListener, NO_SEQ } from "./stomp.js";
11
- import { DefaultMarketView, MarketViewHandle } from "./market-view.js";
13
+ import { DefaultMarketView, MarketViewHandle, } from "./market-view.js";
12
14
  function readVersion() {
13
15
  try {
14
16
  const dir = fileURLToPath(new URL(".", import.meta.url));
@@ -37,7 +39,65 @@ export class ConnectionFailedError extends FlexemarketsError {
37
39
  }
38
40
  export class ConfigurationError extends FlexemarketsError {
39
41
  }
40
- function parsePerson(data) {
42
+ /**
43
+ * A response the SDK has no better name for, carrying its status and body.
44
+ *
45
+ * The fallback. A status with a meaning worth acting on gets its own type —
46
+ * {@link AuthenticationError}, {@link ConflictError} — and this is what is
47
+ * left, so a caller can read the status rather than parse a message.
48
+ */
49
+ export class HttpError extends FlexemarketsError {
50
+ statusCode;
51
+ body;
52
+ constructor(statusCode, body) {
53
+ super(`HTTP ${statusCode}: ${body}`);
54
+ this.statusCode = statusCode;
55
+ this.body = body;
56
+ }
57
+ }
58
+ /**
59
+ * The call could not be completed: the transport failed, or the response was
60
+ * not something the SDK could read.
61
+ *
62
+ * Distinct from {@link HttpError}, which means the server answered and the
63
+ * answer was an error. This means there was no usable answer at all — a
64
+ * malformed body, or a link the API root does not carry.
65
+ */
66
+ export class ApiError extends FlexemarketsError {
67
+ }
68
+ /** A 409. The Java and Python SDKs have raised this since the admin surface landed. */
69
+ export class ConflictError extends FlexemarketsError {
70
+ }
71
+ /**
72
+ * An account name was taken, and the server proposed another.
73
+ *
74
+ * A subclass of {@link ConflictError} rather than a sibling, so a caller that
75
+ * handles conflicts generally still catches this one. The suggestion is worth
76
+ * surfacing rather than retrying blindly: it is the name the account would end
77
+ * up known by.
78
+ */
79
+ export class AccountNameConflictError extends ConflictError {
80
+ requestedName;
81
+ suggestedName;
82
+ constructor(message, requestedName, suggestedName) {
83
+ super(message);
84
+ this.requestedName = requestedName;
85
+ this.suggestedName = suggestedName;
86
+ }
87
+ }
88
+ /**
89
+ * A user could not be deleted because they still own marketplace data —
90
+ * orders or allotments. Deleting them would orphan it, so the server refuses;
91
+ * the caller has to decide what happens to the data first.
92
+ */
93
+ export class PersonHasMarketplaceDataError extends ConflictError {
94
+ userId;
95
+ constructor(message, userId) {
96
+ super(message);
97
+ this.userId = userId;
98
+ }
99
+ }
100
+ export function parsePerson(data) {
41
101
  if (!data)
42
102
  return null;
43
103
  return {
@@ -48,11 +108,11 @@ function parsePerson(data) {
48
108
  email: data.email ?? null,
49
109
  roles: data.roles ?? [],
50
110
  accountOwner: data.accountOwner ?? false,
51
- createdDate: data.createdDate ?? null,
52
- lastModifiedDate: data.lastModifiedDate ?? null,
111
+ createdDate: toInstant(data.createdDate),
112
+ lastModifiedDate: toInstant(data.lastModifiedDate),
53
113
  };
54
114
  }
55
- function parseAccount(data) {
115
+ export function parseAccount(data) {
56
116
  if (!data)
57
117
  return null;
58
118
  return {
@@ -60,13 +120,13 @@ function parseAccount(data) {
60
120
  name: data.name ?? null,
61
121
  description: data.description ?? null,
62
122
  owner: parsePerson(data.owner),
63
- approval: data.approval ?? false,
123
+ approval: data.approval ?? null,
64
124
  approvalDescription: data.approvalDescription ?? null,
65
- createdDate: data.createdDate ?? null,
66
- lastModifiedDate: data.lastModifiedDate ?? null,
125
+ createdDate: toInstant(data.createdDate),
126
+ lastModifiedDate: toInstant(data.lastModifiedDate),
67
127
  };
68
128
  }
69
- function parseToken(data) {
129
+ export function parseToken(data) {
70
130
  return {
71
131
  requestUrl: data.requestUrl ?? null,
72
132
  person: parsePerson(data.person),
@@ -74,7 +134,7 @@ function parseToken(data) {
74
134
  token: data.token ?? null,
75
135
  };
76
136
  }
77
- function parseSecurity(data) {
137
+ export function parseSecurity(data) {
78
138
  return {
79
139
  marketId: data.marketId ?? 0,
80
140
  units: data.units ?? 0,
@@ -85,7 +145,7 @@ function parseSecurity(data) {
85
145
  canSell: data.canSell ?? false,
86
146
  };
87
147
  }
88
- function parseMarket(data) {
148
+ export function parseMarket(data) {
89
149
  return {
90
150
  id: data.id ?? 0,
91
151
  marketplaceId: data.marketplaceId ?? 0,
@@ -101,7 +161,7 @@ function parseMarket(data) {
101
161
  unitTick: data.unitTick ?? 0,
102
162
  };
103
163
  }
104
- function parseMarketplace(data) {
164
+ export function parseMarketplace(data) {
105
165
  return {
106
166
  id: data.id ?? 0,
107
167
  name: data.name ?? null,
@@ -109,7 +169,7 @@ function parseMarketplace(data) {
109
169
  markets: (data.markets ?? []).map(parseMarket),
110
170
  };
111
171
  }
112
- function parseSession(data) {
172
+ export function parseSession(data) {
113
173
  return {
114
174
  marketplaceId: data.marketplaceId ?? 0,
115
175
  allocationId: data.allocationId ?? 0,
@@ -118,8 +178,8 @@ function parseSession(data) {
118
178
  state: data.state ?? null,
119
179
  name: data.name ?? null,
120
180
  description: data.description ?? null,
121
- openDate: data.openDate ?? null,
122
- closeDate: data.closeDate ?? null,
181
+ openDate: toInstant(data.openDate),
182
+ closeDate: toInstant(data.closeDate),
123
183
  };
124
184
  }
125
185
  export function parseOrder(data) {
@@ -128,8 +188,8 @@ export function parseOrder(data) {
128
188
  original: data.original ?? 0,
129
189
  supplier: data.supplier ?? 0,
130
190
  consumer: data.consumer ?? null,
131
- type: data.type ?? null,
132
- side: data.side ?? null,
191
+ type: toOrderType(data.type),
192
+ side: toSide(data.side),
133
193
  units: data.units ?? 0,
134
194
  price: data.price ?? 0,
135
195
  ownerId: data.ownerId ?? null,
@@ -139,8 +199,8 @@ export function parseOrder(data) {
139
199
  marketId: data.marketId ?? 0,
140
200
  ownerTarget: data.ownerTarget ?? null,
141
201
  clientDescription: data.clientDescription ?? null,
142
- createdDate: data.createdDate ?? null,
143
- lastModifiedDate: data.lastModifiedDate ?? null,
202
+ createdDate: toInstant(data.createdDate),
203
+ lastModifiedDate: toInstant(data.lastModifiedDate),
144
204
  };
145
205
  }
146
206
  function parseAllotment(data) {
@@ -154,7 +214,7 @@ function parseAllotment(data) {
154
214
  id: assetsRaw.id ?? null,
155
215
  name: assetsRaw.name ?? null,
156
216
  cash: assetsRaw.cash ?? 0,
157
- securities: securitiesRaw.map(parseSecurity),
217
+ securities: orderedSecurities(securitiesRaw.map(parseSecurity)),
158
218
  };
159
219
  }
160
220
  return {
@@ -222,20 +282,35 @@ export function parseHolding(data) {
222
282
  name: data.name ?? null,
223
283
  cash: data.cash ?? 0,
224
284
  availableCash: data.availableCash ?? 0,
225
- securities: securitiesRaw.map(parseSecurity),
285
+ securities: orderedSecurities(securitiesRaw.map(parseSecurity)),
226
286
  };
227
287
  }
228
- function parseConnection(data) {
288
+ export function parseConnection(data) {
229
289
  return {
230
290
  marketplaceId: data.marketplaceId ?? 0,
231
291
  connectionId: data.id ?? data.connectionId ?? 0,
232
292
  ownerId: data.ownerId ?? 0,
233
- established: data.established ?? null,
234
- terminated: data.terminated ?? null,
293
+ established: toInstant(data.established),
294
+ terminated: toInstant(data.terminated),
235
295
  description: data.description ?? null,
236
296
  sessionId: data.sessionId ?? null,
237
297
  };
238
298
  }
299
+ /**
300
+ * The orders inside a HAL envelope.
301
+ *
302
+ * The server embeds them under `orders`. It was `orderDtoes` — Spring HATEOAS
303
+ * pluralising `OrderDto` — and every SDK still read that name long after the
304
+ * server stopped sending it, so `activeOrders` and `recentTrades` returned an
305
+ * empty array always. `MarketView` seeds from `activeOrders`, so its books were
306
+ * never seeded; they filled from live deltas and looked plausible.
307
+ *
308
+ * Both names are accepted, so an older server still works.
309
+ */
310
+ function embeddedOrders(data) {
311
+ const embedded = data._embedded;
312
+ return embedded?.orders ?? embedded?.orderDtoes ?? [];
313
+ }
239
314
  function parseApiRoot(data) {
240
315
  const linksRaw = data._links ?? {};
241
316
  const links = {};
@@ -249,6 +324,84 @@ function parseApiRoot(data) {
249
324
  }
250
325
  return { links };
251
326
  }
327
+ /**
328
+ * The most aggressive price this market will accept on `side`.
329
+ *
330
+ * Ticks are anchored at `priceMinimum`, not at zero — the server tests
331
+ * `(price - priceMinimum) % priceTick` — so the top of the range is only legal
332
+ * when the range is a whole number of ticks. The highest legal price is the
333
+ * last tick at or below `priceMaximum`. A tick of zero marks a fixed dimension,
334
+ * where the two bounds are equal and there is one legal price.
335
+ */
336
+ export function marketableLimit(market, side) {
337
+ if (side?.toUpperCase() !== "BUY" || market.priceTick <= 0) {
338
+ return market.priceMinimum;
339
+ }
340
+ const span = market.priceMaximum - market.priceMinimum;
341
+ return market.priceMinimum + Math.floor(span / market.priceTick) * market.priceTick;
342
+ }
343
+ /**
344
+ * The `scheme://host:port` of an absolute http(s) URL, else undefined.
345
+ *
346
+ * A relative href already resolves against the origin it was fetched from,
347
+ * and a scheme that is not HTTP is not ours to rewrite.
348
+ */
349
+ function httpOrigin(url) {
350
+ if (!url)
351
+ return undefined;
352
+ const end = url.indexOf("://");
353
+ if (end < 0)
354
+ return undefined;
355
+ const scheme = url.substring(0, end).toLowerCase();
356
+ if (scheme !== "http" && scheme !== "https")
357
+ return undefined;
358
+ const pathStart = url.indexOf("/", end + 3);
359
+ return pathStart < 0 ? url : url.substring(0, pathStart);
360
+ }
361
+ /**
362
+ * Point the API root's links back at the host that was dialled.
363
+ *
364
+ * The server builds these hrefs from the request it believes it received, and
365
+ * behind a proxy that belief can be wrong: an origin reached over a plaintext
366
+ * leg reports `http://` even though the caller arrived on `https://`. Every
367
+ * call that goes through a link — which is most of them — then leaves on plain
368
+ * HTTP and meets the edge's redirect. A GET survives it. A POST does not: a
369
+ * 301 is followed as a GET with the body dropped, so placing an order or
370
+ * opening a session fails with nothing placed and nothing pointing at the
371
+ * scheme.
372
+ *
373
+ * Only the origin is replaced. The path, query and any URI template are the
374
+ * server's to choose; where it is reachable is not, and the token in hand was
375
+ * issued by the origin dialled, not by whatever the links name.
376
+ */
377
+ export function rebaseApiRoot(root, endpoint) {
378
+ const origin = httpOrigin(endpoint);
379
+ if (!origin)
380
+ return root;
381
+ const links = {};
382
+ const moved = [];
383
+ for (const [name, href] of Object.entries(root.links)) {
384
+ const named = httpOrigin(href);
385
+ if (!named || named === origin) {
386
+ links[name] = href;
387
+ continue;
388
+ }
389
+ if (!moved.includes(named))
390
+ moved.push(named);
391
+ links[name] = origin + href.substring(named.length);
392
+ }
393
+ if (moved.length > 0) {
394
+ // Said out loud, because the rewrite would otherwise hide a deployment
395
+ // that is genuinely misconfigured — and a silent correction here is how it
396
+ // stays misconfigured. The SDK keeps working; the operator still gets told
397
+ // where to look.
398
+ console.warn(`[fm-sdk] The API root names ${moved.join(", ")} but this client dialled ${origin}; ` +
399
+ `rewriting ${moved.length} link origin(s) to match. The server is behind a proxy ` +
400
+ `that is not forwarding the request scheme, so its links are wrong. Fix it at the ` +
401
+ `edge — this rewrite only keeps calls working.`);
402
+ }
403
+ return { links };
404
+ }
252
405
  // ---------------------------------------------------------------------------
253
406
  // HATEOAS link resolution
254
407
  // ---------------------------------------------------------------------------
@@ -256,10 +409,20 @@ function processTemplate(href) {
256
409
  const idx = href.indexOf("{");
257
410
  return idx >= 0 ? href.substring(0, idx) : href;
258
411
  }
412
+ /**
413
+ * A V1 route, addressed from the server rather than through a HAL link.
414
+ *
415
+ * V1 is flat and versioned: the path is knowable without fetching the API root
416
+ * first, which is the point of it. Every call that moves here loses a HAL
417
+ * dependency as well as a version.
418
+ */
419
+ function v1(endpoint, path) {
420
+ return `${server(endpoint)}/v1${path}`;
421
+ }
259
422
  function uri(root, linkName) {
260
423
  const href = root.links[linkName];
261
424
  if (href === undefined)
262
- throw new Error(`Link '${linkName}' not found in API root.`);
425
+ throw new ApiError(`Link '${linkName}' not found in API root.`);
263
426
  return processTemplate(href);
264
427
  }
265
428
  function uriId(root, linkName, id) {
@@ -369,10 +532,25 @@ function checkResponse(response, body) {
369
532
  throw new AuthenticationError(body);
370
533
  if (status === 403)
371
534
  throw new AuthorizationError(body);
535
+ if (status === 409)
536
+ throw new ConflictError(body);
372
537
  if (status >= 500)
373
538
  throw new ConnectionFailedError(body);
374
- throw new FlexemarketsError(`HTTP ${status}: ${body}`);
539
+ throw new HttpError(status, body);
540
+ }
541
+ /** The server's proposed alternative name, when a 409 body carries one. */
542
+ function suggestedNameIn(body) {
543
+ try {
544
+ const parsed = JSON.parse(body);
545
+ return parsed.suggestedName ?? null;
546
+ }
547
+ catch {
548
+ return null;
549
+ }
375
550
  }
551
+ // ---------------------------------------------------------------------------
552
+ // Flexemarkets client
553
+ // ---------------------------------------------------------------------------
376
554
  export class Flexemarkets {
377
555
  _clientDescription;
378
556
  _endpoint;
@@ -381,6 +559,7 @@ export class Flexemarkets {
381
559
  _apiRoot;
382
560
  _account;
383
561
  _user;
562
+ _tokenObj;
384
563
  _eventListener = null;
385
564
  constructor(endpoint, baseUrl, bearerToken, clientDescription) {
386
565
  this._endpoint = endpoint;
@@ -412,6 +591,7 @@ export class Flexemarkets {
412
591
  const tokenObj = await signIn(baseUrl, config, desc);
413
592
  const bearer = `Bearer ${tokenObj.token}`;
414
593
  const fm = new Flexemarkets(ep, baseUrl, bearer, desc);
594
+ fm._tokenObj = tokenObj;
415
595
  fm._account = tokenObj.account;
416
596
  fm._user = tokenObj.person;
417
597
  // Fetch API root for HATEOAS links
@@ -525,9 +705,159 @@ export class Flexemarkets {
525
705
  checkResponse(resp, body);
526
706
  return JSON.parse(body);
527
707
  }
708
+ // -- administration --------------------------------------------------------
709
+ /*
710
+ * Creating accounts and users, approving them, deleting them, and minting
711
+ * one-time passcodes. fm-server's administrative surface, carried here so
712
+ * that the tools which run a course have a client that is not fm-lib-net.
713
+ *
714
+ * Several are destructive and one issues credentials. They need an admin or
715
+ * manager and the server answers 401/403 otherwise, which is the only
716
+ * guard: possessing the method is not possessing the right.
717
+ */
718
+ /**
719
+ * Register a new account and its owner, returning the owner's token.
720
+ *
721
+ * The owner's credentials go out as `ownerEmail`/`ownerPassword`. Sending
722
+ * `email`/`password` instead creates an account with an owner the server
723
+ * cannot sign in as.
724
+ */
725
+ async signup(accountName, email, password, firstName, lastName) {
726
+ const url = uri(this._apiRoot, "accounts");
727
+ try {
728
+ const data = await this._post(url, {
729
+ accountName,
730
+ ownerEmail: email,
731
+ ownerPassword: password,
732
+ firstName: firstName ?? null,
733
+ lastName: lastName ?? null,
734
+ });
735
+ return parseToken(data);
736
+ }
737
+ catch (e) {
738
+ // A taken name, with the server's proposed alternative. Raised as its
739
+ // own type so a caller can offer the suggestion rather than parsing it
740
+ // back out of a generic conflict.
741
+ if (e instanceof ConflictError) {
742
+ const suggested = suggestedNameIn(e.message);
743
+ throw new AccountNameConflictError(`Account name '${accountName}' is taken` +
744
+ (suggested === null ? "" : `; server suggests '${suggested}'`), accountName, suggested);
745
+ }
746
+ throw e;
747
+ }
748
+ }
749
+ /** Approve an account by name, returning it as it now stands. */
750
+ async approveAccount(accountName) {
751
+ const url = `${server(this._endpoint)}/approvals`;
752
+ const data = await this._post(url, { name: accountName, approval: true });
753
+ return parseAccount(data.account);
754
+ }
755
+ /** One account by id. */
756
+ async accountById(accountId) {
757
+ return parseAccount(await this._get(uriId(this._apiRoot, "accounts", accountId)));
758
+ }
759
+ /** One user by id. */
760
+ async userById(userId) {
761
+ return parsePerson(await this._get(v1(this._endpoint, `/users/${userId}`)));
762
+ }
763
+ /** The marketplace's private-trader identifiers. */
764
+ async identifiers(marketplaceId) {
765
+ const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "privateTraders");
766
+ return (await this._get(url));
767
+ }
768
+ /** Delete the caller's own account. Its own route, not accounts/{yourId}. */
769
+ async deleteMyAccount() {
770
+ await this._delete(`${server(this._endpoint)}/accounts/me`);
771
+ }
772
+ /** Every account on the server. Admin-only. */
773
+ async accounts() {
774
+ const url = uriParam(this._apiRoot, "accounts", "format=application/json");
775
+ const data = await this._get(url);
776
+ return data.map(parseAccount);
777
+ }
778
+ /** Delete an account. Destructive, and takes its users with it. */
779
+ async deleteAccount(accountId) {
780
+ await this._delete(uriId(this._apiRoot, "accounts", accountId));
781
+ }
782
+ /** Create a user in the caller's account. */
783
+ async createUser(email, password, firstName, lastName, roles = []) {
784
+ const url = v1(this._endpoint, "/users");
785
+ const data = await this._post(url, { email, password, firstName, lastName, roles });
786
+ return parsePerson(data);
787
+ }
788
+ /** Delete a user. Destructive. */
789
+ async deleteUser(userId) {
790
+ try {
791
+ await this._delete(uriId(this._apiRoot, "users", userId));
792
+ }
793
+ catch (e) {
794
+ // The user still owns orders or allotments. Deleting them would orphan
795
+ // it, so the server refuses and the caller has to decide what happens to
796
+ // the data first.
797
+ if (e instanceof ConflictError) {
798
+ throw new PersonHasMarketplaceDataError(`User ${userId} has marketplace data and cannot be deleted.`, userId);
799
+ }
800
+ throw e;
801
+ }
802
+ }
803
+ /** Create an empty marketplace. See also {@link createMarketplaceFromJson}. */
804
+ /** Delete a marketplace, and with it its sessions and their history. */
805
+ async deleteMarketplace(marketplaceId) {
806
+ await this._delete(uriId(this._apiRoot, "marketplaces", marketplaceId));
807
+ }
808
+ /**
809
+ * Add a market to a marketplace.
810
+ *
811
+ * Both dimensions are the caller's. Unit bounds used to be fixed at 1/100/1
812
+ * with no way to say otherwise, on a call that set the price grid three
813
+ * arguments earlier — and the server enforces the two identically, refusing
814
+ * an order for "units is not on a tic" exactly as for a price. Omitting
815
+ * `units` keeps the old default.
816
+ */
817
+ async createMarket(marketplaceId, symbol, name, price, units = unitGrid(), privateMarket = false) {
818
+ const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "markets");
819
+ return parseMarket(await this._post(url, {
820
+ symbol,
821
+ name,
822
+ priceMinimum: price.minimum,
823
+ priceMaximum: price.maximum,
824
+ priceTick: price.tick,
825
+ unitMinimum: units.minimum,
826
+ unitMaximum: units.maximum,
827
+ unitTick: units.tick,
828
+ privateMarket,
829
+ }));
830
+ }
831
+ /**
832
+ * Mint one-time passcodes for the given users.
833
+ *
834
+ * These are credentials: not to be logged, not to be persisted, and
835
+ * delivered to the person they belong to.
836
+ */
837
+ async managerOtpBundle(userIds) {
838
+ const url = `${server(this._endpoint)}/otp/manager`;
839
+ const data = await this._post(url, { userIds });
840
+ return {
841
+ expiresAt: toInstant(data.expiresAt),
842
+ otps: (data.otps ?? []).map((o) => ({
843
+ userId: o.userId ?? 0,
844
+ email: o.email ?? null,
845
+ otp: o.otp ?? null,
846
+ })),
847
+ };
848
+ }
849
+ /** DELETE, whose answer is a status and nothing worth parsing. */
850
+ async _delete(url) {
851
+ const resp = await fetch(url.startsWith("/") ? `${this._baseUrl}${url}` : url, {
852
+ method: "DELETE",
853
+ headers: { ...this._authHeaders(), Accept: "application/json" },
854
+ });
855
+ const body = await resp.text();
856
+ checkResponse(resp, body);
857
+ }
528
858
  async _fetchApiRoot() {
529
859
  const data = await this._get(this._baseUrl);
530
- return parseApiRoot(data);
860
+ return rebaseApiRoot(parseApiRoot(data), this._baseUrl);
531
861
  }
532
862
  // ======================================================================
533
863
  // REST APIs
@@ -548,21 +878,50 @@ export class Flexemarkets {
548
878
  const data = await this._get(url);
549
879
  return data.map(parseMarket);
550
880
  }
881
+ /**
882
+ * The token this connection signed in with.
883
+ *
884
+ * Exposed so a caller can open a sibling connection on the same identity
885
+ * without holding the password again.
886
+ */
887
+ token() {
888
+ return this._tokenObj;
889
+ }
890
+ /** Whether this connection's user holds ROLE_ADMIN. */
891
+ isAdmin() {
892
+ return this.hasRole("ROLE_ADMIN");
893
+ }
894
+ /**
895
+ * Whether this connection's user holds ROLE_MANAGER — the role that runs a
896
+ * study: opening and closing sessions, staging allocations, minting
897
+ * passcodes. Python has had it since the management surface landed.
898
+ */
899
+ isManager() {
900
+ return this.hasRole("ROLE_MANAGER");
901
+ }
902
+ hasRole(role) {
903
+ return (this._user?.roles ?? []).includes(role);
904
+ }
551
905
  async symbols(marketplaceId) {
552
906
  const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "symbols");
553
907
  return (await this._get(url));
554
908
  }
555
909
  // -- sessions --------------------------------------------------------------
556
- async sessions(marketplaceId, sessionIds) {
557
- let url;
558
- if (sessionIds && sessionIds.length > 0) {
559
- url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "sessions", `${sessionIdsParam(sessionIds)}&format=application/json`);
560
- }
561
- else {
562
- url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "sessions", "format=application/json");
563
- }
564
- const data = await this._get(url);
565
- return data.map(parseSession);
910
+ /**
911
+ * The marketplace's sessions — all of them.
912
+ *
913
+ * There is no server-side filter. The route takes no session argument, so
914
+ * the `sessionIds` this used to accept was silently ignored: it returned the
915
+ * whole history and looked like it had filtered. Filter the result; fm-ui
916
+ * already does.
917
+ *
918
+ * On `GET /api/v1/marketplaces/{id}/sessions`, which answers with the same
919
+ * fields as the V0 route it replaces — verified against a running server,
920
+ * not assumed — and needs no `format=application/json` to avoid HAL.
921
+ */
922
+ async sessions(marketplaceId) {
923
+ const url = v1(this._endpoint, `/marketplaces/${marketplaceId}/sessions`);
924
+ return (await this._get(url)).map(parseSession);
566
925
  }
567
926
  async session(marketplaceId) {
568
927
  const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "currentSession");
@@ -582,6 +941,45 @@ export class Flexemarkets {
582
941
  });
583
942
  return parseOrder(data);
584
943
  }
944
+ /**
945
+ * Cross the book: buy at the highest price this market allows, sell at the
946
+ * lowest. Immediate or cancel — whatever does not fill is cancelled.
947
+ *
948
+ * There is no market order on the server. Its type switch falls through to
949
+ * `LIMIT`, so every submission is bounds-checked against the market and must
950
+ * sit on a tick — which is why this asks the marketplace for the market
951
+ * first, and costs a round trip {@link submitLimit} does not.
952
+ *
953
+ * The cancel is unconditional: the exchange consumes a cancel by itself when
954
+ * no units remain, so a complete fill costs a harmless round trip rather than
955
+ * an inspection that would race the book. Without it, a market order that did
956
+ * not fill would rest at the market's extreme — the best price in the book,
957
+ * standing, for anyone to take.
958
+ *
959
+ * Returns the limit order as submitted. What it filled is a property of the
960
+ * book afterwards, not of this value.
961
+ */
962
+ async submitMarket(marketplaceId, marketId, side, units) {
963
+ const market = await this._market(marketplaceId, marketId);
964
+ const limit = await this.submitLimit(marketplaceId, marketId, side, units, marketableLimit(market, side));
965
+ try {
966
+ await this.submitCancel(marketplaceId, marketId, limit.id);
967
+ }
968
+ catch (e) {
969
+ // The order is placed. Reporting only "cancel failed" would invite a
970
+ // caller to retry the whole thing and trade twice.
971
+ throw new FlexemarketsError(`Order ${limit.id} was placed but its remainder could not be cancelled; ` +
972
+ `it may still be resting. Do not resubmit — cancel it. (${String(e)})`);
973
+ }
974
+ return limit;
975
+ }
976
+ async _market(marketplaceId, marketId) {
977
+ for (const candidate of await this.markets(marketplaceId)) {
978
+ if (candidate.id === marketId)
979
+ return candidate;
980
+ }
981
+ throw new InvalidArgumentError(`Market ${marketId} is not in marketplace ${marketplaceId}`);
982
+ }
585
983
  async submitCancel(marketplaceId, marketId, originalId) {
586
984
  const url = uri(this._apiRoot, "orders");
587
985
  const data = await this._post(url, {
@@ -596,28 +994,28 @@ export class Flexemarkets {
596
994
  return parseOrder(data);
597
995
  }
598
996
  /**
599
- * V1 active-orders snapshot: every resting limit order on the
997
+ * The active-orders snapshot: every resting limit order on the
600
998
  * marketplace's current session, plus the `x-fm-as-of-seq` sequence
601
- * the snapshot was read at. Used by `MarketView` Phase 2a seeding
999
+ * the snapshot was read at. Used by `MarketView` seeding
602
1000
  * — clients apply WS deltas whose seq is greater than the returned
603
1001
  * value and skip those whose seq is less than or equal.
604
1002
  */
605
- async activeOrdersV1(marketplaceId) {
1003
+ async activeOrders(marketplaceId) {
606
1004
  const baseRest = this._baseUrl;
607
1005
  const url = `${baseRest}/v1/marketplaces/${marketplaceId}/orders/active`;
608
1006
  const { data, asOfSeq } = await this._getSnapshot(url);
609
- const orders = (data._embedded?.orderDtoes ?? []).map(parseOrder);
1007
+ const orders = embeddedOrders(data).map(parseOrder);
610
1008
  return { body: orders, asOfSeq };
611
1009
  }
612
1010
  /**
613
- * V1 recent-trades snapshot for seeding the trade-history tape.
614
- * Same `x-fm-as-of-seq` contract as `activeOrdersV1`. Server caps
1011
+ * The recent-trades snapshot, for seeding the trade-history tape.
1012
+ * Same `x-fm-as-of-seq` contract as `activeOrders`. Server caps
615
1013
  * at 5000; default size is 1000.
616
1014
  */
617
- async recentTradesV1(marketplaceId, size = 1000) {
1015
+ async recentTrades(marketplaceId, size = 1000) {
618
1016
  const url = `${this._baseUrl}/v1/marketplaces/${marketplaceId}/orders/recent-trades?size=${size}`;
619
1017
  const { data, asOfSeq } = await this._getSnapshot(url);
620
- const orders = (data._embedded?.orderDtoes ?? []).map(parseOrder);
1018
+ const orders = embeddedOrders(data).map(parseOrder);
621
1019
  return { body: orders, asOfSeq };
622
1020
  }
623
1021
  async orders(marketplaceId, options) {
@@ -668,15 +1066,16 @@ export class Flexemarkets {
668
1066
  return parseHolding(await this._get(url));
669
1067
  }
670
1068
  // -- connections -----------------------------------------------------------
671
- async connections(marketplaceId, sessionIds) {
672
- // Canonical path is /marketplaces/{id}/connections ("/agents" is the
673
- // retained pre-FM-4 alias); format=application/json yields a plain list
674
- // (vs the HAL _embedded form).
675
- const sid = sessionIdsParam(sessionIds ?? null);
676
- const param = sid ? `${sid}&format=application/json` : "format=application/json";
677
- const url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "connections", param);
678
- const data = await this._get(url);
679
- return data.map(parseConnection);
1069
+ /**
1070
+ * Who is attached to the marketplace — all of them.
1071
+ *
1072
+ * No server-side filter, for the reason {@link sessions} gives. A connection
1073
+ * carries the session it belonged to, so "who was present in that run" is a
1074
+ * filter on the result.
1075
+ */
1076
+ async connections(marketplaceId) {
1077
+ const url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "connections", "format=application/json");
1078
+ return (await this._get(url)).map(parseConnection);
680
1079
  }
681
1080
  // -- management ------------------------------------------------------------
682
1081
  //
@@ -839,6 +1238,27 @@ export class Flexemarkets {
839
1238
  async listen(marketplaceId, callback) {
840
1239
  this._eventListener = await this._connectEvents(marketplaceId, callback);
841
1240
  }
1241
+ /**
1242
+ * Open an *independent* event subscription, delivering to `callback` until
1243
+ * the returned unsubscribe function is invoked.
1244
+ *
1245
+ * Unlike {@link listen}, which is one per connection and replaces itself,
1246
+ * several of these coexist: each has its own stream and its own lifetime.
1247
+ * That is what lets more than one MarketView live in one connection without
1248
+ * trampling each other — the mechanism was already here for exactly that, as
1249
+ * the package-private `_connectEvents`, but a caller who wanted a second
1250
+ * stream of their own had no way to ask for one.
1251
+ *
1252
+ * Returns an unsubscribe function rather than an object with `close()`,
1253
+ * matching what MarketView's `on*` handlers already return here. Java
1254
+ * returns a `Subscription`; both names describe the same lifetime.
1255
+ */
1256
+ async subscribe(marketplaceId, callback) {
1257
+ const listener = await this._connectEvents(marketplaceId, callback);
1258
+ return () => {
1259
+ void listener.close();
1260
+ };
1261
+ }
842
1262
  /**
843
1263
  * Package-private helper used by {@link DefaultMarketView} (Phase 2d)
844
1264
  * to own its own EventListener subscription rather than clobbering
@@ -882,19 +1302,23 @@ export class Flexemarkets {
882
1302
  async function signIn(baseUrl, config, clientDescription) {
883
1303
  const tok = config.token ?? "";
884
1304
  if (tok && isValidToken(tok)) {
885
- const authUrl = `${baseUrl}/tokens`;
886
- const resp = await fetch(authUrl, {
887
- method: "POST",
1305
+ // A caller who already holds a token has no account/email/password to
1306
+ // present, so signing in is not available: POSTing /tokens with blanks is
1307
+ // rejected -- the server answers 400 MESSAGE_NOT_READABLE for an empty
1308
+ // password, which is what this used to send. Refreshing the token both
1309
+ // validates it and returns the account and person behind it.
1310
+ //
1311
+ // This is the third time. fm-lib-net carried the branch, an earlier rewrite
1312
+ // dropped it, and the Java SDK restored it with a test. This SDK and the
1313
+ // Python one never had it, so token auth returned 400 in both from the day
1314
+ // it was written.
1315
+ const resp = await fetch(`${baseUrl}/tokens/refresh`, {
1316
+ method: "GET",
888
1317
  headers: {
889
1318
  Authorization: `Bearer ${tok}`,
890
- "Content-Type": "application/json",
891
1319
  Accept: "application/json",
892
1320
  "User-Agent": FM_NETWORK_CLIENT,
893
1321
  },
894
- body: JSON.stringify({
895
- username: `${config.account ?? ""}|${config.email ?? ""}`,
896
- password: "",
897
- }),
898
1322
  });
899
1323
  const body = await resp.text();
900
1324
  if (resp.status === 401) {