letsfg 2026.5.71 → 2026.5.73
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 +283 -221
- package/dist/{chunk-GO3FXQXC.mjs → chunk-F5BBI6XX.mjs} +134 -20
- package/dist/cli.js +381 -216
- package/dist/cli.mjs +248 -61
- package/dist/index.d.mts +92 -14
- package/dist/index.d.ts +92 -14
- package/dist/index.js +134 -20
- package/dist/index.mjs +1 -1
- package/package.json +56 -56
- package/dist/auth-SYZ5AYZ6.mjs +0 -16
- package/dist/chunk-XNO2W77S.mjs +0 -126
package/dist/cli.js
CHANGED
|
@@ -6,13 +6,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
8
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __esm = (fn, res) => function __init() {
|
|
10
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
-
};
|
|
12
|
-
var __export = (target, all) => {
|
|
13
|
-
for (var name in all)
|
|
14
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
-
};
|
|
16
9
|
var __copyProps = (to, from, except, desc) => {
|
|
17
10
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
11
|
for (let key of __getOwnPropNames(from))
|
|
@@ -30,139 +23,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
23
|
mod
|
|
31
24
|
));
|
|
32
25
|
|
|
33
|
-
// src/auth.ts
|
|
34
|
-
var auth_exports = {};
|
|
35
|
-
__export(auth_exports, {
|
|
36
|
-
BearerTokenError: () => BearerTokenError,
|
|
37
|
-
getBearerToken: () => getBearerToken,
|
|
38
|
-
paymentAuth: () => paymentAuth,
|
|
39
|
-
requestEnrolment: () => requestEnrolment,
|
|
40
|
-
saveToken: () => saveToken,
|
|
41
|
-
verifyPaymentMethod: () => verifyPaymentMethod
|
|
42
|
-
});
|
|
43
|
-
function configPath() {
|
|
44
|
-
const base = (0, import_node_os.platform)() === "win32" ? process.env.APPDATA || (0, import_node_os.homedir)() : (0, import_node_os.homedir)();
|
|
45
|
-
return (0, import_node_path.join)(base, ".letsfg", "config.json");
|
|
46
|
-
}
|
|
47
|
-
function loadConfig() {
|
|
48
|
-
const p = configPath();
|
|
49
|
-
if (!(0, import_node_fs.existsSync)(p)) return {};
|
|
50
|
-
try {
|
|
51
|
-
return JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
|
|
52
|
-
} catch {
|
|
53
|
-
return {};
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
function saveConfig(cfg) {
|
|
57
|
-
const p = configPath();
|
|
58
|
-
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(p), { recursive: true });
|
|
59
|
-
(0, import_node_fs.writeFileSync)(p, JSON.stringify(cfg, null, 2));
|
|
60
|
-
try {
|
|
61
|
-
(0, import_node_fs.chmodSync)(p, 384);
|
|
62
|
-
} catch {
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function getBearerToken() {
|
|
66
|
-
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
67
|
-
if (env) return env;
|
|
68
|
-
const auth = loadConfig().pfs_auth;
|
|
69
|
-
if (auth?.token && Date.now() < auth.expires_at - 36e5) {
|
|
70
|
-
return auth.token;
|
|
71
|
-
}
|
|
72
|
-
throw new BearerTokenError(
|
|
73
|
-
"No valid LetsFG Bearer token.\n Run: letsfg auth (adds a payment method \u2014 nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
function saveToken(token, expiresAt) {
|
|
77
|
-
const cfg = loadConfig();
|
|
78
|
-
cfg.pfs_auth = { token, expires_at: expiresAt ?? Date.now() + TOKEN_TTL_MS };
|
|
79
|
-
saveConfig(cfg);
|
|
80
|
-
}
|
|
81
|
-
async function postJson(path, payload) {
|
|
82
|
-
const resp = await fetch(`${BASE_URL}${path}`, {
|
|
83
|
-
method: "POST",
|
|
84
|
-
headers: { "Content-Type": "application/json", "User-Agent": "LetsFG-js/0.1.0", "X-Client-Type": "js-sdk" },
|
|
85
|
-
body: JSON.stringify(payload)
|
|
86
|
-
});
|
|
87
|
-
const data = await resp.json().catch(() => ({}));
|
|
88
|
-
return { status: resp.status, data };
|
|
89
|
-
}
|
|
90
|
-
function parseExpiry(raw) {
|
|
91
|
-
if (typeof raw === "number") return raw * 1e3;
|
|
92
|
-
if (typeof raw === "string") {
|
|
93
|
-
const ms = Date.parse(raw);
|
|
94
|
-
if (!Number.isNaN(ms)) return ms;
|
|
95
|
-
}
|
|
96
|
-
return Date.now() + TOKEN_TTL_MS;
|
|
97
|
-
}
|
|
98
|
-
async function requestEnrolment() {
|
|
99
|
-
const { status, data } = await postJson("/api/agent-access/request", {});
|
|
100
|
-
if (status !== 200 && status !== 402) {
|
|
101
|
-
throw new BearerTokenError(`Could not start authentication (HTTP ${status}): ${data.error || ""}`);
|
|
102
|
-
}
|
|
103
|
-
return data;
|
|
104
|
-
}
|
|
105
|
-
async function verifyPaymentMethod(opts) {
|
|
106
|
-
const payload = {};
|
|
107
|
-
if (opts.setupSessionId) payload.setup_session_id = opts.setupSessionId;
|
|
108
|
-
else if (opts.paymentMethodId) payload.payment_method_id = opts.paymentMethodId;
|
|
109
|
-
else if (opts.cardToken) payload.card_token = opts.cardToken;
|
|
110
|
-
else throw new BearerTokenError("Provide one of setupSessionId, paymentMethodId, or cardToken.");
|
|
111
|
-
const { status, data } = await postJson("/api/agent-access/verify", payload);
|
|
112
|
-
if (status !== 200 || !data.token) {
|
|
113
|
-
throw new BearerTokenError(`Verification failed (HTTP ${status}). ${data.hint || data.error || ""}`.trim());
|
|
114
|
-
}
|
|
115
|
-
const token = data.token;
|
|
116
|
-
saveToken(token, parseExpiry(data.expires_at));
|
|
117
|
-
return token;
|
|
118
|
-
}
|
|
119
|
-
function openBrowser(url) {
|
|
120
|
-
import("child_process").then(({ exec }) => {
|
|
121
|
-
const cmd = (0, import_node_os.platform)() === "win32" ? `start "" "${url}"` : (0, import_node_os.platform)() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
122
|
-
exec(cmd, () => {
|
|
123
|
-
});
|
|
124
|
-
}).catch(() => {
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
async function paymentAuth(openBrowserFlag = true) {
|
|
128
|
-
console.log("\n Connecting to LetsFG...");
|
|
129
|
-
const data = await requestEnrolment();
|
|
130
|
-
const setupUrl = data.setup_url;
|
|
131
|
-
const sessionId = data.setup_session_id;
|
|
132
|
-
if (!setupUrl || !sessionId) {
|
|
133
|
-
throw new BearerTokenError("Server did not return a setup URL. Check https://letsfg.co/for-agents");
|
|
134
|
-
}
|
|
135
|
-
console.log("\n LetsFG needs a payment method on file before it can search or book.");
|
|
136
|
-
console.log(" Nothing is charged now \u2014 this is a zero-amount card setup.\n");
|
|
137
|
-
console.log(" Step 1 \u2014 add a card here:\n");
|
|
138
|
-
console.log(` ${setupUrl}
|
|
139
|
-
`);
|
|
140
|
-
if (openBrowserFlag) openBrowser(setupUrl);
|
|
141
|
-
console.log(" Step 2 \u2014 press Enter once you've finished.");
|
|
142
|
-
const readline = await import("readline/promises");
|
|
143
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
144
|
-
await rl.question("");
|
|
145
|
-
rl.close();
|
|
146
|
-
process.stdout.write(" Verifying... ");
|
|
147
|
-
const token = await verifyPaymentMethod({ setupSessionId: sessionId });
|
|
148
|
-
const expiresAt = loadConfig().pfs_auth?.expires_at ?? Date.now() + TOKEN_TTL_MS;
|
|
149
|
-
console.log(`done. Token valid until ${new Date(expiresAt).toISOString().slice(0, 10)}.`);
|
|
150
|
-
return token;
|
|
151
|
-
}
|
|
152
|
-
var import_node_fs, import_node_os, import_node_path, BASE_URL, TOKEN_TTL_MS, BearerTokenError;
|
|
153
|
-
var init_auth = __esm({
|
|
154
|
-
"src/auth.ts"() {
|
|
155
|
-
"use strict";
|
|
156
|
-
import_node_fs = require("fs");
|
|
157
|
-
import_node_os = require("os");
|
|
158
|
-
import_node_path = require("path");
|
|
159
|
-
BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
160
|
-
TOKEN_TTL_MS = 90 * 24 * 3600 * 1e3;
|
|
161
|
-
BearerTokenError = class extends Error {
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
26
|
// src/ranking.ts
|
|
167
27
|
var W = {
|
|
168
28
|
// Generic solo / default — price-driven
|
|
@@ -567,13 +427,23 @@ var LetsFG = class {
|
|
|
567
427
|
return Array.isArray(data) ? data : data.locations || [];
|
|
568
428
|
}
|
|
569
429
|
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
430
|
+
* RETIRED 2026-09-08. Throws instead of calling the server.
|
|
431
|
+
*
|
|
432
|
+
* There is no unlock step on any lane. Unlock existed to confirm a live price
|
|
433
|
+
* before charging; booking now HOLDS the fare on the connected payment method
|
|
434
|
+
* and captures only once a real airline PNR exists, so a fare that moved
|
|
435
|
+
* cannot become a charge for a ticket you did not get. If it moves at
|
|
436
|
+
* checkout you get a `price_change` question to accept or decline instead.
|
|
437
|
+
*
|
|
438
|
+
* Kept as a method, and throwing locally rather than making the request, so an
|
|
439
|
+
* older caller gets one clear sentence at the line that is actually wrong —
|
|
440
|
+
* not a 410 body to decode, and not a TypeError somewhere else.
|
|
573
441
|
*/
|
|
574
|
-
async unlock(
|
|
575
|
-
|
|
576
|
-
|
|
442
|
+
async unlock(_offerId) {
|
|
443
|
+
throw new LetsFGError(
|
|
444
|
+
"unlock() was retired on 2026-09-08 and the endpoint answers 410 Gone. There is no unlock step: call book() directly. The fare is held on the connected payment method and captured only against a real airline PNR. See https://letsfg.co/developers/api/docs",
|
|
445
|
+
410
|
|
446
|
+
);
|
|
577
447
|
}
|
|
578
448
|
/**
|
|
579
449
|
* Book a flight.
|
|
@@ -584,9 +454,14 @@ var LetsFG = class {
|
|
|
584
454
|
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
585
455
|
* nothing was charged.
|
|
586
456
|
*
|
|
587
|
-
* Developer API (X-API-Key):
|
|
588
|
-
*
|
|
589
|
-
*
|
|
457
|
+
* Developer API (X-API-Key): POST /flights/book. NO unlock step. searchId is
|
|
458
|
+
* REQUIRED — an offer is bookable only inside the search that produced it.
|
|
459
|
+
* The connected Revolut method is HELD, not charged; a LetsFG booking agent
|
|
460
|
+
* buys the ticket and the hold is captured only against a real airline PNR.
|
|
461
|
+
* Returns the 202 { ok, booking_id, state, held, charged: 0, poll_url } —
|
|
462
|
+
* poll getBooking(bookingId) until `terminal`, or use bookAndWait().
|
|
463
|
+
* Always provide idempotencyKey: a retry with the same key returns the
|
|
464
|
+
* existing booking instead of opening a second hold on the card.
|
|
590
465
|
*/
|
|
591
466
|
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
|
|
592
467
|
this.requireAuth();
|
|
@@ -607,15 +482,92 @@ var LetsFG = class {
|
|
|
607
482
|
});
|
|
608
483
|
}
|
|
609
484
|
this.requireApiKey();
|
|
485
|
+
if (!searchId) {
|
|
486
|
+
throw new LetsFGError(
|
|
487
|
+
"searchId is required to book on the Developer API \u2014 pass the search_id from search()'s result. An offer can only be booked inside the search that produced it. (Before 2026-09-08 this argument was ignored on this path.)",
|
|
488
|
+
400
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
const pax = passengers.map((p) => ({ ...p }));
|
|
492
|
+
if (contactPhone && pax.length && !pax[0].phone_number) pax[0].phone_number = contactPhone;
|
|
610
493
|
const body = {
|
|
494
|
+
search_id: searchId,
|
|
611
495
|
offer_id: offerId,
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
contact_email: contactEmail,
|
|
615
|
-
contact_phone: contactPhone
|
|
496
|
+
passengers: pax,
|
|
497
|
+
contact_email: contactEmail
|
|
616
498
|
};
|
|
617
499
|
if (idempotencyKey) body.idempotency_key = idempotencyKey;
|
|
618
|
-
return this.post("/developers/api/v1/
|
|
500
|
+
return this.post("/developers/api/v1/flights/book", body);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Poll a Developer API flight booking.
|
|
504
|
+
*
|
|
505
|
+
* Poll every few seconds until `terminal` is true. The poll is ALSO how LetsFG
|
|
506
|
+
* knows you are still there, which is what keeps a booking paused on a
|
|
507
|
+
* question alive — so do not back off to minutes.
|
|
508
|
+
*
|
|
509
|
+
* States: authorised, card_issued, booking_in_progress, awaiting_settlement,
|
|
510
|
+
* then completed (with `pnr` and `charged_amount`), failed (hold released,
|
|
511
|
+
* nothing charged) or needs_attention (a human at LetsFG is on it — do not
|
|
512
|
+
* book again).
|
|
513
|
+
*/
|
|
514
|
+
async getBooking(bookingId) {
|
|
515
|
+
this.requireApiKey();
|
|
516
|
+
return this.getWithAuth(
|
|
517
|
+
`/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Answer the open `question` on a booking.
|
|
522
|
+
*
|
|
523
|
+
* Echo the question's `round`. A stale round is refused with 409 rather than
|
|
524
|
+
* guessed at, so an answer to an old question can never be applied to a new
|
|
525
|
+
* one. Seat: { seats: [...] } or { skip: true }. Price change or paid extra:
|
|
526
|
+
* { confirm: true } or { skip: true } — declining an extra still completes
|
|
527
|
+
* the booking, without it.
|
|
528
|
+
*/
|
|
529
|
+
async answerBooking(bookingId, round, answer = {}) {
|
|
530
|
+
this.requireApiKey();
|
|
531
|
+
return this.post(
|
|
532
|
+
`/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}/answer`,
|
|
533
|
+
{ round, ...answer }
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Book and poll to a terminal state. Mirrors bookHotelAndWait().
|
|
538
|
+
*
|
|
539
|
+
* Blocks for as long as the booking takes (4–11 minutes typically), so use
|
|
540
|
+
* book() + getBooking() instead if your caller has a request timeout.
|
|
541
|
+
*
|
|
542
|
+
* `onQuestion` returns the answer for answerBooking(). Without it, a fare
|
|
543
|
+
* increase is ACCEPTED and a paid extra is DECLINED — the conservative
|
|
544
|
+
* reading of "the traveller asked for this flight".
|
|
545
|
+
*/
|
|
546
|
+
async bookAndWait(offerId, passengers, contactEmail, searchId, opts = {}) {
|
|
547
|
+
const { contactPhone = "", idempotencyKey = "", pollMs = 5e3, timeoutMs = 9e5, onQuestion } = opts;
|
|
548
|
+
const started = await this.book(
|
|
549
|
+
offerId,
|
|
550
|
+
passengers,
|
|
551
|
+
contactEmail,
|
|
552
|
+
contactPhone,
|
|
553
|
+
idempotencyKey,
|
|
554
|
+
searchId
|
|
555
|
+
);
|
|
556
|
+
if (!started || started.ok !== true) return started;
|
|
557
|
+
const bookingId = String(started.booking_id);
|
|
558
|
+
const deadline = Date.now() + timeoutMs;
|
|
559
|
+
while (Date.now() < deadline) {
|
|
560
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
561
|
+
const state = await this.getBooking(bookingId);
|
|
562
|
+
const question = state.question;
|
|
563
|
+
if (question) {
|
|
564
|
+
const answer = onQuestion ? onQuestion(question) : question.kind === "extra" ? { skip: true } : { confirm: true };
|
|
565
|
+
await this.answerBooking(bookingId, Number(question.round), answer);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (state.terminal) return state;
|
|
569
|
+
}
|
|
570
|
+
return this.getBooking(bookingId);
|
|
619
571
|
}
|
|
620
572
|
// ── Hotels ──────────────────────────────────────────────────────────
|
|
621
573
|
//
|
|
@@ -773,15 +725,37 @@ var LetsFG = class {
|
|
|
773
725
|
);
|
|
774
726
|
}
|
|
775
727
|
/**
|
|
776
|
-
* [Developer API
|
|
728
|
+
* [Developer API] Mint a one-time link for connecting a Revolut payment method.
|
|
729
|
+
*
|
|
730
|
+
* This replaced setupPayment() on 2026-09-08. Nothing is charged to connect, and
|
|
731
|
+
* card details never touch LetsFG: the returned `connect_url` opens a hosted page
|
|
732
|
+
* where the developer saves a card, Revolut Pay or Google Pay. A PERSON must open
|
|
733
|
+
* it in a browser — there is no endpoint that takes card details, so do not ask a
|
|
734
|
+
* user for a card number and do not try to automate this step.
|
|
777
735
|
*
|
|
778
|
-
* Most agents should NOT
|
|
779
|
-
* search and booking
|
|
780
|
-
* through a zero-amount setup and creates no billing account.
|
|
736
|
+
* Most agents should NOT need a Developer API account at all. To authenticate for
|
|
737
|
+
* search and booking, run `letsfg auth`, which creates no billing account.
|
|
781
738
|
*/
|
|
782
|
-
async
|
|
739
|
+
async connectPayment() {
|
|
783
740
|
this.requireApiKey();
|
|
784
|
-
return this.post("/developers/api/v1/agents/
|
|
741
|
+
return this.post("/developers/api/v1/agents/connect-payment", {});
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* RETIRED 2026-09-08 with Stripe. Throws instead of calling the server.
|
|
745
|
+
*
|
|
746
|
+
* `/agents/setup-payment` answers 410 Gone. Payment enrolment moved onto the same
|
|
747
|
+
* Revolut rail as the rest of the product: call connectPayment() and open the
|
|
748
|
+
* `connect_url` it returns.
|
|
749
|
+
*
|
|
750
|
+
* Kept as a method, and throwing locally rather than making the request, for the
|
|
751
|
+
* same reason as unlock() — an older caller gets one clear sentence at the line that
|
|
752
|
+
* is actually wrong, not a 410 body to decode and not a TypeError somewhere else.
|
|
753
|
+
*/
|
|
754
|
+
async setupPayment(_token) {
|
|
755
|
+
throw new LetsFGError(
|
|
756
|
+
"setupPayment() was retired on 2026-09-08 with Stripe and the endpoint answers 410 Gone. Call connectPayment() instead and open the connect_url it returns; nothing is charged to connect. See https://letsfg.co/developers/api/docs",
|
|
757
|
+
410
|
|
758
|
+
);
|
|
785
759
|
}
|
|
786
760
|
/**
|
|
787
761
|
* Get current agent profile and usage stats.
|
|
@@ -867,8 +841,213 @@ var LetsFG = class {
|
|
|
867
841
|
}
|
|
868
842
|
};
|
|
869
843
|
|
|
844
|
+
// src/auth.ts
|
|
845
|
+
var import_node_fs = require("fs");
|
|
846
|
+
var import_node_os = require("os");
|
|
847
|
+
var import_node_path = require("path");
|
|
848
|
+
var import_node_crypto = require("crypto");
|
|
849
|
+
var import_node_http = require("http");
|
|
850
|
+
var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
851
|
+
var DEV_ROOT = `${BASE_URL}/developers/api`;
|
|
852
|
+
var FALLBACK_TTL_MS = 55 * 60 * 1e3;
|
|
853
|
+
var REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
854
|
+
var BearerTokenError = class extends Error {
|
|
855
|
+
};
|
|
856
|
+
function configPath() {
|
|
857
|
+
const base = (0, import_node_os.platform)() === "win32" ? process.env.APPDATA || (0, import_node_os.homedir)() : (0, import_node_os.homedir)();
|
|
858
|
+
return (0, import_node_path.join)(base, ".letsfg", "config.json");
|
|
859
|
+
}
|
|
860
|
+
function loadConfig() {
|
|
861
|
+
const p = configPath();
|
|
862
|
+
if (!(0, import_node_fs.existsSync)(p)) return {};
|
|
863
|
+
try {
|
|
864
|
+
return JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
|
|
865
|
+
} catch {
|
|
866
|
+
return {};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function saveConfig(cfg) {
|
|
870
|
+
const p = configPath();
|
|
871
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(p), { recursive: true });
|
|
872
|
+
(0, import_node_fs.writeFileSync)(p, JSON.stringify(cfg, null, 2));
|
|
873
|
+
try {
|
|
874
|
+
(0, import_node_fs.chmodSync)(p, 384);
|
|
875
|
+
} catch {
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function saveToken(token, expiresAt, extra) {
|
|
879
|
+
const cfg = loadConfig();
|
|
880
|
+
cfg.pfs_auth = {
|
|
881
|
+
...cfg.pfs_auth ?? {},
|
|
882
|
+
...extra ?? {},
|
|
883
|
+
token,
|
|
884
|
+
expires_at: expiresAt ?? Date.now() + FALLBACK_TTL_MS
|
|
885
|
+
};
|
|
886
|
+
saveConfig(cfg);
|
|
887
|
+
}
|
|
888
|
+
function getBearerToken() {
|
|
889
|
+
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
890
|
+
if (env) return env;
|
|
891
|
+
const auth = loadConfig().pfs_auth;
|
|
892
|
+
if (auth?.token && Date.now() < auth.expires_at - REFRESH_SKEW_MS) {
|
|
893
|
+
return auth.token;
|
|
894
|
+
}
|
|
895
|
+
if (auth?.refresh_token) {
|
|
896
|
+
throw new BearerTokenError(
|
|
897
|
+
"LetsFG token expired. Call ensureBearerToken() to refresh it, or run: letsfg auth"
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
throw new BearerTokenError(
|
|
901
|
+
"No valid LetsFG Bearer token.\n Run: letsfg auth (connects a card at letsfg.co/connect - nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
async function discover() {
|
|
905
|
+
const fallback = {
|
|
906
|
+
authorization_endpoint: `${BASE_URL}/connect`,
|
|
907
|
+
token_endpoint: `${DEV_ROOT}/oauth/token`,
|
|
908
|
+
registration_endpoint: `${DEV_ROOT}/oauth/register`
|
|
909
|
+
};
|
|
910
|
+
try {
|
|
911
|
+
const resp = await fetch(`${DEV_ROOT}/.well-known/oauth-authorization-server`);
|
|
912
|
+
if (!resp.ok) return fallback;
|
|
913
|
+
const d = await resp.json();
|
|
914
|
+
return {
|
|
915
|
+
authorization_endpoint: d.authorization_endpoint || fallback.authorization_endpoint,
|
|
916
|
+
token_endpoint: d.token_endpoint || fallback.token_endpoint,
|
|
917
|
+
registration_endpoint: d.registration_endpoint || fallback.registration_endpoint
|
|
918
|
+
};
|
|
919
|
+
} catch {
|
|
920
|
+
return fallback;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function pkce() {
|
|
924
|
+
const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
|
|
925
|
+
const challenge = (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url");
|
|
926
|
+
return { verifier, challenge };
|
|
927
|
+
}
|
|
928
|
+
async function postForm(url, form) {
|
|
929
|
+
const resp = await fetch(url, {
|
|
930
|
+
method: "POST",
|
|
931
|
+
// RFC 6749 4.1.3 - the token endpoint takes form encoding. The server also
|
|
932
|
+
// accepts JSON, but sending the spec encoding keeps this honest.
|
|
933
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Client-Type": "js-sdk" },
|
|
934
|
+
body: new URLSearchParams(form).toString()
|
|
935
|
+
});
|
|
936
|
+
const data = await resp.json().catch(() => ({}));
|
|
937
|
+
return { status: resp.status, data };
|
|
938
|
+
}
|
|
939
|
+
function openBrowser(url) {
|
|
940
|
+
import("child_process").then(({ exec }) => {
|
|
941
|
+
const cmd = (0, import_node_os.platform)() === "win32" ? `start "" "${url}"` : (0, import_node_os.platform)() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
942
|
+
exec(cmd, () => {
|
|
943
|
+
});
|
|
944
|
+
}).catch(() => {
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
function startCallbackServer(expectedState) {
|
|
948
|
+
return new Promise((resolve, reject) => {
|
|
949
|
+
let settle = null;
|
|
950
|
+
let fail = null;
|
|
951
|
+
const codePromise = new Promise((res, rej) => {
|
|
952
|
+
settle = res;
|
|
953
|
+
fail = rej;
|
|
954
|
+
});
|
|
955
|
+
const server = (0, import_node_http.createServer)((req, res) => {
|
|
956
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
957
|
+
if (!url.pathname.startsWith("/callback")) {
|
|
958
|
+
res.writeHead(404).end();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const err = url.searchParams.get("error");
|
|
962
|
+
const code = url.searchParams.get("code");
|
|
963
|
+
const state = url.searchParams.get("state");
|
|
964
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
965
|
+
if (err || !code) {
|
|
966
|
+
res.end("<h1>LetsFG</h1><p>That did not complete. Nothing was charged - run <code>letsfg auth</code> again.</p>");
|
|
967
|
+
fail?.(new BearerTokenError(`Authorisation failed: ${err || "no code returned"}`));
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (state !== expectedState) {
|
|
971
|
+
res.end("<h1>LetsFG</h1><p>State mismatch - nothing was charged. Run <code>letsfg auth</code> again.</p>");
|
|
972
|
+
fail?.(new BearerTokenError("State mismatch on the OAuth callback - aborted."));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
res.end("<h1>LetsFG</h1><p>Connected. You can close this tab and go back to your terminal.</p>");
|
|
976
|
+
settle?.(code);
|
|
977
|
+
});
|
|
978
|
+
server.on("error", reject);
|
|
979
|
+
server.listen(0, "127.0.0.1", () => {
|
|
980
|
+
const addr = server.address();
|
|
981
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
982
|
+
if (!port) {
|
|
983
|
+
reject(new BearerTokenError("Could not open a local callback port."));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
resolve({ port, waitForCode: () => codePromise, close: () => server.close() });
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
async function connectAuth(openBrowserFlag = true) {
|
|
991
|
+
const meta = await discover();
|
|
992
|
+
const { verifier, challenge } = pkce();
|
|
993
|
+
const state = (0, import_node_crypto.randomBytes)(16).toString("base64url");
|
|
994
|
+
const { port, waitForCode, close } = await startCallbackServer(state);
|
|
995
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
996
|
+
try {
|
|
997
|
+
const reg = await fetch(meta.registration_endpoint, {
|
|
998
|
+
method: "POST",
|
|
999
|
+
headers: { "Content-Type": "application/json" },
|
|
1000
|
+
body: JSON.stringify({
|
|
1001
|
+
client_name: "letsfg-cli",
|
|
1002
|
+
redirect_uris: [redirectUri],
|
|
1003
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1004
|
+
response_types: ["code"],
|
|
1005
|
+
token_endpoint_auth_method: "none"
|
|
1006
|
+
})
|
|
1007
|
+
});
|
|
1008
|
+
const regData = await reg.json().catch(() => ({}));
|
|
1009
|
+
const clientId = typeof regData.client_id === "string" ? regData.client_id : "";
|
|
1010
|
+
if (!reg.ok || !clientId) {
|
|
1011
|
+
throw new BearerTokenError(
|
|
1012
|
+
`Could not register with LetsFG (HTTP ${reg.status}). See ${BASE_URL}/for-agents`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
const authUrl = `${meta.authorization_endpoint}?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256&state=${encodeURIComponent(state)}&scope=flights`;
|
|
1016
|
+
console.log("\n LetsFG needs a card connected before it can search or book.");
|
|
1017
|
+
console.log(" Nothing is charged now - you pay the fare only when you book,");
|
|
1018
|
+
console.log(" and it is held, not taken, until the airline confirms.\n");
|
|
1019
|
+
console.log(" Open this and add a card (or pay 0.00 with Revolut Pay):\n");
|
|
1020
|
+
console.log(` ${authUrl}
|
|
1021
|
+
`);
|
|
1022
|
+
if (openBrowserFlag) openBrowser(authUrl);
|
|
1023
|
+
process.stdout.write(" Waiting for you to finish... ");
|
|
1024
|
+
const code = await waitForCode();
|
|
1025
|
+
const { status, data } = await postForm(meta.token_endpoint, {
|
|
1026
|
+
grant_type: "authorization_code",
|
|
1027
|
+
code,
|
|
1028
|
+
client_id: clientId,
|
|
1029
|
+
redirect_uri: redirectUri,
|
|
1030
|
+
code_verifier: verifier
|
|
1031
|
+
});
|
|
1032
|
+
if (status !== 200 || !data.access_token) {
|
|
1033
|
+
throw new BearerTokenError(
|
|
1034
|
+
`Could not complete authentication (HTTP ${status}). ${String(data.error_description || data.error || "")}`.trim()
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in * 1e3 : FALLBACK_TTL_MS;
|
|
1038
|
+
saveToken(String(data.access_token), Date.now() + expiresIn, {
|
|
1039
|
+
refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
|
|
1040
|
+
client_id: clientId
|
|
1041
|
+
});
|
|
1042
|
+
console.log("done. Card connected - the token refreshes itself from now on.");
|
|
1043
|
+
return String(data.access_token);
|
|
1044
|
+
} finally {
|
|
1045
|
+
close();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
var paymentAuth = connectAuth;
|
|
1049
|
+
|
|
870
1050
|
// src/cli.ts
|
|
871
|
-
init_auth();
|
|
872
1051
|
function resolveCredentials(apiKeyFlag) {
|
|
873
1052
|
const apiKey = apiKeyFlag || process.env.LETSFG_API_KEY;
|
|
874
1053
|
if (apiKey) return { apiKey };
|
|
@@ -986,38 +1165,15 @@ async function cmdSearch(args) {
|
|
|
986
1165
|
`);
|
|
987
1166
|
} else {
|
|
988
1167
|
console.log(`
|
|
989
|
-
To
|
|
990
|
-
console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
|
|
1168
|
+
To book: letsfg book <offer_id> --search-id ${result.search_id} --passenger '{...}' --email you@example.com
|
|
991
1169
|
`);
|
|
992
1170
|
}
|
|
993
1171
|
}
|
|
994
|
-
async function cmdUnlock(
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (!offerId) {
|
|
1000
|
-
console.error("Usage: letsfg unlock <offer_id>");
|
|
1001
|
-
process.exit(1);
|
|
1002
|
-
}
|
|
1003
|
-
const bt = new LetsFG({ apiKey, baseUrl });
|
|
1004
|
-
const result = await bt.unlock(offerId);
|
|
1005
|
-
if (jsonOut) {
|
|
1006
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1007
|
-
return;
|
|
1008
|
-
}
|
|
1009
|
-
if (result.unlock_status === "unlocked") {
|
|
1010
|
-
console.log(`
|
|
1011
|
-
\u2713 Offer unlocked!`);
|
|
1012
|
-
console.log(` Confirmed price: ${result.confirmed_currency} ${result.confirmed_price?.toFixed(2)}`);
|
|
1013
|
-
console.log(` Expires at: ${result.offer_expires_at}`);
|
|
1014
|
-
console.log(`
|
|
1015
|
-
Next: letsfg book ${offerId} --passenger '{...}' --email you@example.com
|
|
1016
|
-
`);
|
|
1017
|
-
} else {
|
|
1018
|
-
console.error(` \u2717 Unlock failed: ${result.message}`);
|
|
1019
|
-
process.exit(1);
|
|
1020
|
-
}
|
|
1172
|
+
async function cmdUnlock(_args) {
|
|
1173
|
+
console.error(
|
|
1174
|
+
"\n letsfg unlock was retired on 2026-09-08 and the endpoint answers 410 Gone.\n\n There is no unlock step any more. Book directly:\n letsfg book <offer_id> --search-id <search_id> --passenger '{...}' --email you@example.com\n\n The fare is HELD on your connected payment method and captured only once a real\n airline PNR exists, which is what unlock existed to protect against. If the fare moves\n at checkout you are asked to accept or decline it.\n"
|
|
1175
|
+
);
|
|
1176
|
+
process.exit(1);
|
|
1021
1177
|
}
|
|
1022
1178
|
async function cmdBook(args) {
|
|
1023
1179
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -1104,17 +1260,19 @@ async function cmdLocations(args) {
|
|
|
1104
1260
|
}
|
|
1105
1261
|
}
|
|
1106
1262
|
async function cmdAuth(args) {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1263
|
+
for (const dead of ["--card-token", "--payment-method"]) {
|
|
1264
|
+
if (getFlag(args, dead)) {
|
|
1265
|
+
console.error(
|
|
1266
|
+
`
|
|
1267
|
+
${dead} was part of the Stripe enrolment, retired 2026-09-02.
|
|
1268
|
+
Run plain \`letsfg auth\` instead - it opens letsfg.co/connect to add a card.
|
|
1269
|
+
`
|
|
1270
|
+
);
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
}
|
|
1116
1273
|
}
|
|
1117
|
-
|
|
1274
|
+
await paymentAuth(!hasFlag(args, "--no-browser"));
|
|
1275
|
+
console.log("\n You are all set. Run: letsfg search WAW BCN 2026-07-15\n");
|
|
1118
1276
|
}
|
|
1119
1277
|
async function cmdRegister(args) {
|
|
1120
1278
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -1147,19 +1305,26 @@ async function cmdSetupPayment(args) {
|
|
|
1147
1305
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
1148
1306
|
const apiKey = getFlag(args, "--api-key", "-k");
|
|
1149
1307
|
const baseUrl = getFlag(args, "--base-url");
|
|
1150
|
-
|
|
1308
|
+
if (getFlag(args, "--token", "-t")) {
|
|
1309
|
+
console.log("\n Note: --token was part of the Stripe enrolment, retired 2026-09-08. Ignoring it.");
|
|
1310
|
+
}
|
|
1151
1311
|
const bt = new LetsFG({ apiKey, baseUrl });
|
|
1152
|
-
const result = await bt.
|
|
1312
|
+
const result = await bt.connectPayment();
|
|
1153
1313
|
if (jsonOut) {
|
|
1154
1314
|
console.log(JSON.stringify(result, null, 2));
|
|
1155
1315
|
return;
|
|
1156
1316
|
}
|
|
1157
|
-
|
|
1317
|
+
const url = result.connect_url;
|
|
1318
|
+
if (url) {
|
|
1158
1319
|
console.log(`
|
|
1159
|
-
|
|
1320
|
+
Open this in a browser to connect a payment method:
|
|
1321
|
+
`);
|
|
1322
|
+
console.log(` ${url}
|
|
1323
|
+
`);
|
|
1324
|
+
console.log(` Nothing is charged. Run \`letsfg me\` afterwards to confirm it landed.
|
|
1160
1325
|
`);
|
|
1161
1326
|
} else {
|
|
1162
|
-
console.error(` \u2717
|
|
1327
|
+
console.error(` \u2717 Could not mint a connect link: ${result.message || result.status}`);
|
|
1163
1328
|
process.exit(1);
|
|
1164
1329
|
}
|
|
1165
1330
|
}
|
|
@@ -1180,10 +1345,9 @@ async function cmdMe(args) {
|
|
|
1180
1345
|
console.log(` Email: ${p.email}`);
|
|
1181
1346
|
console.log(` Tier: ${p.tier}`);
|
|
1182
1347
|
const access = p.access_granted || false;
|
|
1183
|
-
console.log(` Access: ${access ? "\u2713 Granted (search,
|
|
1348
|
+
console.log(` Access: ${access ? "\u2713 Granted (search, book)" : "\u2717 Not granted"}`);
|
|
1184
1349
|
console.log(` Payment: ${p.payment_ready ? "\u2713 Ready" : "\u2014"}`);
|
|
1185
1350
|
console.log(` Searches: ${u.total_searches || 0}`);
|
|
1186
|
-
console.log(` Unlocks: ${u.total_unlocks || 0}`);
|
|
1187
1351
|
console.log(` Bookings: ${u.total_bookings || 0}`);
|
|
1188
1352
|
console.log(` Total spent: $${((u.total_spent_cents || 0) / 100).toFixed(2)}
|
|
1189
1353
|
`);
|
|
@@ -1192,29 +1356,29 @@ var HELP = `
|
|
|
1192
1356
|
LetsFG \u2014 Agent-native flight search & booking.
|
|
1193
1357
|
|
|
1194
1358
|
Search hundreds of airlines via the LetsFG cloud engine.
|
|
1195
|
-
Authenticate once with letsfg auth \u2014
|
|
1196
|
-
charged
|
|
1359
|
+
Authenticate once with letsfg auth \u2014 it opens letsfg.co/connect so you can add
|
|
1360
|
+
a card. Nothing is charged; you pay the fare only when you book, and it is held,
|
|
1361
|
+
not taken, until the airline confirms.
|
|
1197
1362
|
|
|
1198
1363
|
Commands:
|
|
1199
|
-
auth
|
|
1364
|
+
auth Connect a card at letsfg.co/connect. Nothing charged
|
|
1200
1365
|
search <origin> <dest> <date> Search for flights (free), prints search_id
|
|
1201
1366
|
locations <query> Resolve city name to IATA codes
|
|
1202
|
-
book <offer_id> --search-id ... Book a flight. No
|
|
1367
|
+
book <offer_id> --search-id ... Book a flight. No booking or transaction fee, no unlock step
|
|
1203
1368
|
me Show agent profile
|
|
1204
1369
|
|
|
1205
1370
|
Developer API only (a SEPARATE paid product \u2014 most agents should not use these;
|
|
1206
1371
|
they create a billing account. Use auth above instead):
|
|
1207
1372
|
register --name ... --email ... Create a paid Developer API account
|
|
1208
|
-
|
|
1209
|
-
|
|
1373
|
+
connect-payment Print a link to connect a card to that paid account
|
|
1374
|
+
setup-payment Alias of connect-payment (the Stripe lane retired 2026-09-08)
|
|
1375
|
+
unlock <offer_id> RETIRED 2026-09-08 \u2014 no unlock step, book directly
|
|
1210
1376
|
|
|
1211
1377
|
Options:
|
|
1212
1378
|
--json, -j Output raw JSON
|
|
1213
1379
|
--api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
|
|
1214
1380
|
--base-url API URL (default: https://letsfg.co)
|
|
1215
|
-
--
|
|
1216
|
-
--payment-method (auth only) Stripe pm_... you already hold, for a headless auth
|
|
1217
|
-
--no-browser (auth only) Don't try to auto-open the card setup page
|
|
1381
|
+
--no-browser (auth only) Print the connect URL instead of opening a browser
|
|
1218
1382
|
|
|
1219
1383
|
Examples:
|
|
1220
1384
|
letsfg auth
|
|
@@ -1244,6 +1408,7 @@ async function main() {
|
|
|
1244
1408
|
case "register":
|
|
1245
1409
|
await cmdRegister(args);
|
|
1246
1410
|
break;
|
|
1411
|
+
case "connect-payment":
|
|
1247
1412
|
case "setup-payment":
|
|
1248
1413
|
await cmdSetupPayment(args);
|
|
1249
1414
|
break;
|