sbb-mcp 0.4.3 → 0.5.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.
Files changed (83) hide show
  1. package/LICENSE +50 -57
  2. package/README.md +25 -214
  3. package/dist/index.js +47 -19
  4. package/package.json +10 -33
  5. package/dist/auth.d.ts +0 -2
  6. package/dist/auth.js +0 -44
  7. package/dist/auth.js.map +0 -1
  8. package/dist/cache.d.ts +0 -14
  9. package/dist/cache.js +0 -62
  10. package/dist/cache.js.map +0 -1
  11. package/dist/client.d.ts +0 -17
  12. package/dist/client.js +0 -70
  13. package/dist/client.js.map +0 -1
  14. package/dist/formatters.d.ts +0 -35
  15. package/dist/formatters.js +0 -285
  16. package/dist/formatters.js.map +0 -1
  17. package/dist/http.d.ts +0 -2
  18. package/dist/http.js +0 -117
  19. package/dist/http.js.map +0 -1
  20. package/dist/i18n.d.ts +0 -22
  21. package/dist/i18n.js +0 -36
  22. package/dist/i18n.js.map +0 -1
  23. package/dist/index.d.ts +0 -2
  24. package/dist/index.js.map +0 -1
  25. package/dist/journey.d.ts +0 -5
  26. package/dist/journey.js +0 -67
  27. package/dist/journey.js.map +0 -1
  28. package/dist/look2book.d.ts +0 -98
  29. package/dist/look2book.js +0 -212
  30. package/dist/look2book.js.map +0 -1
  31. package/dist/prices.d.ts +0 -3
  32. package/dist/prices.js +0 -51
  33. package/dist/prices.js.map +0 -1
  34. package/dist/profile.d.ts +0 -16
  35. package/dist/profile.js +0 -84
  36. package/dist/profile.js.map +0 -1
  37. package/dist/rate-limit.d.ts +0 -5
  38. package/dist/rate-limit.js +0 -44
  39. package/dist/rate-limit.js.map +0 -1
  40. package/dist/shortlink.d.ts +0 -60
  41. package/dist/shortlink.js +0 -122
  42. package/dist/shortlink.js.map +0 -1
  43. package/dist/structured.d.ts +0 -125
  44. package/dist/structured.js +0 -134
  45. package/dist/structured.js.map +0 -1
  46. package/dist/swisstrip.d.ts +0 -41
  47. package/dist/swisstrip.js +0 -135
  48. package/dist/swisstrip.js.map +0 -1
  49. package/dist/tools.d.ts +0 -40
  50. package/dist/tools.js +0 -509
  51. package/dist/tools.js.map +0 -1
  52. package/dist/transport/index.d.ts +0 -10
  53. package/dist/transport/index.js +0 -13
  54. package/dist/transport/index.js.map +0 -1
  55. package/dist/transport/setup.d.ts +0 -1
  56. package/dist/transport/setup.js +0 -59
  57. package/dist/transport/setup.js.map +0 -1
  58. package/dist/transport/smapi-auth.d.ts +0 -14
  59. package/dist/transport/smapi-auth.js +0 -89
  60. package/dist/transport/smapi-auth.js.map +0 -1
  61. package/dist/transport/smapi-client.d.ts +0 -46
  62. package/dist/transport/smapi-client.js +0 -186
  63. package/dist/transport/smapi-client.js.map +0 -1
  64. package/dist/transport/smapi-journey.d.ts +0 -29
  65. package/dist/transport/smapi-journey.js +0 -91
  66. package/dist/transport/smapi-journey.js.map +0 -1
  67. package/dist/transport/smapi-mock.d.ts +0 -9
  68. package/dist/transport/smapi-mock.js +0 -151
  69. package/dist/transport/smapi-mock.js.map +0 -1
  70. package/dist/transport/smapi-prices.d.ts +0 -48
  71. package/dist/transport/smapi-prices.js +0 -144
  72. package/dist/transport/smapi-prices.js.map +0 -1
  73. package/dist/transport/smapi-types.d.ts +0 -181
  74. package/dist/transport/smapi-types.js +0 -2
  75. package/dist/transport/smapi-types.js.map +0 -1
  76. package/dist/types.d.ts +0 -139
  77. package/dist/types.js +0 -3
  78. package/dist/types.js.map +0 -1
  79. package/dist/widgets.d.ts +0 -60
  80. package/dist/widgets.js +0 -184
  81. package/dist/widgets.js.map +0 -1
  82. package/web/dist/widgets.css +0 -1
  83. package/web/dist/widgets.js +0 -1
