@curless/hotel-storefront 0.24.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/__tests__/__helpers__/sessions.d.ts +10 -0
  2. package/dist/__tests__/__helpers__/sessions.d.ts.map +1 -0
  3. package/dist/__tests__/__helpers__/sessions.js +37 -0
  4. package/dist/__tests__/__helpers__/sessions.js.map +1 -0
  5. package/dist/__tests__/card-contract.test.js +20 -0
  6. package/dist/__tests__/card-contract.test.js.map +1 -1
  7. package/dist/__tests__/data.test.js +7 -1
  8. package/dist/__tests__/data.test.js.map +1 -1
  9. package/dist/__tests__/dates.test.js +13 -1
  10. package/dist/__tests__/dates.test.js.map +1 -1
  11. package/dist/__tests__/huazhu-audit.test.js +69 -4
  12. package/dist/__tests__/huazhu-audit.test.js.map +1 -1
  13. package/dist/__tests__/huazhu-backend.test.js +93 -0
  14. package/dist/__tests__/huazhu-backend.test.js.map +1 -1
  15. package/dist/__tests__/huazhu-sandbox.test.js +7 -1
  16. package/dist/__tests__/huazhu-sandbox.test.js.map +1 -1
  17. package/dist/__tests__/server.test.js +112 -3
  18. package/dist/__tests__/server.test.js.map +1 -1
  19. package/dist/__tests__/state.test.d.ts +2 -0
  20. package/dist/__tests__/state.test.d.ts.map +1 -0
  21. package/dist/__tests__/state.test.js +204 -0
  22. package/dist/__tests__/state.test.js.map +1 -0
  23. package/dist/__tests__/tools.test.js +18 -0
  24. package/dist/__tests__/tools.test.js.map +1 -1
  25. package/dist/backend.d.ts +5 -0
  26. package/dist/backend.d.ts.map +1 -1
  27. package/dist/backend.js.map +1 -1
  28. package/dist/card.generated.d.ts +1 -1
  29. package/dist/card.generated.js +2 -2
  30. package/dist/card.generated.js.map +1 -1
  31. package/dist/config.d.ts +7 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +0 -11
  34. package/dist/config.js.map +1 -1
  35. package/dist/dates.d.ts +9 -0
  36. package/dist/dates.d.ts.map +1 -1
  37. package/dist/dates.js +32 -0
  38. package/dist/dates.js.map +1 -1
  39. package/dist/demo-hotels.d.ts +21 -1
  40. package/dist/demo-hotels.d.ts.map +1 -1
  41. package/dist/demo-hotels.js +83 -11
  42. package/dist/demo-hotels.js.map +1 -1
  43. package/dist/huazhu/backend.d.ts +5 -0
  44. package/dist/huazhu/backend.d.ts.map +1 -1
  45. package/dist/huazhu/backend.js +67 -9
  46. package/dist/huazhu/backend.js.map +1 -1
  47. package/dist/index.js +9 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/legal.d.ts +8 -1
  50. package/dist/legal.d.ts.map +1 -1
  51. package/dist/legal.js +22 -8
  52. package/dist/legal.js.map +1 -1
  53. package/dist/limits.d.ts +49 -0
  54. package/dist/limits.d.ts.map +1 -1
  55. package/dist/limits.js +45 -0
  56. package/dist/limits.js.map +1 -1
  57. package/dist/members.d.ts +3 -1
  58. package/dist/members.d.ts.map +1 -1
  59. package/dist/members.js +24 -6
  60. package/dist/members.js.map +1 -1
  61. package/dist/money.d.ts.map +1 -1
  62. package/dist/money.js +8 -1
  63. package/dist/money.js.map +1 -1
  64. package/dist/server.d.ts +22 -8
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +121 -37
  67. package/dist/server.js.map +1 -1
  68. package/dist/sessions.d.ts +19 -0
  69. package/dist/sessions.d.ts.map +1 -0
  70. package/dist/sessions.js +96 -0
  71. package/dist/sessions.js.map +1 -0
  72. package/dist/state.d.ts +32 -0
  73. package/dist/state.d.ts.map +1 -0
  74. package/dist/state.js +118 -0
  75. package/dist/state.js.map +1 -0
  76. package/dist/tools.d.ts +13 -0
  77. package/dist/tools.d.ts.map +1 -1
  78. package/dist/tools.js +69 -5
  79. package/dist/tools.js.map +1 -1
  80. package/dist/trip-ids.d.ts +8 -1
  81. package/dist/trip-ids.d.ts.map +1 -1
  82. package/dist/trip-ids.js +24 -1
  83. package/dist/trip-ids.js.map +1 -1
  84. package/dist/trip.d.ts +3 -1
  85. package/dist/trip.d.ts.map +1 -1
  86. package/dist/trip.js +23 -6
  87. package/dist/trip.js.map +1 -1
  88. package/dist/types.d.ts +8 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/package.json +1 -1
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ // The session ids this connector has issued - and only those are accepted.
3
+ //
4
+ // ⚠️ A SESSION ID WAS ANY STRING THE CALLER SENT. `initialize` issued one, but
5
+ // every other call took whatever `Mcp-Session-Id` arrived, so a script could
6
+ // be as many conversations as it liked: twelve made-up ids, one booking each,
7
+ // emptied the Island Shangri-La for a month (2026-10-05 audit). Now an id this
8
+ // connector never issued - or issued and has since forgotten - is answered
9
+ // 404, which the protocol defines as "this session is gone, initialize again".
10
+ //
11
+ // This alone is not the fix for that attack (`initialize` hands out ids for
12
+ // the asking); the demo-wide cap on held rooms is. What it does guarantee is
13
+ // that a conversation's id is one we chose.
14
+ //
15
+ // ⚠️ KEPT ACROSS A RESTART, or every deploy would answer every open
16
+ // conversation 404 - and a host that does not start over on a 404 shows its
17
+ // guest an error instead. A call with NO session id is still answered: the
18
+ // card's calls can arrive without one (trip-ids.ts).
19
+ /** An id unused this long is forgotten; a conversation picked up after that starts a new session. */
20
+ export const SESSION_TTL_MS = 30 * 86_400_000;
21
+ /** Ids kept at once. Past it, the one unused for longest goes. */
22
+ const MAX_SESSIONS = 100_000;
23
+ /**
24
+ * How stale a "last used" may get before it is written down again. A session
25
+ * lives for a month; rewriting the file on every tool call to move its clock by
26
+ * a second would be most of what this server writes.
27
+ */
28
+ const TOUCH_EVERY_MS = 60 * 60_000;
29
+ export class Sessions {
30
+ now;
31
+ file;
32
+ /** Id → last used. Insertion order is use order: a touch re-inserts. */
33
+ seen = new Map();
34
+ sweptAt = 0;
35
+ constructor(now = () => Date.now(), file) {
36
+ this.now = now;
37
+ this.file = file;
38
+ const saved = file?.read();
39
+ for (const [id, at] of saved?.sessions ?? []) {
40
+ if (typeof id === 'string' && typeof at === 'number')
41
+ this.seen.set(id, at);
42
+ }
43
+ file?.bind(() => ({ sessions: [...this.seen] }));
44
+ this.sweep();
45
+ }
46
+ /** A new id, ours. */
47
+ issue() {
48
+ this.sweep();
49
+ const id = randomUUID();
50
+ this.seen.set(id, this.now());
51
+ while (this.seen.size > MAX_SESSIONS) {
52
+ const oldest = this.seen.keys().next().value;
53
+ if (oldest === undefined)
54
+ break;
55
+ this.seen.delete(oldest);
56
+ }
57
+ this.file?.changed();
58
+ return id;
59
+ }
60
+ /** Whether this is an id we issued and still keep - and, if so, that it was just used. */
61
+ known(id) {
62
+ const at = this.seen.get(id);
63
+ if (at === undefined)
64
+ return false;
65
+ const t = this.now();
66
+ if (t - at > SESSION_TTL_MS) {
67
+ this.seen.delete(id);
68
+ this.file?.changed();
69
+ return false;
70
+ }
71
+ if (t - at > TOUCH_EVERY_MS) {
72
+ this.seen.delete(id);
73
+ this.seen.set(id, t);
74
+ this.file?.changed();
75
+ }
76
+ return true;
77
+ }
78
+ get size() {
79
+ return this.seen.size;
80
+ }
81
+ /** At most once an hour: a walk over a hundred thousand ids is not free. */
82
+ sweep() {
83
+ const t = this.now();
84
+ if (t - this.sweptAt < TOUCH_EVERY_MS)
85
+ return;
86
+ this.sweptAt = t;
87
+ let gone = false;
88
+ for (const [id, at] of this.seen) {
89
+ if (t - at > SESSION_TTL_MS)
90
+ gone = this.seen.delete(id) || gone;
91
+ }
92
+ if (gone)
93
+ this.file?.changed();
94
+ }
95
+ }
96
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.js","sourceRoot":"","sources":["../src/sessions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ export declare class StateFile {
2
+ readonly path: string;
3
+ private timer;
4
+ private snapshot;
5
+ constructor(path: string);
6
+ /**
7
+ * What was written last time, or undefined when there is nothing usable.
8
+ *
9
+ * ⚠️ A FILE THAT DOES NOT PARSE IS SET ASIDE, NOT OVERWRITTEN. Starting
10
+ * empty and writing over it would destroy the only copy of somebody's
11
+ * booking; renamed, a person can still look at it.
12
+ */
13
+ read<T>(): T | undefined;
14
+ /** What to write. The store hands over a function, so the write takes the state as it is THEN. */
15
+ bind(snapshot: () => unknown): void;
16
+ /** Something changed: write it soon. */
17
+ changed(): void;
18
+ /** Write now if anything is waiting. */
19
+ flush(): void;
20
+ private write;
21
+ }
22
+ /** Where the stores of one deployment keep their files - or nothing, when it has no directory. */
23
+ export declare class StateDir {
24
+ readonly dir: string | undefined;
25
+ private readonly files;
26
+ constructor(dir: string | undefined);
27
+ get persisted(): boolean;
28
+ file(name: string): StateFile | undefined;
29
+ /** Everything still waiting, written now - on a clean stop. */
30
+ flush(): void;
31
+ }
32
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAuBA,qBAAa,SAAS;IAIR,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHjC,OAAO,CAAC,KAAK,CAA4C;IACzD,OAAO,CAAC,QAAQ,CAA8B;gBAEzB,IAAI,EAAE,MAAM;IAEjC;;;;;;OAMG;IACH,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,SAAS;IAuBxB,kGAAkG;IAClG,IAAI,CAAC,QAAQ,EAAE,MAAM,OAAO,GAAG,IAAI;IAInC,wCAAwC;IACxC,OAAO,IAAI,IAAI;IAMf,wCAAwC;IACxC,KAAK,IAAI,IAAI;IAOb,OAAO,CAAC,KAAK;CAed;AAED,kGAAkG;AAClG,qBAAa,QAAQ;IAGP,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS;IAF5C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmB;gBAEpB,GAAG,EAAE,MAAM,GAAG,SAAS;IAE5C,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAOzC,+DAA+D;IAC/D,KAAK,IAAI,IAAI;CAGd"}
package/dist/state.js ADDED
@@ -0,0 +1,118 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ // What survives a restart: one JSON file per store, in the deployment's state
4
+ // directory (`HOTEL_STATE_DIR`).
5
+ //
6
+ // ⚠️ A DEPLOY USED TO FORGET EVERY GUEST. Bookings, trips, sign-ins and the
7
+ // session ids themselves were in memory, and this connector ships several times
8
+ // a day: a guest told "held until 18:00, SL-7K2M4P" asked to cancel after a
9
+ // deploy and was told no booking matched - and the lookup with their email was
10
+ // then counted as a wrong guess (2026-10-05 audit). Now every store writes
11
+ // itself here, and reads itself back on boot.
12
+ //
13
+ // ⚠️ NO DIRECTORY, NO DISK. Without one the stores stay in memory exactly as
14
+ // before, so a local run or a test writes nothing anywhere. And the directory
15
+ // is never the brand's repository: that is mounted read-only on the box.
16
+ //
17
+ // Writes are coalesced - a trip id touched on every tool call would otherwise
18
+ // rewrite its file on every tool call - so the last half second before a crash
19
+ // can be lost. A clean stop (`flush`, on SIGTERM) loses nothing.
20
+ const WRITE_DELAY_MS = 500;
21
+ export class StateFile {
22
+ path;
23
+ timer;
24
+ snapshot;
25
+ constructor(path) {
26
+ this.path = path;
27
+ }
28
+ /**
29
+ * What was written last time, or undefined when there is nothing usable.
30
+ *
31
+ * ⚠️ A FILE THAT DOES NOT PARSE IS SET ASIDE, NOT OVERWRITTEN. Starting
32
+ * empty and writing over it would destroy the only copy of somebody's
33
+ * booking; renamed, a person can still look at it.
34
+ */
35
+ read() {
36
+ let text;
37
+ try {
38
+ text = readFileSync(this.path, 'utf8');
39
+ }
40
+ catch {
41
+ return undefined; // none yet
42
+ }
43
+ try {
44
+ return JSON.parse(text);
45
+ }
46
+ catch (err) {
47
+ const aside = `${this.path}.corrupt-${Date.now()}`;
48
+ try {
49
+ renameSync(this.path, aside);
50
+ }
51
+ catch {
52
+ // Unreadable and unmovable: starting empty is all that is left.
53
+ }
54
+ console.error(`[state] ${this.path} did not parse (${err.message}); kept as ${aside}`);
55
+ return undefined;
56
+ }
57
+ }
58
+ /** What to write. The store hands over a function, so the write takes the state as it is THEN. */
59
+ bind(snapshot) {
60
+ this.snapshot = snapshot;
61
+ }
62
+ /** Something changed: write it soon. */
63
+ changed() {
64
+ if (this.timer)
65
+ return;
66
+ this.timer = setTimeout(() => this.flush(), WRITE_DELAY_MS);
67
+ this.timer.unref?.();
68
+ }
69
+ /** Write now if anything is waiting. */
70
+ flush() {
71
+ if (!this.timer)
72
+ return;
73
+ clearTimeout(this.timer);
74
+ this.timer = undefined;
75
+ this.write();
76
+ }
77
+ write() {
78
+ if (!this.snapshot)
79
+ return;
80
+ try {
81
+ mkdirSync(dirname(this.path), { recursive: true });
82
+ const tmp = `${this.path}.${process.pid}.tmp`;
83
+ // ⚠️ OWNER-ONLY. These files hold names, emails and phone numbers.
84
+ writeFileSync(tmp, JSON.stringify(this.snapshot()), { mode: 0o600 });
85
+ chmodSync(tmp, 0o600);
86
+ renameSync(tmp, this.path);
87
+ }
88
+ catch (err) {
89
+ // A full disk must not take the conversation down with it: the state is
90
+ // still right in memory, and the next change tries again.
91
+ console.error(`[state] could not write ${this.path}: ${err.message}`);
92
+ }
93
+ }
94
+ }
95
+ /** Where the stores of one deployment keep their files - or nothing, when it has no directory. */
96
+ export class StateDir {
97
+ dir;
98
+ files = [];
99
+ constructor(dir) {
100
+ this.dir = dir;
101
+ }
102
+ get persisted() {
103
+ return this.dir !== undefined;
104
+ }
105
+ file(name) {
106
+ if (!this.dir)
107
+ return undefined;
108
+ const f = new StateFile(join(this.dir, name));
109
+ this.files.push(f);
110
+ return f;
111
+ }
112
+ /** Everything still waiting, written now - on a clean stop. */
113
+ flush() {
114
+ for (const f of this.files)
115
+ f.flush();
116
+ }
117
+ }
118
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.js","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACxF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C,8EAA8E;AAC9E,iCAAiC;AACjC,EAAE;AACF,4EAA4E;AAC5E,gFAAgF;AAChF,4EAA4E;AAC5E,+EAA+E;AAC/E,2EAA2E;AAC3E,8CAA8C;AAC9C,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,+EAA+E;AAC/E,iEAAiE;AAEjE,MAAM,cAAc,GAAG,GAAG,CAAC;AAE3B,MAAM,OAAO,SAAS;IAIC;IAHb,KAAK,CAA4C;IACjD,QAAQ,CAA8B;IAE9C,YAAqB,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;IAAG,CAAC;IAErC;;;;;;OAMG;IACH,IAAI;QACF,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC;YACH,IAAI,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,SAAS,CAAC,CAAC,WAAW;QAC/B,CAAC;QACD,IAAI,CAAC;YACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAM,CAAC;QAC/B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,IAAI,YAAY,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;YACnD,IAAI,CAAC;gBACH,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAC/B,CAAC;YAAC,MAAM,CAAC;gBACP,gEAAgE;YAClE,CAAC;YACD,OAAO,CAAC,KAAK,CACX,WAAW,IAAI,CAAC,IAAI,mBAAoB,GAAa,CAAC,OAAO,cAAc,KAAK,EAAE,CACnF,CAAC;YACF,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;IAED,kGAAkG;IAClG,IAAI,CAAC,QAAuB;QAC1B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;IAED,wCAAwC;IACxC,OAAO;QACL,IAAI,IAAI,CAAC,KAAK;YAAE,OAAO;QACvB,IAAI,CAAC,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,cAAc,CAAC,CAAC;QAC5D,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IACvB,CAAC;IAED,wCAAwC;IACxC,KAAK;QACH,IAAI,CAAC,IAAI,CAAC,KAAK;YAAE,OAAO;QACxB,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACvB,IAAI,CAAC,KAAK,EAAE,CAAC;IACf,CAAC;IAEO,KAAK;QACX,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,OAAO;QAC3B,IAAI,CAAC;YACH,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YACnD,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,MAAM,CAAC;YAC9C,mEAAmE;YACnE,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACrE,SAAS,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YACtB,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,wEAAwE;YACxE,0DAA0D;YAC1D,OAAO,CAAC,KAAK,CAAC,2BAA2B,IAAI,CAAC,IAAI,KAAM,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;CACF;AAED,kGAAkG;AAClG,MAAM,OAAO,QAAQ;IAGE;IAFJ,KAAK,GAAgB,EAAE,CAAC;IAEzC,YAAqB,GAAuB;QAAvB,QAAG,GAAH,GAAG,CAAoB;IAAG,CAAC;IAEhD,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,GAAG,KAAK,SAAS,CAAC;IAChC,CAAC;IAED,IAAI,CAAC,IAAY;QACf,IAAI,CAAC,IAAI,CAAC,GAAG;YAAE,OAAO,SAAS,CAAC;QAChC,MAAM,CAAC,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;QAC9C,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACnB,OAAO,CAAC,CAAC;IACX,CAAC;IAED,+DAA+D;IAC/D,KAAK;QACH,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK;YAAE,CAAC,CAAC,KAAK,EAAE,CAAC;IACxC,CAAC;CACF"}
package/dist/tools.d.ts CHANGED
@@ -774,6 +774,8 @@ export type ToolContext = {
774
774
  * cart was empty. See trip-ids.ts.
775
775
  */
776
776
  tripKey?: string;
777
+ /** The client's network address, as the edge proxy saw it - for the booking limits only. */
778
+ ip?: string;
777
779
  };
