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/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
- * Unlock a flight offer — confirms live price, reveals direct airline booking URL.
571
- * Developer API only, legacy — there is no unlock endpoint on a PFS Bearer
572
- * token, so PFS callers use book() directly.
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(offerId) {
575
- this.requireApiKey();
576
- return this.post("/developers/api/v1/bookings/unlock", { offer_id: offerId });
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): charges ticket price + service fee via Stripe,
588
- * creates a real PNR. Requires unlock() first. Always provide
589
- * idempotencyKey to prevent double-bookings on retry.
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
- booking_type: "flight",
613
- passengers,
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/bookings/book", body);
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 only] Attach a card to a PAID prepaid Developer API account.
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 call this. It is unrelated to authenticating for
779
- * search and booking — for that, run `letsfg auth`, which puts a card on file
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 setupPayment(token = "tok_visa") {
739
+ async connectPayment() {
783
740
  this.requireApiKey();
784
- return this.post("/developers/api/v1/agents/setup-payment", { token });
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 unlock: letsfg unlock <offer_id>`);
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(args) {
995
- const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
996
- const apiKey = getFlag(args, "--api-key", "-k");
997
- const baseUrl = getFlag(args, "--base-url");
998
- const offerId = args[0];
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 cardToken = getFlag(args, "--card-token");
1108
- const paymentMethodId = getFlag(args, "--payment-method");
1109
- const noBrowser = hasFlag(args, "--no-browser");
1110
- if (cardToken || paymentMethodId) {
1111
- const { verifyPaymentMethod: verifyPaymentMethod2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
1112
- await verifyPaymentMethod2({ cardToken: cardToken || void 0, paymentMethodId: paymentMethodId || void 0 });
1113
- console.log("\n \u2713 Authenticated. Nothing was charged.");
1114
- } else {
1115
- await paymentAuth(!noBrowser);
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
- console.log("\n You're all set. Run: letsfg search WAW BCN 2026-07-15\n");
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
- const token = getFlag(args, "--token", "-t") || "tok_visa";
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.setupPayment(token);
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
- if (result.status === "ready") {
1317
+ const url = result.connect_url;
1318
+ if (url) {
1158
1319
  console.log(`
1159
- \u2713 Payment ready! You can now unlock offers and book flights.
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 Payment setup failed: ${result.message || result.status}`);
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, unlock, book)" : "\u2717 Not granted"}`);
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 a zero-amount card setup, nothing is
1196
- charged \u2014 then search and book.
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 Put a card on file -> 90-day token. Nothing charged
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 LetsFG fee, no unlock step
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
- setup-payment Attach a card to that paid account
1209
- unlock <offer_id> [Developer API only] Unlock offer (legacy)
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
- --card-token (auth only) Stripe tok_... you already hold, for a headless auth
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;