package/dist/i18n.d.ts DELETED
@@ -1,22 +0,0 @@
1
- /**
2
- * Language helpers for sbb-mcp.
3
- *
4
- * Re-exports the shared `sbb-i18n` primitives and adds MCP-specific helpers:
5
- *
6
- * - `toLocale(lang)` — maps our 9 language codes to BCP-47 locale tags for
7
- * `Intl.DateTimeFormat`. Swiss languages get `-CH` (so e.g. dates render as
8
- * DD.MM.YYYY for de-CH, not DD/MM/YYYY); non-Swiss use their primary region.
9
- * - `SBB_LINK_LANGS` — languages SBB.ch actually supports in URL paths. The
10
- * rest fall back to English for deep links.
11
- */
12
- import type { Lang } from 'sbb-i18n';
13
- export type { Lang, Translations } from 'sbb-i18n';
14
- export { resolveLang, t, SUPPORTED_LANGS, isLang } from 'sbb-i18n';
15
- /** BCP-47 locale tag for `Intl.DateTimeFormat`. Swiss languages use -CH variants. */
16
- export declare function toLocale(lang: Lang): string;
17
- /**
18
- * Languages SBB.ch supports in its public URLs (/de, /fr, /it, /en).
19
- * Other input languages fall back to English when building deep links.
20
- */
21
- export declare const SBB_LINK_LANGS: readonly Lang[];
22
- export declare function toSbbLinkLang(lang: Lang): 'de' | 'fr' | 'it' | 'en';
package/dist/i18n.js DELETED
@@ -1,36 +0,0 @@
1
- /**
2
- * Language helpers for sbb-mcp.
3
- *
4
- * Re-exports the shared `sbb-i18n` primitives and adds MCP-specific helpers:
5
- *
6
- * - `toLocale(lang)` — maps our 9 language codes to BCP-47 locale tags for
7
- * `Intl.DateTimeFormat`. Swiss languages get `-CH` (so e.g. dates render as
8
- * DD.MM.YYYY for de-CH, not DD/MM/YYYY); non-Swiss use their primary region.
9
- * - `SBB_LINK_LANGS` — languages SBB.ch actually supports in URL paths. The
10
- * rest fall back to English for deep links.
11
- */
12
- export { resolveLang, t, SUPPORTED_LANGS, isLang } from 'sbb-i18n';
13
- const LOCALE_MAP = {
14
- de: 'de-CH',
15
- fr: 'fr-CH',
16
- it: 'it-CH',
17
- en: 'en-CH',
18
- es: 'es-ES',
19
- pt: 'pt-PT',
20
- ru: 'ru-RU',
21
- ar: 'ar',
22
- zh: 'zh-CN',
23
- };
24
- /** BCP-47 locale tag for `Intl.DateTimeFormat`. Swiss languages use -CH variants. */
25
- export function toLocale(lang) {
26
- return LOCALE_MAP[lang];
27
- }
28
- /**
29
- * Languages SBB.ch supports in its public URLs (/de, /fr, /it, /en).
30
- * Other input languages fall back to English when building deep links.
31
- */
32
- export const SBB_LINK_LANGS = ['de', 'fr', 'it', 'en'];
33
- export function toSbbLinkLang(lang) {
34
- return SBB_LINK_LANGS.includes(lang) ? lang : 'en';
35
- }
36
- //# sourceMappingURL=i18n.js.map
package/dist/i18n.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,EAAE,WAAW,EAAE,CAAC,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAElE,MAAM,UAAU,GAAyB;IACvC,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,IAAI;IACR,EAAE,EAAE,OAAO;CACZ,CAAA;AAED,qFAAqF;AACrF,MAAM,UAAU,QAAQ,CAAC,IAAU;IACjC,OAAO,UAAU,CAAC,IAAI,CAAC,CAAA;AACzB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAU,CAAA;AAEhF,MAAM,UAAU,aAAa,CAAC,IAAU;IACtC,OAAQ,cAAoC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,IAAkC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC1G,CAAC"}
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/index.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAA;AAChF,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAA;AAClE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAE/C,KAAK,UAAU,IAAI;IACjB,2BAA2B,EAAE,CAAA;IAC7B,MAAM,MAAM,GAAG,kBAAkB,EAAE,CAAA;IACnC,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAA;IAC5C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;IAE/B,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC;QACzB,OAAO,CAAC,KAAK,CAAC,kEAAkE,CAAC,CAAA;QACjF,OAAO,CAAC,KAAK,CAAC,+EAA+E,CAAC,CAAA;IAChG,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,KAAK,CAAC,kCAAkC,CAAC,CAAA;IACnD,CAAC;IAED,IAAI,qBAAqB,EAAE,EAAE,CAAC;QAC5B,OAAO,CAAC,KAAK,CAAC,kEAAkE,CAAC,CAAA;IACnF,CAAC;AACH,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,GAAG,CAAC,CAAA;IAC5C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"}
package/dist/journey.d.ts DELETED
@@ -1,5 +0,0 @@
1
- import type { Place, Trip, TripsCollection, TripSearchParams, PlaceSearchParams } from './types.js';
2
- export declare function searchPlaces(params: PlaceSearchParams): Promise<Place[]>;
3
- export declare function searchTrips(params: TripSearchParams): Promise<TripsCollection>;
4
- export declare function getTrip(tripId: string, stopBehavior?: 'ORIGIN_DESTINATION_ONLY' | 'REAL_BOARDING_ALIGHTING'): Promise<Trip>;
5
- export declare function paginateTrips(collectionId: string, direction: 'next' | 'previous'): Promise<TripsCollection>;
package/dist/journey.js DELETED
@@ -1,67 +0,0 @@
1
- import { smapiRequest, getJourneyBaseUrl } from './client.js';
2
- export async function searchPlaces(params) {
3
- const body = {
4
- placeInput: { name: params.name },
5
- restrictions: {
6
- ...(params.type && { type: params.type }),
7
- numberOfResults: params.numberOfResults ?? 10,
8
- },
9
- };
10
- const result = await smapiRequest(getJourneyBaseUrl(), '/v1/places', { method: 'POST', body });
11
- return result.places ?? [];
12
- }
13
- export async function searchTrips(params) {
14
- const body = {
15
- origin: {
16
- objectType: 'StopPlaceRef',
17
- stopPlaceRef: params.origin,
18
- },
19
- destination: {
20
- objectType: 'StopPlaceRef',
21
- stopPlaceRef: params.destination,
22
- },
23
- };
24
- if (params.departureTime) {
25
- body.departureTime = params.departureTime;
26
- }
27
- else if (params.arrivalTime) {
28
- body.arrivalTime = params.arrivalTime;
29
- }
30
- else {
31
- body.departureTime = new Date().toISOString();
32
- }
33
- if (params.transferLimit !== undefined) {
34
- body.parameters = { transferLimit: params.transferLimit };
35
- }
36
- if (params.vias?.length) {
37
- body.vias = params.vias.map((via) => ({
38
- viaPlace: {
39
- objectType: 'StopPlaceRef',
40
- stopPlaceRef: via.stopPlaceRef,
41
- },
42
- ...(via.dwellTime && { dwellTime: via.dwellTime }),
43
- }));
44
- }
45
- if (params.ptModeFilter) {
46
- const parameters = body.parameters || {};
47
- parameters.dataFilter = {
48
- ptModeFilter: {
49
- exclude: params.ptModeFilter.exclude,
50
- transportModes: params.ptModeFilter.transportModes.map((mode) => ({
51
- ptMode: mode,
52
- })),
53
- },
54
- };
55
- body.parameters = parameters;
56
- }
57
- return smapiRequest(getJourneyBaseUrl(), '/v1/trips-collection', { method: 'POST', body });
58
- }
59
- export async function getTrip(tripId, stopBehavior = 'ORIGIN_DESTINATION_ONLY') {
60
- const params = new URLSearchParams({ stopBehavior });
61
- return smapiRequest(getJourneyBaseUrl(), `/v1/trips/${encodeURIComponent(tripId)}?${params}`);
62
- }
63
- export async function paginateTrips(collectionId, direction) {
64
- const params = new URLSearchParams({ page: direction });
65
- return smapiRequest(getJourneyBaseUrl(), `/v1/trips-collections/${encodeURIComponent(collectionId)}?${params}`);
66
- }
67
- //# sourceMappingURL=journey.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"journey.js","sourceRoot":"","sources":["../src/journey.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAS7D,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,MAAyB;IAEzB,MAAM,IAAI,GAAG;QACX,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE;QACjC,YAAY,EAAE;YACZ,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC;YACzC,eAAe,EAAE,MAAM,CAAC,eAAe,IAAI,EAAE;SAC9C;KACF,CAAA;IAED,MAAM,MAAM,GAAG,MAAM,YAAY,CAC/B,iBAAiB,EAAE,EACnB,YAAY,EACZ,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CACzB,CAAA;IAED,OAAO,MAAM,CAAC,MAAM,IAAI,EAAE,CAAA;AAC5B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,MAAwB;IAExB,MAAM,IAAI,GAA4B;QACpC,MAAM,EAAE;YACN,UAAU,EAAE,cAAc;YAC1B,YAAY,EAAE,MAAM,CAAC,MAAM;SAC5B;QACD,WAAW,EAAE;YACX,UAAU,EAAE,cAAc;YAC1B,YAAY,EAAE,MAAM,CAAC,WAAW;SACjC;KACF,CAAA;IAED,IAAI,MAAM,CAAC,aAAa,EAAE,CAAC;QACzB,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,CAAA;IAC3C,CAAC;SAAM,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QAC9B,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,WAAW,CAAA;IACvC,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,aAAa,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;IAC/C,CAAC;IAED,IAAI,MAAM,CAAC,aAAa,KAAK,SAAS,EAAE,CAAC;QACvC,IAAI,CAAC,UAAU,GAAG,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,EAAE,CAAA;IAC3D,CAAC;IAED,IAAI,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACpC,QAAQ,EAAE;gBACR,UAAU,EAAE,cAAc;gBAC1B,YAAY,EAAE,GAAG,CAAC,YAAY;aAC/B;YACD,GAAG,CAAC,GAAG,CAAC,SAAS,IAAI,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC;SACnD,CAAC,CAAC,CAAA;IACL,CAAC;IAED,IAAI,MAAM,CAAC,YAAY,EAAE,CAAC;QACxB,MAAM,UAAU,GAAI,IAAI,CAAC,UAAsC,IAAI,EAAE,CAAA;QACrE,UAAU,CAAC,UAAU,GAAG;YACtB,YAAY,EAAE;gBACZ,OAAO,EAAE,MAAM,CAAC,YAAY,CAAC,OAAO;gBACpC,cAAc,EAAE,MAAM,CAAC,YAAY,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;oBAChE,MAAM,EAAE,IAAI;iBACb,CAAC,CAAC;aACJ;SACF,CAAA;QACD,IAAI,CAAC,UAAU,GAAG,UAAU,CAAA;IAC9B,CAAC;IAED,OAAO,YAAY,CACjB,iBAAiB,EAAE,EACnB,sBAAsB,EACtB,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CACzB,CAAA;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,MAAc,EACd,eAAsE,yBAAyB;IAE/F,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,EAAE,YAAY,EAAE,CAAC,CAAA;IACpD,OAAO,YAAY,CACjB,iBAAiB,EAAE,EACnB,aAAa,kBAAkB,CAAC,MAAM,CAAC,IAAI,MAAM,EAAE,CACpD,CAAA;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,YAAoB,EACpB,SAA8B;IAE9B,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;IACvD,OAAO,YAAY,CACjB,iBAAiB,EAAE,EACnB,yBAAyB,kBAAkB,CAAC,YAAY,CAAC,IAAI,MAAM,EAAE,CACtE,CAAA;AACH,CAAC"}
@@ -1,98 +0,0 @@
1
- /**
2
- * look2book.ts — Look2Book ratio telemetry for SBB SMAPI calls.
3
- *
4
- * ## Why
5
- * SBB's Distributionsvertrag caps the API-call-to-booking-click ratio:
6
- * - Trips API (places, trips, trip-details, pagination): max 100 : 1
7
- * - Offers API (prices, trip-offers): max 50 : 1
8
- *
9
- * Violating the ratio is a contractual issue, not a technical one — SBB
10
- * audits quarterly. We need to: (1) observe the ratio in production so we
11
- * can intervene before an audit finds us over, and (2) emit an operator
12
- * warning when a running window crosses the threshold so the on-call can
13
- * investigate (bot traffic? broken booking flow? missing CTAs?).
14
- *
15
- * ## What this module does — and doesn't do
16
- * Does:
17
- * - Count every SMAPI request (classified `trips` vs `offers` by base URL)
18
- * and every booking-link click (from `/r/:token`) on a sliding 1-hour
19
- * window.
20
- * - Emit a JSON line per event to stdout so Railway's log drain (or any
21
- * log forwarder) can ship it to long-term storage for trend analysis.
22
- * - Log a `sbb_look2book_violation` warning JSON line once per 5-minute
23
- * cooldown per family when the current window's ratio exceeds the cap.
24
- * - Expose a synchronous `getRatioSnapshot()` so `/health` can surface the
25
- * current state for readiness probes / dashboards.
26
- *
27
- * Does NOT:
28
- * - Block or rate-limit violating requests. Look2Book is a business metric,
29
- * not a request gate — gating tool calls based on a ratio would break UX
30
- * catastrophically (e.g. a single user who hasn't clicked yet would see
31
- * subsequent searches fail). See rate-limit.ts for the actual per-IP gate.
32
- * - Persist counters. Railway restarts reset the window — acceptable since
33
- * the contractual window is aggregate and real analysis happens offline
34
- * via the JSON-line logs.
35
- *
36
- * ## Design: in-memory sliding window
37
- * A single array of `{ts, kind}` events, pruned at every read/write so old
38
- * entries fall off after `WINDOW_MS`. O(n) prune on each call, but n is
39
- * bounded by the per-IP rate limit (60/min × 60min ≈ 3.6k events worst
40
- * case) so this stays well under a millisecond.
41
- *
42
- * Events are timestamped with an injectable `now()` so tests can drive the
43
- * window deterministically without `vi.useFakeTimers`.
44
- */
45
- /** Tool family — matches the two SMAPI base URLs in smapi-client.ts. */
46
- export type ApiFamily = 'trips' | 'offers';
47
- /** Snapshot returned by getRatioSnapshot() — safe to JSON-serialize. */
48
- export interface RatioSnapshot {
49
- windowMs: number;
50
- trips: {
51
- calls: number;
52
- clicks: number;
53
- ratio: number | null;
54
- cap: number;
55
- violated: boolean;
56
- };
57
- offers: {
58
- calls: number;
59
- clicks: number;
60
- ratio: number | null;
61
- cap: number;
62
- violated: boolean;
63
- };
64
- }
65
- /**
66
- * Record a successful SMAPI call. Call AFTER the request succeeds so failed
67
- * requests (auth errors, network blips) don't pollute the ratio — only
68
- * billable, data-returning calls count against the cap.
69
- *
70
- * The `endpoint` arg is logged for offline attribution (which tool is the
71
- * worst offender?) but doesn't affect bucketing.
72
- */
73
- export declare function recordApiCall(family: ApiFamily, endpoint: string): void;
74
- /**
75
- * Record a booking-link click (from /r/:token). The click "pays back" one
76
- * unit of ratio for either family — SBB's contract counts all clicks toward
77
- * both caps since a single click represents real user booking intent.
78
- */
79
- export declare function recordClick(meta?: {
80
- tripId?: string;
81
- }): void;
82
- /**
83
- * Current window state. Cheap (bounded prune + single pass). Safe to call
84
- * from a health endpoint handler without a cache.
85
- */
86
- export declare function getRatioSnapshot(): RatioSnapshot;
87
- /** Reset all state. Call between tests. */
88
- export declare function __resetForTesting(): void;
89
- /** Override the clock for deterministic window tests. */
90
- export declare function __setClockForTesting(fn: () => number): void;
91
- /** Expose tunables so tests can read them instead of duplicating the numbers. */
92
- export declare const __internals: {
93
- readonly WINDOW_MS: number;
94
- readonly CAP_TRIPS: 100;
95
- readonly CAP_OFFERS: 50;
96
- readonly ALERT_COOLDOWN_MS: number;
97
- readonly MIN_CALLS_BEFORE_ALERT: 10;
98
- };
package/dist/look2book.js DELETED
@@ -1,212 +0,0 @@
1
- /**
2
- * look2book.ts — Look2Book ratio telemetry for SBB SMAPI calls.
3
- *
4
- * ## Why
5
- * SBB's Distributionsvertrag caps the API-call-to-booking-click ratio:
6
- * - Trips API (places, trips, trip-details, pagination): max 100 : 1
7
- * - Offers API (prices, trip-offers): max 50 : 1
8
- *
9
- * Violating the ratio is a contractual issue, not a technical one — SBB
10
- * audits quarterly. We need to: (1) observe the ratio in production so we
11
- * can intervene before an audit finds us over, and (2) emit an operator
12
- * warning when a running window crosses the threshold so the on-call can
13
- * investigate (bot traffic? broken booking flow? missing CTAs?).
14
- *
15
- * ## What this module does — and doesn't do
16
- * Does:
17
- * - Count every SMAPI request (classified `trips` vs `offers` by base URL)
18
- * and every booking-link click (from `/r/:token`) on a sliding 1-hour
19
- * window.
20
- * - Emit a JSON line per event to stdout so Railway's log drain (or any
21
- * log forwarder) can ship it to long-term storage for trend analysis.
22
- * - Log a `sbb_look2book_violation` warning JSON line once per 5-minute
23
- * cooldown per family when the current window's ratio exceeds the cap.
24
- * - Expose a synchronous `getRatioSnapshot()` so `/health` can surface the
25
- * current state for readiness probes / dashboards.
26
- *
27
- * Does NOT:
28
- * - Block or rate-limit violating requests. Look2Book is a business metric,
29
- * not a request gate — gating tool calls based on a ratio would break UX
30
- * catastrophically (e.g. a single user who hasn't clicked yet would see
31
- * subsequent searches fail). See rate-limit.ts for the actual per-IP gate.
32
- * - Persist counters. Railway restarts reset the window — acceptable since
33
- * the contractual window is aggregate and real analysis happens offline
34
- * via the JSON-line logs.
35
- *
36
- * ## Design: in-memory sliding window
37
- * A single array of `{ts, kind}` events, pruned at every read/write so old
38
- * entries fall off after `WINDOW_MS`. O(n) prune on each call, but n is
39
- * bounded by the per-IP rate limit (60/min × 60min ≈ 3.6k events worst
40
- * case) so this stays well under a millisecond.
41
- *
42
- * Events are timestamped with an injectable `now()` so tests can drive the
43
- * window deterministically without `vi.useFakeTimers`.
44
- */
45
- // ─── Tunables ───────────────────────────────────────────────────────────────
46
- /** Rolling window length. 1 hour balances responsiveness vs noise. */
47
- const WINDOW_MS = 60 * 60 * 1000;
48
- /** Maximum API-calls-to-clicks ratio per family — contractual caps. */
49
- const CAP_TRIPS = 100;
50
- const CAP_OFFERS = 50;
51
- /** Minimum gap between duplicate violation warnings per family. */
52
- const ALERT_COOLDOWN_MS = 5 * 60 * 1000;
53
- /**
54
- * Don't warn on the first few calls of a fresh window — a single call with
55
- * zero clicks gives infinite ratio, which is technically a violation but
56
- * operationally meaningless. Wait until we have enough calls to reason about.
57
- */
58
- const MIN_CALLS_BEFORE_ALERT = 10;
59
- // ─── Mutable module state ───────────────────────────────────────────────────
60
- const events = [];
61
- const lastAlertTs = { trips: 0, offers: 0 };
62
- /** Clock injector for deterministic tests. Defaults to Date.now. */
63
- let nowFn = () => Date.now();
64
- // ─── Internals ──────────────────────────────────────────────────────────────
65
- function pruneOld(now) {
66
- const cutoff = now - WINDOW_MS;
67
- // Array.shift is O(n) but events is bounded (~3.6k max); fine.
68
- while (events.length > 0 && events[0].ts < cutoff) {
69
- events.shift();
70
- }
71
- }
72
- function countsFor(family) {
73
- let calls = 0;
74
- let clicks = 0;
75
- for (const ev of events) {
76
- if (ev.kind === family)
77
- calls++;
78
- else if (ev.kind === 'click')
79
- clicks++;
80
- }
81
- return { calls, clicks };
82
- }
83
- /**
84
- * Current ratio for a family. Returns `null` when no calls yet (undefined
85
- * ratio). When calls > 0 and clicks == 0, returns `Infinity` — callers
86
- * should treat this as "over the cap" but violation logic also checks
87
- * MIN_CALLS_BEFORE_ALERT to avoid spurious warnings.
88
- */
89
- function ratioFor(family) {
90
- const { calls, clicks } = countsFor(family);
91
- if (calls === 0)
92
- return null;
93
- if (clicks === 0)
94
- return Infinity;
95
- return calls / clicks;
96
- }
97
- function capFor(family) {
98
- return family === 'trips' ? CAP_TRIPS : CAP_OFFERS;
99
- }
100
- function logJson(line) {
101
- // Single JSON line per event — consistent with the /r/:token click log.
102
- // Using stdout directly; Railway (and most log drains) capture it verbatim.
103
- console.log(JSON.stringify(line));
104
- }
105
- function checkAndWarn(family, now) {
106
- const { calls, clicks } = countsFor(family);
107
- if (calls < MIN_CALLS_BEFORE_ALERT)
108
- return;
109
- const cap = capFor(family);
110
- const ratio = ratioFor(family);
111
- if (ratio === null || ratio <= cap)
112
- return;
113
- if (now - lastAlertTs[family] < ALERT_COOLDOWN_MS)
114
- return;
115
- lastAlertTs[family] = now;
116
- console.warn(JSON.stringify({
117
- event: 'sbb_look2book_violation',
118
- ts: new Date(now).toISOString(),
119
- family,
120
- calls,
121
- clicks,
122
- ratio: ratio === Infinity ? 'infinity' : Number(ratio.toFixed(2)),
123
- cap,
124
- windowMs: WINDOW_MS,
125
- }));
126
- }
127
- // ─── Public API ─────────────────────────────────────────────────────────────
128
- /**
129
- * Record a successful SMAPI call. Call AFTER the request succeeds so failed
130
- * requests (auth errors, network blips) don't pollute the ratio — only
131
- * billable, data-returning calls count against the cap.
132
- *
133
- * The `endpoint` arg is logged for offline attribution (which tool is the
134
- * worst offender?) but doesn't affect bucketing.
135
- */
136
- export function recordApiCall(family, endpoint) {
137
- const now = nowFn();
138
- pruneOld(now);
139
- events.push({ ts: now, kind: family, endpoint });
140
- logJson({
141
- event: 'sbb_smapi_call',
142
- ts: new Date(now).toISOString(),
143
- family,
144
- endpoint,
145
- });
146
- checkAndWarn(family, now);
147
- }
148
- /**
149
- * Record a booking-link click (from /r/:token). The click "pays back" one
150
- * unit of ratio for either family — SBB's contract counts all clicks toward
151
- * both caps since a single click represents real user booking intent.
152
- */
153
- export function recordClick(meta = {}) {
154
- const now = nowFn();
155
- pruneOld(now);
156
- events.push({ ts: now, kind: 'click' });
157
- logJson({
158
- event: 'sbb_look2book_click',
159
- ts: new Date(now).toISOString(),
160
- ...(meta.tripId && { tripId: meta.tripId }),
161
- });
162
- }
163
- /**
164
- * Current window state. Cheap (bounded prune + single pass). Safe to call
165
- * from a health endpoint handler without a cache.
166
- */
167
- export function getRatioSnapshot() {
168
- const now = nowFn();
169
- pruneOld(now);
170
- const t = countsFor('trips');
171
- const o = countsFor('offers');
172
- const tRatio = ratioFor('trips');
173
- const oRatio = ratioFor('offers');
174
- return {
175
- windowMs: WINDOW_MS,
176
- trips: {
177
- calls: t.calls,
178
- clicks: t.clicks,
179
- ratio: tRatio === Infinity ? null : tRatio,
180
- cap: CAP_TRIPS,
181
- violated: tRatio !== null && tRatio > CAP_TRIPS,
182
- },
183
- offers: {
184
- calls: o.calls,
185
- clicks: o.clicks,
186
- ratio: oRatio === Infinity ? null : oRatio,
187
- cap: CAP_OFFERS,
188
- violated: oRatio !== null && oRatio > CAP_OFFERS,
189
- },
190
- };
191
- }
192
- // ─── Test hooks ─────────────────────────────────────────────────────────────
193
- /** Reset all state. Call between tests. */
194
- export function __resetForTesting() {
195
- events.length = 0;
196
- lastAlertTs.trips = 0;
197
- lastAlertTs.offers = 0;
198
- nowFn = () => Date.now();
199
- }
200
- /** Override the clock for deterministic window tests. */
201
- export function __setClockForTesting(fn) {
202
- nowFn = fn;
203
- }
204
- /** Expose tunables so tests can read them instead of duplicating the numbers. */
205
- export const __internals = {
206
- WINDOW_MS,
207
- CAP_TRIPS,
208
- CAP_OFFERS,
209
- ALERT_COOLDOWN_MS,
210
- MIN_CALLS_BEFORE_ALERT,
211
- };
212
- //# sourceMappingURL=look2book.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"look2book.js","sourceRoot":"","sources":["../src/look2book.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAoBH,+EAA+E;AAE/E,sEAAsE;AACtE,MAAM,SAAS,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEhC,uEAAuE;AACvE,MAAM,SAAS,GAAG,GAAG,CAAA;AACrB,MAAM,UAAU,GAAG,EAAE,CAAA;AAErB,mEAAmE;AACnE,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;AAEvC;;;;GAIG;AACH,MAAM,sBAAsB,GAAG,EAAE,CAAA;AAEjC,+EAA+E;AAE/E,MAAM,MAAM,GAAY,EAAE,CAAA;AAC1B,MAAM,WAAW,GAA8B,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;AAEtE,oEAAoE;AACpE,IAAI,KAAK,GAAiB,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAA;AAE1C,+EAA+E;AAE/E,SAAS,QAAQ,CAAC,GAAW;IAC3B,MAAM,MAAM,GAAG,GAAG,GAAG,SAAS,CAAA;IAC9B,+DAA+D;IAC/D,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,CAAC;QAClD,MAAM,CAAC,KAAK,EAAE,CAAA;IAChB,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,MAAiB;IAClC,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACxB,IAAI,EAAE,CAAC,IAAI,KAAK,MAAM;YAAE,KAAK,EAAE,CAAA;aAC1B,IAAI,EAAE,CAAC,IAAI,KAAK,OAAO;YAAE,MAAM,EAAE,CAAA;IACxC,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AAC1B,CAAC;AAED;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,MAAiB;IACjC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC,CAAA;IAC3C,IAAI,KAAK,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAC5B,IAAI,MAAM,KAAK,CAAC;QAAE,OAAO,QAAQ,CAAA;IACjC,OAAO,KAAK,GAAG,MAAM,CAAA;AACvB,CAAC;AAED,SAAS,MAAM,CAAC,MAAiB;IAC/B,OAAO,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAA;AACpD,CAAC;AAED,SAAS,OAAO,CAAC,IAA6B;IAC5C,wEAAwE;IACxE,4EAA4E;IAC5E,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAA;AACnC,CAAC;AAED,SAAS,YAAY,CAAC,MAAiB,EAAE,GAAW;IAClD,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC,CAAA;IAC3C,IAAI,KAAK,GAAG,sBAAsB;QAAE,OAAM;IAE1C,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,CAAA;IAC1B,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAA;IAC9B,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,IAAI,GAAG;QAAE,OAAM;IAE1C,IAAI,GAAG,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,iBAAiB;QAAE,OAAM;IACzD,WAAW,CAAC,MAAM,CAAC,GAAG,GAAG,CAAA;IAEzB,OAAO,CAAC,IAAI,CACV,IAAI,CAAC,SAAS,CAAC;QACb,KAAK,EAAE,yBAAyB;QAChC,EAAE,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE;QAC/B,MAAM;QACN,KAAK;QACL,MAAM;QACN,KAAK,EAAE,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;QACjE,GAAG;QACH,QAAQ,EAAE,SAAS;KACpB,CAAC,CACH,CAAA;AACH,CAAC;AAED,+EAA+E;AAE/E;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,MAAiB,EAAE,QAAgB;IAC/D,MAAM,GAAG,GAAG,KAAK,EAAE,CAAA;IACnB,QAAQ,CAAC,GAAG,CAAC,CAAA;IACb,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAA;IAEhD,OAAO,CAAC;QACN,KAAK,EAAE,gBAAgB;QACvB,EAAE,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE;QAC/B,MAAM;QACN,QAAQ;KACT,CAAC,CAAA;IAEF,YAAY,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAC3B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,OAA4B,EAAE;IACxD,MAAM,GAAG,GAAG,KAAK,EAAE,CAAA;IACnB,QAAQ,CAAC,GAAG,CAAC,CAAA;IACb,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAA;IAEvC,OAAO,CAAC;QACN,KAAK,EAAE,qBAAqB;QAC5B,EAAE,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE;QAC/B,GAAG,CAAC,IAAI,CAAC,MAAM,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;KAC5C,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,GAAG,GAAG,KAAK,EAAE,CAAA;IACnB,QAAQ,CAAC,GAAG,CAAC,CAAA;IAEb,MAAM,CAAC,GAAG,SAAS,CAAC,OAAO,CAAC,CAAA;IAC5B,MAAM,CAAC,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC7B,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAA;IAChC,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAA;IAEjC,OAAO;QACL,QAAQ,EAAE,SAAS;QACnB,KAAK,EAAE;YACL,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,KAAK,EAAE,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;YAC1C,GAAG,EAAE,SAAS;YACd,QAAQ,EAAE,MAAM,KAAK,IAAI,IAAI,MAAM,GAAG,SAAS;SAChD;QACD,MAAM,EAAE;YACN,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,KAAK,EAAE,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;YAC1C,GAAG,EAAE,UAAU;YACf,QAAQ,EAAE,MAAM,KAAK,IAAI,IAAI,MAAM,GAAG,UAAU;SACjD;KACF,CAAA;AACH,CAAC;AAED,+EAA+E;AAE/E,2CAA2C;AAC3C,MAAM,UAAU,iBAAiB;IAC/B,MAAM,CAAC,MAAM,GAAG,CAAC,CAAA;IACjB,WAAW,CAAC,KAAK,GAAG,CAAC,CAAA;IACrB,WAAW,CAAC,MAAM,GAAG,CAAC,CAAA;IACtB,KAAK,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAA;AAC1B,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,oBAAoB,CAAC,EAAgB;IACnD,KAAK,GAAG,EAAE,CAAA;AACZ,CAAC;AAED,iFAAiF;AACjF,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,SAAS;IACT,SAAS;IACT,UAAU;IACV,iBAAiB;IACjB,sBAAsB;CACd,CAAA"}
package/dist/prices.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import type { PriceResult, Traveler } from './types.js';
2
- export declare function getTripPrices(tripIds: string[], travelers?: Traveler[]): Promise<PriceResult[]>;
3
- export declare function getTripOfferLink(tripId: string, travelers: Traveler[]): Promise<string | null>;
package/dist/prices.js DELETED
@@ -1,51 +0,0 @@
1
- import { smapiRequest, getTicketingBaseUrl } from './client.js';
2
- export async function getTripPrices(tripIds, travelers) {
3
- const results = await Promise.all(tripIds.map(async (tripId) => {
4
- const params = new URLSearchParams({ tripId });
5
- const path = travelers?.length
6
- ? `/api/v2/prices?${params}`
7
- : `/api/prices?${params}`;
8
- try {
9
- const data = await smapiRequest(getTicketingBaseUrl(), path, {
10
- method: travelers?.length ? 'POST' : 'GET',
11
- ...(travelers?.length && { body: { travelers } }),
12
- });
13
- const prices = data.prices ?? (data.price ? [{
14
- amount: data.price.amount,
15
- currency: data.price.currency,
16
- class: '2',
17
- }] : []);
18
- return {
19
- tripId,
20
- prices: prices.map((p) => ({
21
- amount: p.amount,
22
- currency: p.currency || 'CHF',
23
- class: (p.class || '2'),
24
- reductionCard: p.reductionCard,
25
- })),
26
- };
27
- }
28
- catch (err) {
29
- console.error(`[sbb-mcp] Failed to get price for trip ${tripId}:`, err);
30
- return { tripId, prices: [] };
31
- }
32
- }));
33
- return results;
34
- }
35
- export async function getTripOfferLink(tripId, travelers) {
36
- const body = {
37
- tripId,
38
- travelers: travelers.map((t) => ({
39
- externalId: t.id,
40
- type: t.type,
41
- ...(t.dateOfBirth && { dateOfBirth: t.dateOfBirth }),
42
- ...(t.reductionCard && t.reductionCard !== 'NONE' && {
43
- cards: [{ type: t.reductionCard }],
44
- }),
45
- })),
46
- };
47
- const data = await smapiRequest(getTicketingBaseUrl(), '/api/trip-offers', { method: 'POST', body });
48
- const links = (data.links ?? []);
49
- return links.find((l) => l.rel === 'online-offers')?.href ?? null;
50
- }
51
- //# sourceMappingURL=prices.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"prices.js","sourceRoot":"","sources":["../src/prices.ts"],"names":[],"mappings":"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"}
package/dist/profile.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import type { Lang } from './i18n.js';
2
- export type UserProfile = {
3
- first_name?: string;
4
- last_name?: string;
5
- date_of_birth?: string;
6
- reduction_card?: 'HALF_FARE' | 'GA' | 'NONE';
7
- reduction_card_valid_until?: string;
8
- language?: Lang;
9
- created_at?: string;
10
- updated_at?: string;
11
- };
12
- export declare function getProfilePath(): string;
13
- export declare function loadProfile(): UserProfile | null;
14
- export declare function saveProfile(profile: UserProfile): void;
15
- export declare function isReductionCardExpired(profile: UserProfile): boolean;
16
- export declare function formatProfileSummary(profile: UserProfile, lang?: Lang): string;
package/dist/profile.js DELETED
@@ -1,84 +0,0 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
- import { join } from 'path';
3
- import { homedir } from 'os';
4
- import { toLocale, t } from './i18n.js';
5
- // Path resolution is lazy (per call) rather than a module-level constant so
6
- // tests can redirect storage via `SBB_MCP_PROFILE_DIR` without re-importing.
7
- // Power users can also set the env var to sandbox the profile file under a
8
- // custom directory (e.g. for multi-account setups).
9
- function getProfileDir() {
10
- return process.env.SBB_MCP_PROFILE_DIR || join(homedir(), '.sbb-mcp');
11
- }
12
- export function getProfilePath() {
13
- return join(getProfileDir(), 'profile.json');
14
- }
15
- export function loadProfile() {
16
- try {
17
- const path = getProfilePath();
18
- if (!existsSync(path))
19
- return null;
20
- const raw = readFileSync(path, 'utf-8');
21
- return JSON.parse(raw);
22
- }
23
- catch {
24
- return null;
25
- }
26
- }
27
- export function saveProfile(profile) {
28
- const dir = getProfileDir();
29
- mkdirSync(dir, { recursive: true });
30
- const existing = loadProfile();
31
- const merged = {
32
- ...existing,
33
- ...profile,
34
- updated_at: new Date().toISOString(),
35
- created_at: existing?.created_at || new Date().toISOString(),
36
- };
37
- writeFileSync(getProfilePath(), JSON.stringify(merged, null, 2), 'utf-8');
38
- }
39
- export function isReductionCardExpired(profile) {
40
- if (!profile.reduction_card || profile.reduction_card === 'NONE')
41
- return false;
42
- if (!profile.reduction_card_valid_until)
43
- return false; // no expiry set, assume valid
44
- const validUntil = new Date(profile.reduction_card_valid_until).getTime();
45
- return Date.now() > validUntil;
46
- }
47
- export function formatProfileSummary(profile, lang = 'en') {
48
- const tr = t(lang);
49
- const locale = toLocale(lang);
50
- const lines = ['**Your Travel Profile**', ''];
51
- if (profile.first_name || profile.last_name) {
52
- lines.push(`Name: ${[profile.first_name, profile.last_name].filter(Boolean).join(' ')}`);
53
- }
54
- if (profile.date_of_birth) {
55
- lines.push(`Date of birth: ${profile.date_of_birth}`);
56
- }
57
- if (profile.reduction_card && profile.reduction_card !== 'NONE') {
58
- const cardName = profile.reduction_card === 'HALF_FARE' ? 'Halbtax (Half-Fare)' : 'GA Travelcard';
59
- const expired = isReductionCardExpired(profile);
60
- let cardLine = `Reduction card: ${cardName}`;
61
- if (profile.reduction_card_valid_until) {
62
- cardLine += ` (valid until ${profile.reduction_card_valid_until})`;
63
- }
64
- if (expired) {
65
- cardLine += ' — EXPIRED. Ask the user if they renewed it.';
66
- }
67
- lines.push(cardLine);
68
- }
69
- else {
70
- lines.push('Reduction card: None');
71
- }
72
- if (profile.language) {
73
- lines.push(`Language: ${profile.language}`);
74
- }
75
- if (profile.updated_at) {
76
- lines.push(`Last updated: ${new Date(profile.updated_at).toLocaleDateString(locale)}`);
77
- }
78
- // Suppress unused-var warning for tr until we translate the labels above
79
- void tr;
80
- lines.push('');
81
- lines.push(`*Profile stored at \`${getProfilePath()}\`. Edit or delete anytime.*`);
82
- return lines.join('\n');
83
- }
84
- //# sourceMappingURL=profile.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"profile.js","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":"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"}
@@ -1,5 +0,0 @@
1
- /**
2
- * Simple in-memory rate limiter per IP.
3
- * 60 requests per minute per IP. Max 50k tracked IPs.
4
- */
5
- export declare function isRateLimited(ip: string): boolean;