letsfg 2026.5.71 → 2026.5.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
 
10
10
  | | **CLI / SDK** (this package) | **Developer API** |
11
11
  |---|---|---|
12
- | **Search cost** | Free (Bearer token via `letsfg auth` — zero-amount card setup) | Prepaid credits |
13
- | **Booking** | `POST /api/agent-book` — confirmed order or a booking link, no LetsFG fee | Direct airline URL (unlock required first) |
12
+ | **Search cost** | Free (card-backed token from [letsfg.co/connect](https://letsfg.co/connect), nothing charged) | Prepaid credits |
13
+ | **Booking** | `POST /api/agent-book` — fare held on your card, a LetsFG agent buys the ticket, captured only on a real PNR. Every offer. | Direct airline URL (unlock required first) |
14
14
  | **Speed** | 8–10 s to first results; longer on a split | 2–5 s (discover) · 8–10 s to first results (full) |
15
- | **Setup** | `npm install letsfg` then `letsfg auth` | [letsfg.co/developers](https://letsfg.co/developers) |
15
+ | **Setup** | `npm install letsfg`, then connect at [letsfg.co/developers/api/mcp](https://letsfg.co/developers/api/mcp) | [letsfg.co/developers](https://letsfg.co/developers) |
16
16
 
17
17
  > **Want direct airline URLs without any per-booking fee?** Use the [Developer API](https://letsfg.co/developers) — prepaid credits, results in seconds, no checkout step.
18
18
 
@@ -22,13 +22,31 @@
22
22
  npm install letsfg
23
23
  ```
24
24
 
25
+ ## Getting a token
26
+
27
+ Connect LetsFG as an MCP server at `https://letsfg.co/developers/api/mcp` and
28
+ approve the connection — in Claude, ChatGPT, Cursor, Windsurf, or Claude Code
29
+ (`claude mcp add --transport http letsfg https://letsfg.co/developers/api/mcp`).
30
+ The consent step opens [letsfg.co/connect](https://letsfg.co/connect), where you
31
+ add a card (any card, or Revolut Pay / Google Pay) in a 0.00 Revolut setup.
32
+ Nothing is charged, no Revolut account is needed, and the card details go to
33
+ Revolut, never to LetsFG. The token you get back is card-backed: it searches
34
+ and it books. One card = one account; quotas are per card (10 searches per
35
+ 10 min, 30 per hour, 100 per day — polling never counts).
36
+
37
+ Pass it as `bearerToken`, or set `LETSFG_BEARER_TOKEN` for the CLI.
38
+
39
+ > `letsfg auth` still runs the Stripe card setup that was retired on
40
+ > 2026-09-02 and cannot get a token today; every token issued that way was
41
+ > revoked (401 `TOKEN_REVOKED`). A connect-flow login for the CLI and SDKs is
42
+ > coming — until then, connect through the MCP.
43
+
25
44
  ## Quick Start (SDK)
26
45
 
27
46
  ```typescript
28
47
  import { LetsFG, cheapestOffer, offerSummary } from 'letsfg';
29
48
 
30
- // PFS — free. Get a Bearer token once with `letsfg auth` (zero-amount card
31
- // setup, nothing charged), then pass it here.
49
+ // PFS — free. The card-backed token from the connect flow (see above).
32
50
  const bt = new LetsFG({ bearerToken: 'eyJ...' });
33
51
 
34
52
  // Search — FREE
@@ -36,22 +54,62 @@ const flights = await bt.search('GDN', 'BER', '2026-03-03');
36
54
  const best = cheapestOffer(flights);
37
55
  console.log(offerSummary(best));
38
56
 
39
- // Book — free, ticket price only, no LetsFG fee. No unlock step.
57
+ // Book — ticket price only, no LetsFG fee, no unlock step. Starts the booking:
58
+ // the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
40
59
  const result = await bt.book(
41
60
  best.id,
42
- [{ given_name: 'John', family_name: 'Doe', born_on: '1990-01-15', gender: 'm' }],
61
+ [{
62
+ given_name: 'John', family_name: 'Doe', born_on: '1990-01-15', gender: 'm',
63
+ nationality: 'GB', phone_number: '+447700900123', phone_country: 'GB',
64
+ address_line1: '1 Analytical Way', address_city: 'London',
65
+ address_postal: 'N1 9GU', address_country: 'GB',
66
+ }],
43
67
  'john@example.com',
44
68
  '',
45
69
  '',
46
70
  flights.search_id,
47
71
  );
48
- if (result.booked) {
49
- console.log(`Order: ${result.order_id}`);
50
- } else {
51
- console.log(`Booking link (nothing charged): ${result.booking_url}`);
52
- }
72
+ const bookingRef = result.booking_ref as string;
73
+
74
+ // Poll until it lands (every 20-30 s): completed | failed | needs_attention
75
+ let status: Record<string, unknown>;
76
+ do {
77
+ await new Promise(r => setTimeout(r, 25_000));
78
+ status = await (await fetch('https://letsfg.co/api/agent-book/status', {
79
+ method: 'POST',
80
+ headers: { Authorization: 'Bearer eyJ...', 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ booking_ref: bookingRef }),
82
+ })).json();
83
+ } while (status.state === 'booking_in_progress');
84
+ console.log(status); // { state: 'completed', pnr: 'ABC123', charged_amount: 93, currency: 'EUR' }
53
85
  ```
54
86
 
87
+ ### How booking works
88
+
89
+ `bt.book()` posts to `POST /api/agent-book` and does exactly what the website
90
+ checkout does: the fare plus LetsFG's markup is **held** on the connected card
91
+ (not taken), a LetsFG booking agent buys the ticket from the seller, and the
92
+ hold is captured only once a real airline PNR exists. If the booking fails the
93
+ hold is released and nothing is charged. Every offer can be booked this way —
94
+ no unlock step, no booking-link fallback, no separate LetsFG fee.
95
+
96
+ The call returns within seconds with a `booking_ref`; the booking itself takes
97
+ 4–11 minutes. Poll `POST /api/agent-book/status` with `{"booking_ref": ...}`
98
+ every 20–30 s (the SDK has no helper for this yet):
99
+
100
+ | `state` | Meaning |
101
+ |---|---|
102
+ | `booking_in_progress` | the agent is at the seller's checkout — keep waiting |
103
+ | `completed` | booked — `pnr`, `charged_amount`, `currency` are in the answer |
104
+ | `failed` | not booked — the hold was released, nothing charged; see `failure_reason` |
105
+ | `needs_attention` | a human at LetsFG is checking it — do **not** book again |
106
+
107
+ One traveller per call, with the details an airline checkout asks for: name,
108
+ date of birth, gender, nationality, email, phone with its country, residence
109
+ address (passport optional). A missing detail returns `missing_details` with
110
+ `missing_fields` and charges nothing. Never start a second booking for the
111
+ same trip while one is in progress — that would place a second hold.
112
+
55
113
  Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
56
114
  `search()`/`book()` dispatch automatically. That path requires `unlock()`
57
115
  (1% fee, min $3) before `book()`.
@@ -59,11 +117,12 @@ Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken`
59
117
  ## Quick Start (CLI)
60
118
 
61
119
  ```bash
62
- export LETSFG_BEARER_TOKEN=<your-bearer-token> # from `letsfg auth`
120
+ export LETSFG_BEARER_TOKEN=<your-bearer-token> # card-backed, from the connect flow
63
121
 
64
122
  letsfg search GDN BER 2026-03-03 --sort price
65
123
  letsfg search LON BCN 2026-04-01 --json # Machine-readable
66
- letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":"Doe","born_on":"1990-01-15","gender":"m"}' -e john@example.com
124
+ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":"Doe","born_on":"1990-01-15","gender":"m","nationality":"GB","phone_number":"+447700900123","phone_country":"GB","address_line1":"1 Analytical Way","address_city":"London","address_postal":"N1 9GU","address_country":"GB"}' -e john@example.com
125
+ # prints the booking_ref — poll POST /api/agent-book/status until completed
67
126
  ```
68
127
 
69
128
  ## API
@@ -74,8 +133,9 @@ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":
74
133
  ### `bt.resolveLocation(query)`
75
134
  ### `bt.unlock(offerId)` — Developer API only
76
135
  ### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
77
- Dispatches on which credential is set: `bearerToken` → free PFS booking via
78
- `POST /api/agent-book` (pass `searchId`, one passenger). `apiKey` → paid
136
+ Dispatches on which credential is set: `bearerToken` → PFS booking via
137
+ `POST /api/agent-book` (pass `searchId`, one passenger with full details;
138
+ returns `booking_ref` — poll `POST /api/agent-book/status`). `apiKey` → paid
79
139
  Developer API `book` (requires `unlock()` first, supports multiple passengers
80
140
  and `idempotencyKey`).
81
141
  ### `bt.setupPayment(token?)` — Developer API only
@@ -118,7 +178,7 @@ MIT
118
178
 
119
179
  ## 🏨 Hotels — new, and live
120
180
 
121
- Your agent can now book hotels, not just flights. Same API key, same card on file.
181
+ Your agent can book hotels as well as flights. Same card-backed token or API key, same card on file.
122
182
 
123
183
  ```python
124
184
  from letsfg import LetsFG
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
@@ -867,8 +727,213 @@ var LetsFG = class {
867
727
  }
868
728
  };
869
729
 
730
+ // src/auth.ts
731
+ var import_node_fs = require("fs");
732
+ var import_node_os = require("os");
733
+ var import_node_path = require("path");
734
+ var import_node_crypto = require("crypto");
735
+ var import_node_http = require("http");
736
+ var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
737
+ var DEV_ROOT = `${BASE_URL}/developers/api`;
738
+ var FALLBACK_TTL_MS = 55 * 60 * 1e3;
739
+ var REFRESH_SKEW_MS = 5 * 60 * 1e3;
740
+ var BearerTokenError = class extends Error {
741
+ };
742
+ function configPath() {
743
+ const base = (0, import_node_os.platform)() === "win32" ? process.env.APPDATA || (0, import_node_os.homedir)() : (0, import_node_os.homedir)();
744
+ return (0, import_node_path.join)(base, ".letsfg", "config.json");
745
+ }
746
+ function loadConfig() {
747
+ const p = configPath();
748
+ if (!(0, import_node_fs.existsSync)(p)) return {};
749
+ try {
750
+ return JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
751
+ } catch {
752
+ return {};
753
+ }
754
+ }
755
+ function saveConfig(cfg) {
756
+ const p = configPath();
757
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(p), { recursive: true });
758
+ (0, import_node_fs.writeFileSync)(p, JSON.stringify(cfg, null, 2));
759
+ try {
760
+ (0, import_node_fs.chmodSync)(p, 384);
761
+ } catch {
762
+ }
763
+ }
764
+ function saveToken(token, expiresAt, extra) {
765
+ const cfg = loadConfig();
766
+ cfg.pfs_auth = {
767
+ ...cfg.pfs_auth ?? {},
768
+ ...extra ?? {},
769
+ token,
770
+ expires_at: expiresAt ?? Date.now() + FALLBACK_TTL_MS
771
+ };
772
+ saveConfig(cfg);
773
+ }
774
+ function getBearerToken() {
775
+ const env = process.env.LETSFG_BEARER_TOKEN;
776
+ if (env) return env;
777
+ const auth = loadConfig().pfs_auth;
778
+ if (auth?.token && Date.now() < auth.expires_at - REFRESH_SKEW_MS) {
779
+ return auth.token;
780
+ }
781
+ if (auth?.refresh_token) {
782
+ throw new BearerTokenError(
783
+ "LetsFG token expired. Call ensureBearerToken() to refresh it, or run: letsfg auth"
784
+ );
785
+ }
786
+ throw new BearerTokenError(
787
+ "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>"
788
+ );
789
+ }
790
+ async function discover() {
791
+ const fallback = {
792
+ authorization_endpoint: `${BASE_URL}/connect`,
793
+ token_endpoint: `${DEV_ROOT}/oauth/token`,
794
+ registration_endpoint: `${DEV_ROOT}/oauth/register`
795
+ };
796
+ try {
797
+ const resp = await fetch(`${DEV_ROOT}/.well-known/oauth-authorization-server`);
798
+ if (!resp.ok) return fallback;
799
+ const d = await resp.json();
800
+ return {
801
+ authorization_endpoint: d.authorization_endpoint || fallback.authorization_endpoint,
802
+ token_endpoint: d.token_endpoint || fallback.token_endpoint,
803
+ registration_endpoint: d.registration_endpoint || fallback.registration_endpoint
804
+ };
805
+ } catch {
806
+ return fallback;
807
+ }
808
+ }
809
+ function pkce() {
810
+ const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
811
+ const challenge = (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url");
812
+ return { verifier, challenge };
813
+ }
814
+ async function postForm(url, form) {
815
+ const resp = await fetch(url, {
816
+ method: "POST",
817
+ // RFC 6749 4.1.3 - the token endpoint takes form encoding. The server also
818
+ // accepts JSON, but sending the spec encoding keeps this honest.
819
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Client-Type": "js-sdk" },
820
+ body: new URLSearchParams(form).toString()
821
+ });
822
+ const data = await resp.json().catch(() => ({}));
823
+ return { status: resp.status, data };
824
+ }
825
+ function openBrowser(url) {
826
+ import("child_process").then(({ exec }) => {
827
+ const cmd = (0, import_node_os.platform)() === "win32" ? `start "" "${url}"` : (0, import_node_os.platform)() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
828
+ exec(cmd, () => {
829
+ });
830
+ }).catch(() => {
831
+ });
832
+ }
833
+ function startCallbackServer(expectedState) {
834
+ return new Promise((resolve, reject) => {
835
+ let settle = null;
836
+ let fail = null;
837
+ const codePromise = new Promise((res, rej) => {
838
+ settle = res;
839
+ fail = rej;
840
+ });
841
+ const server = (0, import_node_http.createServer)((req, res) => {
842
+ const url = new URL(req.url || "/", "http://127.0.0.1");
843
+ if (!url.pathname.startsWith("/callback")) {
844
+ res.writeHead(404).end();
845
+ return;
846
+ }
847
+ const err = url.searchParams.get("error");
848
+ const code = url.searchParams.get("code");
849
+ const state = url.searchParams.get("state");
850
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
851
+ if (err || !code) {
852
+ res.end("<h1>LetsFG</h1><p>That did not complete. Nothing was charged - run <code>letsfg auth</code> again.</p>");
853
+ fail?.(new BearerTokenError(`Authorisation failed: ${err || "no code returned"}`));
854
+ return;
855
+ }
856
+ if (state !== expectedState) {
857
+ res.end("<h1>LetsFG</h1><p>State mismatch - nothing was charged. Run <code>letsfg auth</code> again.</p>");
858
+ fail?.(new BearerTokenError("State mismatch on the OAuth callback - aborted."));
859
+ return;
860
+ }
861
+ res.end("<h1>LetsFG</h1><p>Connected. You can close this tab and go back to your terminal.</p>");
862
+ settle?.(code);
863
+ });
864
+ server.on("error", reject);
865
+ server.listen(0, "127.0.0.1", () => {
866
+ const addr = server.address();
867
+ const port = typeof addr === "object" && addr ? addr.port : 0;
868
+ if (!port) {
869
+ reject(new BearerTokenError("Could not open a local callback port."));
870
+ return;
871
+ }
872
+ resolve({ port, waitForCode: () => codePromise, close: () => server.close() });
873
+ });
874
+ });
875
+ }
876
+ async function connectAuth(openBrowserFlag = true) {
877
+ const meta = await discover();
878
+ const { verifier, challenge } = pkce();
879
+ const state = (0, import_node_crypto.randomBytes)(16).toString("base64url");
880
+ const { port, waitForCode, close } = await startCallbackServer(state);
881
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
882
+ try {
883
+ const reg = await fetch(meta.registration_endpoint, {
884
+ method: "POST",
885
+ headers: { "Content-Type": "application/json" },
886
+ body: JSON.stringify({
887
+ client_name: "letsfg-cli",
888
+ redirect_uris: [redirectUri],
889
+ grant_types: ["authorization_code", "refresh_token"],
890
+ response_types: ["code"],
891
+ token_endpoint_auth_method: "none"
892
+ })
893
+ });
894
+ const regData = await reg.json().catch(() => ({}));
895
+ const clientId = typeof regData.client_id === "string" ? regData.client_id : "";
896
+ if (!reg.ok || !clientId) {
897
+ throw new BearerTokenError(
898
+ `Could not register with LetsFG (HTTP ${reg.status}). See ${BASE_URL}/for-agents`
899
+ );
900
+ }
901
+ 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`;
902
+ console.log("\n LetsFG needs a card connected before it can search or book.");
903
+ console.log(" Nothing is charged now - you pay the fare only when you book,");
904
+ console.log(" and it is held, not taken, until the airline confirms.\n");
905
+ console.log(" Open this and add a card (or pay 0.00 with Revolut Pay):\n");
906
+ console.log(` ${authUrl}
907
+ `);
908
+ if (openBrowserFlag) openBrowser(authUrl);
909
+ process.stdout.write(" Waiting for you to finish... ");
910
+ const code = await waitForCode();
911
+ const { status, data } = await postForm(meta.token_endpoint, {
912
+ grant_type: "authorization_code",
913
+ code,
914
+ client_id: clientId,
915
+ redirect_uri: redirectUri,
916
+ code_verifier: verifier
917
+ });
918
+ if (status !== 200 || !data.access_token) {
919
+ throw new BearerTokenError(
920
+ `Could not complete authentication (HTTP ${status}). ${String(data.error_description || data.error || "")}`.trim()
921
+ );
922
+ }
923
+ const expiresIn = typeof data.expires_in === "number" ? data.expires_in * 1e3 : FALLBACK_TTL_MS;
924
+ saveToken(String(data.access_token), Date.now() + expiresIn, {
925
+ refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
926
+ client_id: clientId
927
+ });
928
+ console.log("done. Card connected - the token refreshes itself from now on.");
929
+ return String(data.access_token);
930
+ } finally {
931
+ close();
932
+ }
933
+ }
934
+ var paymentAuth = connectAuth;
935
+
870
936
  // src/cli.ts
871
- init_auth();
872
937
  function resolveCredentials(apiKeyFlag) {
873
938
  const apiKey = apiKeyFlag || process.env.LETSFG_API_KEY;
874
939
  if (apiKey) return { apiKey };
@@ -1104,17 +1169,19 @@ async function cmdLocations(args) {
1104
1169
  }
1105
1170
  }
1106
1171
  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);
1172
+ for (const dead of ["--card-token", "--payment-method"]) {
1173
+ if (getFlag(args, dead)) {
1174
+ console.error(
1175
+ `
1176
+ ${dead} was part of the Stripe enrolment, retired 2026-09-02.
1177
+ Run plain \`letsfg auth\` instead - it opens letsfg.co/connect to add a card.
1178
+ `
1179
+ );
1180
+ process.exit(1);
1181
+ }
1116
1182
  }
1117
- console.log("\n You're all set. Run: letsfg search WAW BCN 2026-07-15\n");
1183
+ await paymentAuth(!hasFlag(args, "--no-browser"));
1184
+ console.log("\n You are all set. Run: letsfg search WAW BCN 2026-07-15\n");
1118
1185
  }
1119
1186
  async function cmdRegister(args) {
1120
1187
  const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
@@ -1192,11 +1259,12 @@ var HELP = `
1192
1259
  LetsFG \u2014 Agent-native flight search & booking.
1193
1260
 
1194
1261
  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.
1262
+ Authenticate once with letsfg auth \u2014 it opens letsfg.co/connect so you can add
1263
+ a card. Nothing is charged; you pay the fare only when you book, and it is held,
1264
+ not taken, until the airline confirms.
1197
1265
 
1198
1266
  Commands:
1199
- auth Put a card on file -> 90-day token. Nothing charged
1267
+ auth Connect a card at letsfg.co/connect. Nothing charged
1200
1268
  search <origin> <dest> <date> Search for flights (free), prints search_id
1201
1269
  locations <query> Resolve city name to IATA codes
1202
1270
  book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
@@ -1212,9 +1280,7 @@ Options:
1212
1280
  --json, -j Output raw JSON
1213
1281
  --api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
1214
1282
  --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
1283
+ --no-browser (auth only) Print the connect URL instead of opening a browser
1218
1284
 
1219
1285
  Examples:
1220
1286
  letsfg auth
package/dist/cli.mjs CHANGED
@@ -4,11 +4,212 @@ import {
4
4
  LetsFGError,
5
5
  offerSummary
6
6
  } from "./chunk-GO3FXQXC.mjs";
7
- import {
8
- BearerTokenError,
9
- getBearerToken,
10
- paymentAuth
11
- } from "./chunk-XNO2W77S.mjs";
7
+
8
+ // src/auth.ts
9
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
10
+ import { homedir, platform } from "os";
11
+ import { join, dirname } from "path";
12
+ import { createHash, randomBytes } from "crypto";
13
+ import { createServer } from "http";
14
+ var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
15
+ var DEV_ROOT = `${BASE_URL}/developers/api`;
16
+ var FALLBACK_TTL_MS = 55 * 60 * 1e3;
17
+ var REFRESH_SKEW_MS = 5 * 60 * 1e3;
18
+ var BearerTokenError = class extends Error {
19
+ };
20
+ function configPath() {
21
+ const base = platform() === "win32" ? process.env.APPDATA || homedir() : homedir();
22
+ return join(base, ".letsfg", "config.json");
23
+ }
24
+ function loadConfig() {
25
+ const p = configPath();
26
+ if (!existsSync(p)) return {};
27
+ try {
28
+ return JSON.parse(readFileSync(p, "utf-8"));
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+ function saveConfig(cfg) {
34
+ const p = configPath();
35
+ mkdirSync(dirname(p), { recursive: true });
36
+ writeFileSync(p, JSON.stringify(cfg, null, 2));
37
+ try {
38
+ chmodSync(p, 384);
39
+ } catch {
40
+ }
41
+ }
42
+ function saveToken(token, expiresAt, extra) {
43
+ const cfg = loadConfig();
44
+ cfg.pfs_auth = {
45
+ ...cfg.pfs_auth ?? {},
46
+ ...extra ?? {},
47
+ token,
48
+ expires_at: expiresAt ?? Date.now() + FALLBACK_TTL_MS
49
+ };
50
+ saveConfig(cfg);
51
+ }
52
+ function getBearerToken() {
53
+ const env = process.env.LETSFG_BEARER_TOKEN;
54
+ if (env) return env;
55
+ const auth = loadConfig().pfs_auth;
56
+ if (auth?.token && Date.now() < auth.expires_at - REFRESH_SKEW_MS) {
57
+ return auth.token;
58
+ }
59
+ if (auth?.refresh_token) {
60
+ throw new BearerTokenError(
61
+ "LetsFG token expired. Call ensureBearerToken() to refresh it, or run: letsfg auth"
62
+ );
63
+ }
64
+ throw new BearerTokenError(
65
+ "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>"
66
+ );
67
+ }
68
+ async function discover() {
69
+ const fallback = {
70
+ authorization_endpoint: `${BASE_URL}/connect`,
71
+ token_endpoint: `${DEV_ROOT}/oauth/token`,
72
+ registration_endpoint: `${DEV_ROOT}/oauth/register`
73
+ };
74
+ try {
75
+ const resp = await fetch(`${DEV_ROOT}/.well-known/oauth-authorization-server`);
76
+ if (!resp.ok) return fallback;
77
+ const d = await resp.json();
78
+ return {
79
+ authorization_endpoint: d.authorization_endpoint || fallback.authorization_endpoint,
80
+ token_endpoint: d.token_endpoint || fallback.token_endpoint,
81
+ registration_endpoint: d.registration_endpoint || fallback.registration_endpoint
82
+ };
83
+ } catch {
84
+ return fallback;
85
+ }
86
+ }
87
+ function pkce() {
88
+ const verifier = randomBytes(32).toString("base64url");
89
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
90
+ return { verifier, challenge };
91
+ }
92
+ async function postForm(url, form) {
93
+ const resp = await fetch(url, {
94
+ method: "POST",
95
+ // RFC 6749 4.1.3 - the token endpoint takes form encoding. The server also
96
+ // accepts JSON, but sending the spec encoding keeps this honest.
97
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Client-Type": "js-sdk" },
98
+ body: new URLSearchParams(form).toString()
99
+ });
100
+ const data = await resp.json().catch(() => ({}));
101
+ return { status: resp.status, data };
102
+ }
103
+ function openBrowser(url) {
104
+ import("child_process").then(({ exec }) => {
105
+ const cmd = platform() === "win32" ? `start "" "${url}"` : platform() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
106
+ exec(cmd, () => {
107
+ });
108
+ }).catch(() => {
109
+ });
110
+ }
111
+ function startCallbackServer(expectedState) {
112
+ return new Promise((resolve, reject) => {
113
+ let settle = null;
114
+ let fail = null;
115
+ const codePromise = new Promise((res, rej) => {
116
+ settle = res;
117
+ fail = rej;
118
+ });
119
+ const server = createServer((req, res) => {
120
+ const url = new URL(req.url || "/", "http://127.0.0.1");
121
+ if (!url.pathname.startsWith("/callback")) {
122
+ res.writeHead(404).end();
123
+ return;
124
+ }
125
+ const err = url.searchParams.get("error");
126
+ const code = url.searchParams.get("code");
127
+ const state = url.searchParams.get("state");
128
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
129
+ if (err || !code) {
130
+ res.end("<h1>LetsFG</h1><p>That did not complete. Nothing was charged - run <code>letsfg auth</code> again.</p>");
131
+ fail?.(new BearerTokenError(`Authorisation failed: ${err || "no code returned"}`));
132
+ return;
133
+ }
134
+ if (state !== expectedState) {
135
+ res.end("<h1>LetsFG</h1><p>State mismatch - nothing was charged. Run <code>letsfg auth</code> again.</p>");
136
+ fail?.(new BearerTokenError("State mismatch on the OAuth callback - aborted."));
137
+ return;
138
+ }
139
+ res.end("<h1>LetsFG</h1><p>Connected. You can close this tab and go back to your terminal.</p>");
140
+ settle?.(code);
141
+ });
142
+ server.on("error", reject);
143
+ server.listen(0, "127.0.0.1", () => {
144
+ const addr = server.address();
145
+ const port = typeof addr === "object" && addr ? addr.port : 0;
146
+ if (!port) {
147
+ reject(new BearerTokenError("Could not open a local callback port."));
148
+ return;
149
+ }
150
+ resolve({ port, waitForCode: () => codePromise, close: () => server.close() });
151
+ });
152
+ });
153
+ }
154
+ async function connectAuth(openBrowserFlag = true) {
155
+ const meta = await discover();
156
+ const { verifier, challenge } = pkce();
157
+ const state = randomBytes(16).toString("base64url");
158
+ const { port, waitForCode, close } = await startCallbackServer(state);
159
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
160
+ try {
161
+ const reg = await fetch(meta.registration_endpoint, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({
165
+ client_name: "letsfg-cli",
166
+ redirect_uris: [redirectUri],
167
+ grant_types: ["authorization_code", "refresh_token"],
168
+ response_types: ["code"],
169
+ token_endpoint_auth_method: "none"
170
+ })
171
+ });
172
+ const regData = await reg.json().catch(() => ({}));
173
+ const clientId = typeof regData.client_id === "string" ? regData.client_id : "";
174
+ if (!reg.ok || !clientId) {
175
+ throw new BearerTokenError(
176
+ `Could not register with LetsFG (HTTP ${reg.status}). See ${BASE_URL}/for-agents`
177
+ );
178
+ }
179
+ 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`;
180
+ console.log("\n LetsFG needs a card connected before it can search or book.");
181
+ console.log(" Nothing is charged now - you pay the fare only when you book,");
182
+ console.log(" and it is held, not taken, until the airline confirms.\n");
183
+ console.log(" Open this and add a card (or pay 0.00 with Revolut Pay):\n");
184
+ console.log(` ${authUrl}
185
+ `);
186
+ if (openBrowserFlag) openBrowser(authUrl);
187
+ process.stdout.write(" Waiting for you to finish... ");
188
+ const code = await waitForCode();
189
+ const { status, data } = await postForm(meta.token_endpoint, {
190
+ grant_type: "authorization_code",
191
+ code,
192
+ client_id: clientId,
193
+ redirect_uri: redirectUri,
194
+ code_verifier: verifier
195
+ });
196
+ if (status !== 200 || !data.access_token) {
197
+ throw new BearerTokenError(
198
+ `Could not complete authentication (HTTP ${status}). ${String(data.error_description || data.error || "")}`.trim()
199
+ );
200
+ }
201
+ const expiresIn = typeof data.expires_in === "number" ? data.expires_in * 1e3 : FALLBACK_TTL_MS;
202
+ saveToken(String(data.access_token), Date.now() + expiresIn, {
203
+ refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
204
+ client_id: clientId
205
+ });
206
+ console.log("done. Card connected - the token refreshes itself from now on.");
207
+ return String(data.access_token);
208
+ } finally {
209
+ close();
210
+ }
211
+ }
212
+ var paymentAuth = connectAuth;
12
213
 
13
214
  // src/cli.ts
14
215
  function resolveCredentials(apiKeyFlag) {
@@ -246,17 +447,19 @@ async function cmdLocations(args) {
246
447
  }
247
448
  }
248
449
  async function cmdAuth(args) {
249
- const cardToken = getFlag(args, "--card-token");
250
- const paymentMethodId = getFlag(args, "--payment-method");
251
- const noBrowser = hasFlag(args, "--no-browser");
252
- if (cardToken || paymentMethodId) {
253
- const { verifyPaymentMethod } = await import("./auth-SYZ5AYZ6.mjs");
254
- await verifyPaymentMethod({ cardToken: cardToken || void 0, paymentMethodId: paymentMethodId || void 0 });
255
- console.log("\n \u2713 Authenticated. Nothing was charged.");
256
- } else {
257
- await paymentAuth(!noBrowser);
450
+ for (const dead of ["--card-token", "--payment-method"]) {
451
+ if (getFlag(args, dead)) {
452
+ console.error(
453
+ `
454
+ ${dead} was part of the Stripe enrolment, retired 2026-09-02.
455
+ Run plain \`letsfg auth\` instead - it opens letsfg.co/connect to add a card.
456
+ `
457
+ );
458
+ process.exit(1);
459
+ }
258
460
  }
259
- console.log("\n You're all set. Run: letsfg search WAW BCN 2026-07-15\n");
461
+ await paymentAuth(!hasFlag(args, "--no-browser"));
462
+ console.log("\n You are all set. Run: letsfg search WAW BCN 2026-07-15\n");
260
463
  }
261
464
  async function cmdRegister(args) {
262
465
  const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
@@ -334,11 +537,12 @@ var HELP = `
334
537
  LetsFG \u2014 Agent-native flight search & booking.
335
538
 
336
539
  Search hundreds of airlines via the LetsFG cloud engine.
337
- Authenticate once with letsfg auth \u2014 a zero-amount card setup, nothing is
338
- charged \u2014 then search and book.
540
+ Authenticate once with letsfg auth \u2014 it opens letsfg.co/connect so you can add
541
+ a card. Nothing is charged; you pay the fare only when you book, and it is held,
542
+ not taken, until the airline confirms.
339
543
 
340
544
  Commands:
341
- auth Put a card on file -> 90-day token. Nothing charged
545
+ auth Connect a card at letsfg.co/connect. Nothing charged
342
546
  search <origin> <dest> <date> Search for flights (free), prints search_id
343
547
  locations <query> Resolve city name to IATA codes
344
548
  book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
@@ -354,9 +558,7 @@ Options:
354
558
  --json, -j Output raw JSON
355
559
  --api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
356
560
  --base-url API URL (default: https://letsfg.co)
357
- --card-token (auth only) Stripe tok_... you already hold, for a headless auth
358
- --payment-method (auth only) Stripe pm_... you already hold, for a headless auth
359
- --no-browser (auth only) Don't try to auto-open the card setup page
561
+ --no-browser (auth only) Print the connect URL instead of opening a browser
360
562
 
361
563
  Examples:
362
564
  letsfg auth
package/package.json CHANGED
@@ -1,56 +1,56 @@
1
- {
2
- "name": "letsfg",
3
- "version": "2026.5.71",
4
- "description": "Flights and hotels for AI agents. Server-side engine covers hundreds of airlines; hotels are real bookable inventory with free cancellation and pay-later terms. Includes open-source ranking engine.",
5
- "main": "dist/index.js",
6
- "module": "dist/index.mjs",
7
- "types": "dist/index.d.ts",
8
- "bin": {
9
- "letsfg": "dist/cli.js"
10
- },
11
- "files": [
12
- "dist/",
13
- "README.md",
14
- "LICENSE"
15
- ],
16
- "scripts": {
17
- "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean",
18
- "test": "tsx --test src/index.test.ts src/auth.test.ts",
19
- "prepublishOnly": "npm run build"
20
- },
21
- "keywords": [
22
- "flights",
23
- "travel",
24
- "booking",
25
- "agent",
26
- "ai",
27
- "cli",
28
- "autonomous",
29
- "mcp",
30
- "openai",
31
- "airline",
32
- "ndc",
33
- "search",
34
- "ndc",
35
- "hotels",
36
- "hotel-booking",
37
- "hotel-search",
38
- "accommodation"
39
- ],
40
- "author": "LetsFG",
41
- "license": "MIT",
42
- "repository": {
43
- "type": "git",
44
- "url": "https://github.com/LetsFG/LetsFG.git"
45
- },
46
- "homepage": "https://letsfg.co",
47
- "devDependencies": {
48
- "@types/node": "^25.3.3",
49
- "tsup": "^8.0.0",
50
- "tsx": "^4.19.0",
51
- "typescript": "^5.3.0"
52
- },
53
- "engines": {
54
- "node": ">=18.0.0"
55
- }
56
- }
1
+ {
2
+ "name": "letsfg",
3
+ "version": "2026.5.72",
4
+ "description": "Flights and hotels for AI agents. Server-side engine covers hundreds of airlines; hotels are real bookable inventory with free cancellation and pay-later terms. Includes open-source ranking engine.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "letsfg": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean",
18
+ "test": "tsx --test src/index.test.ts src/auth.test.ts",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "flights",
23
+ "travel",
24
+ "booking",
25
+ "agent",
26
+ "ai",
27
+ "cli",
28
+ "autonomous",
29
+ "mcp",
30
+ "openai",
31
+ "airline",
32
+ "ndc",
33
+ "search",
34
+ "ndc",
35
+ "hotels",
36
+ "hotel-booking",
37
+ "hotel-search",
38
+ "accommodation"
39
+ ],
40
+ "author": "LetsFG",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/LetsFG/LetsFG.git"
45
+ },
46
+ "homepage": "https://letsfg.co",
47
+ "devDependencies": {
48
+ "@types/node": "^25.3.3",
49
+ "tsup": "^8.0.0",
50
+ "tsx": "^4.19.0",
51
+ "typescript": "^5.3.0"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ }
56
+ }
@@ -1,16 +0,0 @@
1
- import {
2
- BearerTokenError,
3
- getBearerToken,
4
- paymentAuth,
5
- requestEnrolment,
6
- saveToken,
7
- verifyPaymentMethod
8
- } from "./chunk-XNO2W77S.mjs";
9
- export {
10
- BearerTokenError,
11
- getBearerToken,
12
- paymentAuth,
13
- requestEnrolment,
14
- saveToken,
15
- verifyPaymentMethod
16
- };
@@ -1,126 +0,0 @@
1
- // src/auth.ts
2
- import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
3
- import { homedir, platform } from "os";
4
- import { join, dirname } from "path";
5
- var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
6
- var TOKEN_TTL_MS = 90 * 24 * 3600 * 1e3;
7
- var BearerTokenError = class extends Error {
8
- };
9
- function configPath() {
10
- const base = platform() === "win32" ? process.env.APPDATA || homedir() : homedir();
11
- return join(base, ".letsfg", "config.json");
12
- }
13
- function loadConfig() {
14
- const p = configPath();
15
- if (!existsSync(p)) return {};
16
- try {
17
- return JSON.parse(readFileSync(p, "utf-8"));
18
- } catch {
19
- return {};
20
- }
21
- }
22
- function saveConfig(cfg) {
23
- const p = configPath();
24
- mkdirSync(dirname(p), { recursive: true });
25
- writeFileSync(p, JSON.stringify(cfg, null, 2));
26
- try {
27
- chmodSync(p, 384);
28
- } catch {
29
- }
30
- }
31
- function getBearerToken() {
32
- const env = process.env.LETSFG_BEARER_TOKEN;
33
- if (env) return env;
34
- const auth = loadConfig().pfs_auth;
35
- if (auth?.token && Date.now() < auth.expires_at - 36e5) {
36
- return auth.token;
37
- }
38
- throw new BearerTokenError(
39
- "No valid LetsFG Bearer token.\n Run: letsfg auth (adds a payment method \u2014 nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
40
- );
41
- }
42
- function saveToken(token, expiresAt) {
43
- const cfg = loadConfig();
44
- cfg.pfs_auth = { token, expires_at: expiresAt ?? Date.now() + TOKEN_TTL_MS };
45
- saveConfig(cfg);
46
- }
47
- async function postJson(path, payload) {
48
- const resp = await fetch(`${BASE_URL}${path}`, {
49
- method: "POST",
50
- headers: { "Content-Type": "application/json", "User-Agent": "LetsFG-js/0.1.0", "X-Client-Type": "js-sdk" },
51
- body: JSON.stringify(payload)
52
- });
53
- const data = await resp.json().catch(() => ({}));
54
- return { status: resp.status, data };
55
- }
56
- function parseExpiry(raw) {
57
- if (typeof raw === "number") return raw * 1e3;
58
- if (typeof raw === "string") {
59
- const ms = Date.parse(raw);
60
- if (!Number.isNaN(ms)) return ms;
61
- }
62
- return Date.now() + TOKEN_TTL_MS;
63
- }
64
- async function requestEnrolment() {
65
- const { status, data } = await postJson("/api/agent-access/request", {});
66
- if (status !== 200 && status !== 402) {
67
- throw new BearerTokenError(`Could not start authentication (HTTP ${status}): ${data.error || ""}`);
68
- }
69
- return data;
70
- }
71
- async function verifyPaymentMethod(opts) {
72
- const payload = {};
73
- if (opts.setupSessionId) payload.setup_session_id = opts.setupSessionId;
74
- else if (opts.paymentMethodId) payload.payment_method_id = opts.paymentMethodId;
75
- else if (opts.cardToken) payload.card_token = opts.cardToken;
76
- else throw new BearerTokenError("Provide one of setupSessionId, paymentMethodId, or cardToken.");
77
- const { status, data } = await postJson("/api/agent-access/verify", payload);
78
- if (status !== 200 || !data.token) {
79
- throw new BearerTokenError(`Verification failed (HTTP ${status}). ${data.hint || data.error || ""}`.trim());
80
- }
81
- const token = data.token;
82
- saveToken(token, parseExpiry(data.expires_at));
83
- return token;
84
- }
85
- function openBrowser(url) {
86
- import("child_process").then(({ exec }) => {
87
- const cmd = platform() === "win32" ? `start "" "${url}"` : platform() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
88
- exec(cmd, () => {
89
- });
90
- }).catch(() => {
91
- });
92
- }
93
- async function paymentAuth(openBrowserFlag = true) {
94
- console.log("\n Connecting to LetsFG...");
95
- const data = await requestEnrolment();
96
- const setupUrl = data.setup_url;
97
- const sessionId = data.setup_session_id;
98
- if (!setupUrl || !sessionId) {
99
- throw new BearerTokenError("Server did not return a setup URL. Check https://letsfg.co/for-agents");
100
- }
101
- console.log("\n LetsFG needs a payment method on file before it can search or book.");
102
- console.log(" Nothing is charged now \u2014 this is a zero-amount card setup.\n");
103
- console.log(" Step 1 \u2014 add a card here:\n");
104
- console.log(` ${setupUrl}
105
- `);
106
- if (openBrowserFlag) openBrowser(setupUrl);
107
- console.log(" Step 2 \u2014 press Enter once you've finished.");
108
- const readline = await import("readline/promises");
109
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
110
- await rl.question("");
111
- rl.close();
112
- process.stdout.write(" Verifying... ");
113
- const token = await verifyPaymentMethod({ setupSessionId: sessionId });
114
- const expiresAt = loadConfig().pfs_auth?.expires_at ?? Date.now() + TOKEN_TTL_MS;
115
- console.log(`done. Token valid until ${new Date(expiresAt).toISOString().slice(0, 10)}.`);
116
- return token;
117
- }
118
-
119
- export {
120
- BearerTokenError,
121
- getBearerToken,
122
- saveToken,
123
- requestEnrolment,
124
- verifyPaymentMethod,
125
- paymentAuth
126
- };