sabre-client 0.4.1 → 0.5.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/README.md CHANGED
@@ -12,17 +12,31 @@ TypeScript SDK for the Sabre APIs.
12
12
 
13
13
  ## Status
14
14
 
15
- `0.4.0` is the current release of the rewrite. It covers Tier 1 and Tier 2 of the Air Booking
16
- flow over REST, and Tier 3 (SOAP sessions and queues). It adds `buildCredentials` and host
17
- commands (`session.command`) to `0.3.0`, with no breaking changes.
15
+ `0.5.0` is published under the `next` dist-tag while it is tested; `latest` stays on `0.4.1`.
16
+ It covers Tier 1 and Tier 2 of the Air Booking flow over REST, Tier 3 (SOAP sessions and queues)
17
+ and Tier 4: queue activity, PNR history, the `S` message queue, `selection` in `list` and
18
+ `onRawRequest`. It has three breaking changes (see [Upgrading from 0.4](#upgrading-from-04)).
18
19
 
19
20
  ```sh
20
- npm install sabre-client
21
+ npm install sabre-client@next # 0.5.0
22
+ npm install sabre-client # 0.4.1
21
23
  ```
22
24
 
23
25
  The package is still `0.x`: a minor version may include breaking changes, and the
24
26
  [CHANGELOG](./CHANGELOG.md) marks them.
25
27
 
28
+ ### Upgrading from 0.4
29
+
30
+ - **`pcc` is no longer in the config.** Passing it to `new Sabre` or `new SabreLegacy` is a type
31
+ error, and in JavaScript it is ignored. The PCC you authenticate with goes in `credentials`
32
+ (see [Credentials](#credentials)); another PCC goes per operation, as `targetPcc` in REST or
33
+ `pcc` in the queue operations.
34
+ - **`QueueRef` can be a `string`.** An alphabetic queue like `'S'` is `'S'`, and `count` and
35
+ `place` return it that way instead of `{ name: 'S' }`. Code that does
36
+ `typeof q === 'number' ? … : q.name` must handle the `string` case.
37
+ - **A `QueueStep` that is not done has a `kind`.** Check `step.kind === 'pnr'` before reading
38
+ `step.item`; `kind: 'message'` carries a message of the `S` queue.
39
+
26
40
  Tier 1 covers the core Air Booking flow over REST:
27
41
 
28
42
  | Step | Service | Method |
@@ -45,11 +59,12 @@ Tier 3 adds the `SabreLegacy` SOAP client, with a stateful session and Queue Man
45
59
  | Step | Service | Method |
46
60
  | - | - | - |
47
61
  | Session | `SessionCreateRQ` 2.0.0 / 1.0.0, `OTA_PingRQ`, `SessionCloseRQ` | `legacy.withSession`, `legacy.openSession` |
48
- | Queues | Get Queue Count, Access Queue, Place Queue Message | `session.queue.count`, `list`, `access`, `ignore`, `remove`, `place`, `exit`, `process` |
49
- | Queue paragraph | — (pure function) | `parseQueueParagraph`, `resolveHostDate` |
62
+ | Queues | Get Queue Count, Get Queue Activity, Access Queue, Place Queue Message | `session.queue.count`, `activity`, `list`, `access`, `ignore`, `remove`, `place`, `exit`, `process` |
63
+ | Queue paragraph | — (pure function) | `parseQueueParagraph`, `parseQueuePlaceRemark`, `resolveHostDate` |
64
+ | PNR history | `TravelItineraryHistoryLLSRQ` 2.4.0 | `session.reservation.history` |
50
65
  | Host commands | `SabreCommandLLSRQ` 2.0.0 | `session.command` |
51
66
 
52
- Planned after `0.4.0`: Get Ancillaries (NDC only).
67
+ Planned after `0.5.0`: Get Ancillaries (NDC only).
53
68
 
54
69
  ## Requirements
55
70
 
@@ -88,11 +103,6 @@ const sabre = new Sabre({
88
103
  | `fetch` | global `fetch` | Custom `fetch` (proxies, tests, other runtimes) |
89
104
  | `tokenStore` | in-memory only | Shares the token between processes (see [Token reuse](#token-reuse)) |
90
105
 
91
- > **`pcc` is deprecated** since `0.4.1` and will be removed in `0.5.0`. It was a reserved field,
92
- > and no client reads it. The PCC you authenticate with goes in `credentials` (see
93
- > [Credentials](#credentials) and `buildCredentials`). To work in another PCC, pass it per
94
- > operation: `targetPcc` in REST, `pcc` in the queue operations.
95
-
96
106
  ### Credentials
97
107
 
98
108
  There is **one set of credentials** for REST and SOAP: the same `credentials` object works for
@@ -124,7 +134,9 @@ The object that `buildCredentials` returns carries its `authVersion`, so the con
124
134
  need to repeat it: with `authVersion: 'v2'` in `buildCredentials`, `{ environment, credentials }`
125
135
  is already a v2 config. If you pass `authVersion` in the config too, it must match, or the client
126
136
  throws a `TypeError`. Credentials you build by hand have no `authVersion`, so with v2 the config
127
- still needs `authVersion: 'v2'`.
137
+ still needs `authVersion: 'v2'`. If the version is only known at runtime (for example, read
138
+ from an environment variable into a `BuildCredentialsOptions`), the result of `buildCredentials`
139
+ can still go straight into the config, with no `authVersion` there.
128
140
 
129
141
  The PCC in the credentials is the one you authenticate with. Working in another PCC is not a
130
142
  second set of credentials: pass it per operation, as `targetPcc` in `booking.create`, `get` and
@@ -360,6 +372,7 @@ const screen = await legacy.withSession((session) => session.command('W/PT¥*',
360
372
  // screen.text → 'PSGR TYPE - ON' (as Sabre sends it, not trimmed)
361
373
  // screen.warnings → the stl:Warning entries, if any
362
374
  // screen.messageId → eb:MessageId, for Sabre support
375
+ // screen.respondedAt → eb:Timestamp in UTC, e.g. '2026-11-06T17:34:16Z'
363
376
  ```
364
377
 
365
378
  - **The command is sent as is.** In this service Sabre's `‡` separator is written `¥`
@@ -384,16 +397,63 @@ const screen = await legacy.withSession((session) => session.command('W/PT¥*',
384
397
  characters (`W/PT`, `DC`, `*IM`), never the whole command or the screen. The full response is
385
398
  available through `onRawResponse`.
386
399
 
400
+ ### PNR history
401
+
402
+ `session.reservation.history(locator, options?)` reads the PNR history (Get Itinerary History,
403
+ `TravelItineraryHistoryLLSRQ` 2.4.0, the host's `*H`), one entry per transaction:
404
+
405
+ ```ts
406
+ const { transactions } = await legacy.withSession((session) => session.reservation.history('ABC123'));
407
+ // transactions[0] → { rph, createdAt: '2026-11-04T17:15', agent, agentDutyCode, pcc, aaaPcc,
408
+ // receivedFrom, flightSegments, generalInfo, remarks, accounting }
409
+ ```
410
+
411
+ - **Who, when and from where**: `createdAt` (with a year, no time zone), the agent's sine and duty
412
+ code, the signing `pcc`, the AAA's `aaaPcc` and `receivedFrom`.
413
+ - **What changed, with its `action`**: `flightSegments` with their status at that moment
414
+ (`AS` added, `XS` cancelled; dates without a year, as the host sends them), and SSR, ticketing,
415
+ phones, names and emails as text in `generalInfo` (`A4S`, `A7`, `A9`, `AN`, `APE`…). Sabre does
416
+ not publish the list of `action` values. Seats, cars, hotels, rail, insurance and invoices are
417
+ not typed: they stay in the raw response (`onRawResponse`).
418
+ - **It carries passenger data** (names, phones, emails). The SDK never logs it.
419
+ - **It does not record queue placements**, neither the system's nor a manual `place` (CERT): for that, use
420
+ `queue.list`/`access` or the `QUEUE_PLACE` remarks of Get Booking.
421
+ - **Call it outside a queue walk.** Inside a queue, Sabre rejects the history of another PNR with
422
+ `NAK6 -NOT ALLOWED- FINISH OR IGNORE OTHER ACTIVITY`, a `SabreBusinessError` (CERT). The PNR in
423
+ the AAA does not change, `queue.state` stays `'inside'`, and the walk goes on with `ignore`.
424
+ - It is a read: it is retried. A `locator` that is not 6 uppercase alphanumeric characters throws a
425
+ `TypeError` without calling the network. A PNR that does not exist throws a `SabreBusinessError`
426
+ (`service: 'itineraryHistory'`).
427
+
387
428
  ### Queues
388
429
 
389
430
  The queue operations live in `session.queue`. `pcc` is optional in every call and defaults to the
390
- session's PCC. A queue is a number from 0 to 511 or `{ name }` for alphabetic queues
391
- (`QueueRef`). The queues returned by `count` can be passed straight to the other operations.
431
+ session's PCC. A queue (`QueueRef`) comes in three forms:
432
+
433
+ | Queue | `QueueRef` | Sent as |
434
+ | - | - | - |
435
+ | Numeric, 0 to 511 | `400` | `Number="400"` |
436
+ | Alphabetic, one or two uppercase letters | `'S'` | `Number="S"` |
437
+ | Named (QN and QNB tables) | `{ name: 'HOTELS' }` | `Name="HOTELS"` |
438
+
439
+ The queues returned by `count` can be passed straight to the other operations. `'S'` is the PCC's
440
+ **message queue**: it holds messages, not PNRs, and Sabre does not list it, so `list({ queue: 'S' })`
441
+ throws a `TypeError` without calling Sabre. Walk it by hand: `access({ queue: 'S' })` returns the
442
+ first message (`kind: 'message'`) and `ignore()` the next one, leaving it in the queue. `process`
443
+ and `place` inside `S` throw a `TypeError` without calling Sabre. `remove()` sends `QR`, but there
444
+ is no evidence yet of what Sabre does with the message.
445
+
446
+ > **Migrating from 0.4.x**: `count` used to return the `S` queue as `{ name: 'S' }`, which Sabre
447
+ > rejects (`INVALID QUEUE NAME FORMAT`). It now returns `'S'`. Code that does
448
+ > `typeof q === 'number' ? … : q.name` no longer compiles, because `q` can be a `string`. A
449
+ > `{ name }` of one or two uppercase letters now throws a `TypeError` that tells you to pass the
450
+ > letter instead, and `'12'` or `'s'` throw too: each queue has a single form.
392
451
 
393
452
  | Method | Sabre service | Returns |
394
453
  | - | - | - |
395
454
  | `count({ pcc? })` | `QueueCountLLSRQ` | `{ queues: [{ queue, count }], totals, countedAt, warnings }` |
396
- | `list({ queue, pcc?, primaryPassenger? })` | `QueueAccessLLSRQ` (`List`) | `{ locators, entries, paragraph, warnings }`, without entering the queue |
455
+ | `activity({ pcc? })` | `QueueAnalysisLLSRQ` | `{ queues: [{ queue, numMessages, ended, ignored, … }], totals, analyzedAt, warnings }` |
456
+ | `list({ queue, pcc?, primaryPassenger?, selection? })` | `QueueAccessLLSRQ` (`List`) | `{ locators, entries, paragraph, warnings }`, without entering the queue |
397
457
  | `access({ queue, pcc?, selection? })` | `QueueAccessLLSRQ` | `QueueStep` |
398
458
  | `ignore()` | `QueueAccessLLSRQ` (`I`) | `QueueStep` |
399
459
  | `remove({ from? })` | `QueueAccessLLSRQ` (`QR`) | `QueueStep` |
@@ -402,10 +462,30 @@ session's PCC. A queue is a number from 0 to 511 or `{ name }` for alphabetic qu
402
462
 
403
463
  ```ts
404
464
  type QueueStep =
405
- | { done: false; item: QueueItem; warnings: SabreWarning[] }
465
+ | { done: false; kind: 'pnr'; item: QueueItem; warnings: SabreWarning[] }
466
+ | { done: false; kind: 'message'; message: { text: string[] }; warnings: SabreWarning[] } // queue 'S'
406
467
  | { done: true; reason: 'end-of-display' | 'empty'; message: string; warnings: SabreWarning[] };
407
468
  ```
408
469
 
470
+ > **Migrating from 0.4.x**: a step that is not done now has a `kind`. Check `step.kind === 'pnr'`
471
+ > before reading `step.item`. Only the `S` queue returns `kind: 'message'`.
472
+
473
+ - **`list` returns the whole queue in one response**: there is no paging, even with more than 1000
474
+ PNRs (1221 in about 279 KB, in production). `selection` narrows it, but in the list Sabre only
475
+ accepts `QD` (date placed on the queue, e.g. `{ function: 'QD', criteria: '05NOV' }`) or `AK`
476
+ (the agent who placed it, PCC plus sine, e.g. `'PCC1AAT'`; not tested in the list yet).
477
+ Any other code throws a `TypeError` without calling Sabre. The `placedAt` of a list entry is
478
+ when the PNR was placed on the queue, the date `QD` filters on.
479
+ - **`activity`** returns, per queue, the eight counters of the host's `QA` screen: `numMessages`
480
+ (on the queue now), `ended`, `ignored`, `moved`, `removed`, `transferred`,
481
+ `leftMessageToContact` and `unableToReach`, plus `totals` for the PCC. Sabre also lists empty
482
+ queues that have counters. No source says since when the counters run, so the SDK returns them
483
+ as they come. Like `count`, it is a read: it is retried, it works inside or outside a queue, and
484
+ it does not need duty code 9.
485
+ - **Every queue result carries `respondedAt`**: Sabre's `eb:Timestamp`, in ISO 8601 UTC
486
+ (`'2026-11-06T17:34:16Z'`). Unlike `countedAt` and `placedAt`, which come as the host sends them
487
+ (no year, PCC local time), it has a year and a zone. It is missing only if Sabre does not send it.
488
+
409
489
  - **The end of the queue is a result, not an error.** `END OF DISPLAY FOR REQUESTED DATA` gives
410
490
  `{ done: true, reason: 'end-of-display' }`, and an empty queue (`QUEUE SELECTED WAS EMPTY`)
411
491
  gives `reason: 'empty'`. In both cases the session is left outside the queue. Any other Sabre
@@ -425,7 +505,9 @@ type QueueStep =
425
505
  Production rejects a `place` without it, although the design says it is optional.
426
506
  - **`QueueItem`** has `locator`, the `placedAt` and `placedBy` of `Line`, `primaryPassenger` and
427
507
  `group` when Sabre sends them, and `paragraph`: the PNR in host format, line by line and
428
- untouched. `placedAt` is the text that arrived, with or without the year. `resolveHostDate`
508
+ untouched. `placedAt` is the text that arrived, with or without the year. Inside a queue it is
509
+ the PNR's last change, not when it was placed on the queue: that one is the `placedAt` of
510
+ `list`, and the two differ for the same PNR. `resolveHostDate`
429
511
  turns it into a full date.
430
512
  - `count` inside a queue does not count the PNR on screen.
431
513
 
@@ -466,6 +548,20 @@ segments with their status, the ticketing field with an `issued` flag, and the r
466
548
  - It does not read deadlines that airlines write as free text in remarks, SSR or OSI. Their format
467
549
  belongs to each airline. The text is still in `paragraph`.
468
550
 
551
+ ### Reading a `QUEUE_PLACE` remark
552
+
553
+ Get Booking returns where the PNR was placed as a remark with `type: 'QUEUE_PLACE'`. Its `text`
554
+ packs the PCC, the date, the queue and the PIC with no separators. `parseQueuePlaceRemark(text)`
555
+ splits it:
556
+
557
+ ```ts
558
+ parseQueuePlaceRemark('MEX25SEP146/015'); // { pcc: 'MEX', date: '25SEP', queue: 146, pic: 15 }
559
+ ```
560
+
561
+ It returns `null` if the text does not have that shape, or if the day, month, queue (0–511) or PIC
562
+ (0–191, 200–254) are out of range. `date` has no year: resolve it with
563
+ `resolveHostDate(date, referenceDate)`. Reading the remarks from the booking is up to you.
564
+
469
565
  ### Testing your queue logic: `sabre-client/testing`
470
566
 
471
567
  `sabre-client/testing` is a separate entry point with a fake of Sabre's sessions and queues. It is
@@ -496,16 +592,67 @@ fake.openSessions; // 0: the session was closed
496
592
  `{ locator, paragraph?, placedAt?, placedBy?, primaryPassenger? }`. A paragraph without a
497
593
  signature line gets a synthetic one. `pnrs` adds PNRs that are not in any queue, for
498
594
  `place({ locators })`. The fake has no AAA: outside a queue, `place` needs `locators`.
595
+ - `queues['S']` is the message queue: each item is a message's text (`string[]`), not a PNR.
596
+ `access` shows it without `Line`, as Sabre does, and `fake.messages()` lists the ones left.
597
+ That `remove` takes a message out is an assumption of the fake, not tested against Sabre.
598
+ - `queue.activity`: `numMessages` comes from the fake's queues (without the PNR on screen, as in
599
+ `count`, as CERT does). The other seven counters are 0 unless you seed them with
600
+ `activity: { '9': { ignored: 27 } }`. The fake's actions do **not** change them: no one has
601
+ tested which counter Sabre raises for each action.
602
+ - `reservation.history`: `histories: { AAAAAA: [transactions] }` sets what each PNR returns, in
603
+ the shape of `ReservationHistoryTransaction` (missing lists come back empty). A PNR the fake
604
+ knows without a seeded history returns one synthetic transaction (its creation), and an unknown
605
+ locator gives a host error marked as not simulated. Inside a queue, the history of another PNR
606
+ is rejected with `NAK6 -NOT ALLOWED- FINISH OR IGNORE OTHER ACTIVITY` and the one on screen is
607
+ returned, as in CERT.
499
608
  - It answers like production: empty queues, the end of the queue, `place` moving the PNR, the
500
609
  required PIC, and an expired session with `fake.expireSessions()`. `fake.calls` lists what the
501
610
  SDK asked for.
502
611
  - **Not simulated**: PCCs (the queues are the same for any `pcc`) and `selection`, which gets an
503
- error instead of the whole queue.
612
+ error instead of the whole queue, in `access` and in `list`.
504
613
  - `queueFixtures` holds the anonymized responses recorded in production (`walk`, `emptyQueue`,
505
- `place`, …), as `{ operation, status, body }`.
614
+ `place`, …), as `{ operation, status, body }`. Each one has its own locator aliases (`walk`:
615
+ `AAAAAA`–`AAAAAD`, `place`: `AAAAAE`–`AAAAAF`, `placeWithoutInstructionCodeInside`:
616
+ `AAAAAG`–`AAAAAH`), and their signature lines keep their shape (`… <alias> H`).
617
+ - `queueFakePnrs(fixture)` turns a recording into the fake's PNRs: those of `list` first, in
618
+ order, with their `Line`, and the paragraph `access`, `ignore`, `remove` or `place` showed for
619
+ each one. You choose the queue, and you can mix fixtures:
620
+
621
+ ```ts
622
+ const fake = createQueueFake({
623
+ queues: { '9': queueFakePnrs(queueFixtures.walk), '50': [] },
624
+ pnrs: queueFakePnrs(queueFixtures.place),
625
+ });
626
+ ```
506
627
 
507
628
  `examples/queue-review.mjs` is a full example, and its test runs it against the fake.
508
629
 
630
+ #### Anonymizing recorded responses
631
+
632
+ `createAnonymizer` applies the rule the SDK's fixtures were recorded with, so you can store
633
+ Sabre's responses (for example from `onRawResponse`) without passenger data:
634
+
635
+ ```ts
636
+ import { createAnonymizer } from 'sabre-client/testing';
637
+
638
+ const anonymizer = createAnonymizer({ secrets: [pcc, username] });
639
+ const safe = anonymizer.anonymize(raw.body); // a whole SOAP envelope
640
+ anonymizer.maskParagraph(item.paragraph); // host-format lines
641
+ if (anonymizer.leaks(safe).length > 0) throw new Error('real data left');
642
+ ```
643
+
644
+ - Each locator gets an alias (`AAAAAA`, `AAAAAB`…), the same one everywhere it appears, also in
645
+ later texts passed to the same anonymizer. `PassengerName` becomes `PASAJERO/UNO`, `Group`
646
+ becomes `GRUPO`, `AgentSine` becomes `AAA` (except `SABRE`), `PseudoCityCode` becomes `XXXX`,
647
+ `LNIATA` becomes `XXXXXX`, and each `secret` becomes `XXXX`.
648
+ - In host-format lines (`<Text>`, or `maskLine` and `maskParagraph`), only the lines of a
649
+ whitelist stay as they are (`isSafeHostLine`: empty lines, PIC notices like `011 SEE REMARKS`,
650
+ section titles, the end of the queue). In the others, letters become `X` and digits `9`. The
651
+ signature line keeps its shape, and its locator only if it is already an alias.
652
+ - `leaks(text)` lists the real data still in `text` (`'secreto'`, `'localizador'`, `'nombre'`,
653
+ `'línea del PNR'`, `'token'`): use it before writing a file.
654
+ - Use one anonymizer per recording. It is a rule, not a guarantee: check what you store.
655
+
509
656
  ## Error handling
510
657
 
511
658
  Every error thrown by the SDK extends `SabreError`:
@@ -576,6 +723,39 @@ In `SabreLegacy`, `protocol` is `'soap'`, `body` is the whole SOAP envelope, and
576
723
  > `body` is the raw response and **contains passenger data**. The SDK never sends it to the
577
724
  > logger. Where you store it is up to you.
578
725
 
726
+ ## Raw request
727
+
728
+ The same operations accept `onRawRequest`, next to `onRawResponse`. It receives what the SDK sent
729
+ over the network, for example to show the real request next to the response:
730
+
731
+ ```ts
732
+ import type { SabreRawRequest } from 'sabre-client';
733
+
734
+ let sent: SabreRawRequest | undefined;
735
+ await sabre.booking.get({ confirmationId }, { onRawRequest: (r) => (sent = r) });
736
+ // sent: { operation: 'booking.get', protocol: 'rest', method, url, headers, body, attempts }
737
+ ```
738
+
739
+ - `operation` is the same value `onRawResponse` gets. `url` is the full URL, with its query
740
+ string. `headers` are the ones sent, **without `Authorization`** (the whole header is dropped).
741
+ `body` is the text sent, or `undefined` if there was none. In REST it is untouched; in
742
+ `SabreLegacy` it is the whole SOAP envelope with the `BinarySecurityToken` replaced by
743
+ `[REDACTED]`, as in the response.
744
+ - It is called **once**, with the last attempt sent, before the operation returns or throws and
745
+ before `onRawResponse`. Unlike `onRawResponse`, it is also called when no attempt got an HTTP
746
+ response (network error, abort), as long as at least one was sent. It is not called if the
747
+ operation throws before sending anything (for example a `TypeError` from validation).
748
+ - In SOAP, `eb:MessageId` and `eb:Timestamp` are new on every attempt, so the envelope you get is
749
+ the one of the last attempt.
750
+ - It is not called for authentication: the OAuth token request, `openSession`
751
+ (`SessionCreateRQ`, whose envelope carries the EPR password) and `close` (`SessionCloseRQ`).
752
+ - It is synchronous and its return value is ignored. If it throws, the SDK logs
753
+ `sabre.request.rawRequestHookFailed` without the body, and the operation carries on.
754
+
755
+ > [!WARNING]
756
+ > `body` and `url` **contain passenger data** (locators, names, contact details). The SDK never
757
+ > sends them to the logger. Where you store them is up to you.
758
+
579
759
  ## Retries
580
760
 
581
761
  Only idempotent operations are retried: Bargain Finder Max and Revalidate Itinerary (both
@@ -632,7 +812,8 @@ const sabre = new Sabre({
632
812
  ```
633
813
 
634
814
  The SDK logs structured events (`sabre.request.start`, `sabre.request.end`,
635
- `sabre.request.retry`, `sabre.request.rawResponseHookFailed`, `sabre.auth.token.refresh`,
815
+ `sabre.request.retry`, `sabre.request.rawResponseHookFailed`,
816
+ `sabre.request.rawRequestHookFailed`, `sabre.auth.token.refresh`,
636
817
  `sabre.auth.token.store`, `sabre.booking.seat.priced`, `sabre.session.open`,
637
818
  `sabre.session.close`, `sabre.session.closeFailed`, `sabre.session.keepAliveFailed`,
638
819
  `sabre.session.command`, `sabre.queue.exitTolerated`, `sabre.queue.exitFailed`), never raw