778
780
  export type ToolAnswer = {
779
781
  structured: Record<string, unknown>;
@@ -785,5 +787,16 @@ export type ToolAnswer = {
785
787
  * read better as hotels, grouped by city.
786
788
  */
787
789
  export declare const CITY_PICKER_MIN = 4;
790
+ /**
791
+ * The answer when the card names a trip this connector no longer knows.
792
+ *
793
+ * ⚠️ SAID, NOT PAPERED OVER. An unknown id used to fall back to the card's own
794
+ * connection - a DIFFERENT trip from the conversation's - and the card took
795
+ * that trip's id as its own. Rooms added on the card then went into a trip the
796
+ * assistant could not see, and "book it" was answered "the trip is empty"
797
+ * (2026-10-05 audit). The way back is the conversation's own trip, which the
798
+ * assistant's next answer hands the card.
799
+ */
800
+ export declare const tripGone: (deps: ToolDeps) => ToolAnswer;
788
801
  export declare const handleTool: (deps: ToolDeps, name: string, rawArgs: unknown, ctx: ToolContext) => Promise<ToolAnswer>;
789
802
  //# sourceMappingURL=tools.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"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"}
package/dist/tools.js CHANGED
@@ -5,7 +5,7 @@ import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
5
5
  import { formatMoney, money, multiplyMoney, toMinor } from './money.js';
6
6
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
7
7
  import { regionOf } from './regions.js';
8
- import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, tripKey, } from './trip.js';
8
+ import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, TRIP_TTL_MS, clampCount, tripKey, } from './trip.js';
9
9
  import { DEMO_NOTE } from './vocab.js';
10
10
  // The eleven things a guest can do: find hotels, look at one on dates, see what
11
11
  // else it offers, build a trip, ask for the rooms, and deal with a booking they
@@ -114,6 +114,23 @@ const forCapabilities = (tools, n, v, caps) => {
114
114
  }
115
115
  }
116
116
  if (t.name === n.requestBooking) {
117
+ if (caps.requiresPhone) {
118
+ const schema = out.inputSchema;
119
+ out = {
120
+ ...out,
121
+ inputSchema: {
122
+ ...schema,
123
+ properties: {
124
+ ...(schema.properties ?? {}),
125
+ phone: {
126
+ type: 'string',
127
+ description: "The guest's phone number, for the hotel to reach them",
128
+ },
129
+ },
130
+ required: [...new Set([...(schema.required ?? []), 'phone'])],
131
+ },
132
+ };
133
+ }
117
134
  if (!caps.members)
118
135
  out = { ...out, inputSchema: without(out.inputSchema, 'rememberRequests') };
119
136
  if (caps.booking === 'pay-at-hotel') {
@@ -982,11 +999,21 @@ const common = (deps, key) => ({
982
999
  ? Object.fromEntries(Object.entries(deps.tools).filter(([, name]) => deps.offered?.has(name)))
983
1000
  : deps.tools,
984
1001
  bookingMode: (deps.backend.capabilities ?? FULL_CAPABILITIES).booking,
1002
+ ...((deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone
1003
+ ? { phoneRequired: true }
1004
+ : {}),
985
1005
  ...(deps.vocab.sandbox ? { sandbox: true } : {}),
986
1006
  vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
987
1007
  ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
988
1008
  ...(key ? memberBadge(deps, key) : {}),
989
1009
  });
1010
+ /**
1011
+ * When a room stops being free to cancel, the way a guest reads it: the
1012
+ * hotel's own clock when the backend knows it, else the instant in UTC - never
1013
+ * the date alone (see `atHotelTime`).
1014
+ */
1015
+ const freeUntilText = (r) => r.freeCancellationLocal ??
1016
+ `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
990
1017
  /** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
991
1018
  const quoteForCard = (quote) => ({
992
1019
  lines: quote.lines.map((l) => ({
@@ -1328,6 +1355,8 @@ const identity = (deps, ctx, key, args = {}) => {
1328
1355
  // checks, not authentication, and it must never become a way to read
1329
1356
  // somebody else's booking.
1330
1357
  ...(member ? { member } : {}),
1358
+ // Only a booking limit reads it - see `Identity.ip`.
1359
+ ...(ctx.ip ? { ip: ctx.ip } : {}),
1331
1360
  };
1332
1361
  };
1333
1362
  /**
@@ -1682,6 +1711,23 @@ const suggestionsFor = async (deps, d) => {
1682
1711
  })
1683
1712
  .slice(0, 3);
1684
1713
  };
1714
+ /**
1715
+ * The answer when the card names a trip this connector no longer knows.
1716
+ *
1717
+ * ⚠️ SAID, NOT PAPERED OVER. An unknown id used to fall back to the card's own
1718
+ * connection - a DIFFERENT trip from the conversation's - and the card took
1719
+ * that trip's id as its own. Rooms added on the card then went into a trip the
1720
+ * assistant could not see, and "book it" was answered "the trip is empty"
1721
+ * (2026-10-05 audit). The way back is the conversation's own trip, which the
1722
+ * assistant's next answer hands the card.
1723
+ */
1724
+ export const tripGone = (deps) => {
1725
+ const detail = `The trip this card was showing is no longer kept - a trip is kept for ${TRIP_TTL_MS / 3_600_000} hours after its last change. Ask the assistant to show the trip again.`;
1726
+ return {
1727
+ structured: { view: 'error', ...common(deps), error: 'trip-gone', detail },
1728
+ text: detail,
1729
+ };
1730
+ };
1685
1731
  export const handleTool = async (deps, name, rawArgs, ctx) => {
1686
1732
  const answer = await answerTool(deps, name, rawArgs, ctx);
1687
1733
  const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
@@ -2166,6 +2212,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2166
2212
  // string, not by anyone looking at it.
2167
2213
  ...(r.publicNightly ? { publicNightly: formatMoney(r.publicNightly) } : {}),
2168
2214
  ...(r.publicStayTotal ? { publicStayTotal: formatMoney(r.publicStayTotal) } : {}),
2215
+ // ⚠️ AND STRUCK THROUGH FOR AS MANY ROOMS AS THE PRICE BESIDE IT. One
2216
+ // room's public rate drawn over two rooms' member rate put a "was" price
2217
+ // BELOW the price a member pays (2026-10-05 audit).
2218
+ ...(r.publicStayTotal && roomsWanted > 1
2219
+ ? { publicRoomsTotal: formatMoney(multiplyMoney(r.publicStayTotal, roomsWanted)) }
2220
+ : {}),
2221
+ ...(r.freeCancellationUntil ? { freeCancellationLocal: freeUntilText(r) } : {}),
2169
2222
  }));
2170
2223
  return {
2171
2224
  structured: {
@@ -2178,7 +2231,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2178
2231
  },
2179
2232
  text: [
2180
2233
  `**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
2181
- ...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${r.freeCancellationUntil.slice(0, 10)}` : r.refundable ? ', the free cancellation window has closed - cancelling now has a fee' : ', non-refundable'}`),
2234
+ ...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? ', the free cancellation window has closed - cancelling now has a fee' : ', non-refundable'}`),
2182
2235
  flex.text,
2183
2236
  deps.vocab.demo ? DEMO_NOTE : '',
2184
2237
  ]
@@ -2259,7 +2312,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2259
2312
  : rooms.length
2260
2313
  ? [
2261
2314
  `Rooms for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}:`,
2262
- ...rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${r.freeCancellationUntil ? `, free to cancel until ${r.freeCancellationUntil.slice(0, 10)}` : r.refundable ? '' : ', non-refundable'}`),
2315
+ ...rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`),
2263
2316
  ].join('\n')
2264
2317
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
2265
2318
  extras.length
@@ -2487,11 +2540,22 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2487
2540
  };
2488
2541
  }
2489
2542
  const guest = { name: str(args.name), email: str(args.email) };
2490
- const missing = [!guest.name && 'a name', !guest.email.includes('@') && 'an email'].filter((x) => typeof x === 'string');
2543
+ // A phone where the backend needs one: a booking paid at the hotel and
2544
+ // guaranteed by nobody, which the hotel has to be able to follow up.
2545
+ const phoneNeeded = !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
2546
+ const phone = str(args.phone);
2547
+ const missing = [
2548
+ !guest.name && 'a name',
2549
+ !guest.email.includes('@') && 'an email',
2550
+ phoneNeeded && phone.replace(/\D/g, '').length < 6 && 'a phone number',
2551
+ ].filter((x) => typeof x === 'string');
2491
2552
  if (missing.length) {
2553
+ const said = missing.length > 1
2554
+ ? `${missing.slice(0, -1).join(', ')} and ${missing.at(-1)}`
2555
+ : (missing[0] ?? '');
2492
2556
  return {
2493
2557
  structured: { view: 'needs-guest', ...common(deps, key), missing },
2494
- text: `The booking needs ${missing.join(' and ')}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a name or email you know from anywhere else.`,
2558
+ text: `The booking needs ${said}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a ${phoneNeeded ? 'name, email or phone number' : 'name or email'} you know from anywhere else.`,
2495
2559
  };
2496
2560
  }
2497
2561
  const { requests, dropped } = readRequests(args);