tixbit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -47
- package/dist/cli.js +1050 -69
- package/dist/index.d.ts +240 -14
- package/dist/index.js +526 -37
- package/package.json +12 -6
- package/skills/tixbit/SKILL.md +86 -0
package/dist/cli.js
CHANGED
|
@@ -3,18 +3,78 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
+
// src/safety.ts
|
|
7
|
+
function usdCents(raw) {
|
|
8
|
+
if (!raw || !/^\d+(\.\d{1,2})?$/.test(raw)) throw new TypeError("--max-price must be a positive total USD amount with at most two decimals");
|
|
9
|
+
const [dollars, cents = ""] = raw.split(".");
|
|
10
|
+
const amount = BigInt(dollars) * 100n + BigInt(cents.padEnd(2, "0"));
|
|
11
|
+
if (amount <= 0n || amount > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("Invalid --max-price");
|
|
12
|
+
return Number(amount);
|
|
13
|
+
}
|
|
14
|
+
function positiveInteger(raw, name, max) {
|
|
15
|
+
const value = Number(raw);
|
|
16
|
+
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`${name} must be an integer from 1 to ${max}`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function trustedWebBase(base) {
|
|
20
|
+
const url = new URL(base);
|
|
21
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
22
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !(url.origin === "https://www.tixbit.com" || local && ["http:", "https:"].includes(url.protocol))) {
|
|
23
|
+
throw new TypeError("Credential requests require https://www.tixbit.com or a loopback QA origin");
|
|
24
|
+
}
|
|
25
|
+
return url.origin;
|
|
26
|
+
}
|
|
27
|
+
function assertMppChallenge(challenge, maxAmountCents) {
|
|
28
|
+
const { amount, currency } = challenge.request;
|
|
29
|
+
if (!Number.isSafeInteger(maxAmountCents) || maxAmountCents <= 0 || challenge.method !== "tempo" || challenge.intent !== "charge" || currency !== "0x20c0000000000000000000000000000000000000" || typeof amount !== "string" || !/^\d{1,30}$/.test(amount) || BigInt(amount) <= 0n || BigInt(amount) > BigInt(maxAmountCents) * 10000n || challenge.request.splits !== void 0 || challenge.request.methodDetails !== void 0 && (!challenge.request.methodDetails || typeof challenge.request.methodDetails !== "object" || "splits" in challenge.request.methodDetails)) {
|
|
30
|
+
throw new TypeError("MPP challenge exceeds the approved total or uses an unsupported payment asset or intent");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function redact(value, secrets = []) {
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
let result = value.replace(/\bspt_[A-Za-z0-9_]+/g, "[REDACTED]");
|
|
36
|
+
for (const secret of secrets.filter(Boolean)) result = result.split(secret).join("[REDACTED]");
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(value)) return value.map((item) => redact(item, secrets));
|
|
40
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, /token|secret|authorization|private.?key|password|credential/i.test(key) ? "[REDACTED]" : redact(item, secrets)]));
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/cli.ts
|
|
45
|
+
import { createRequire as createRequire2 } from "module";
|
|
46
|
+
|
|
6
47
|
// src/client.ts
|
|
7
|
-
var DEFAULT_BASE_URL = "https://tixbit.com";
|
|
48
|
+
var DEFAULT_BASE_URL = "https://www.tixbit.com";
|
|
49
|
+
var DEFAULT_PAYMENT_ENDPOINT = "https://mcp.tixbit.com/api/purchase";
|
|
8
50
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
9
51
|
var USER_AGENT = "@tixbit/sdk";
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
52
|
+
function normalizePaymentEndpoint(value) {
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(value);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new TypeError("paymentEndpoint must be an absolute URL");
|
|
58
|
+
}
|
|
59
|
+
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
60
|
+
const isTixBitHost = url.origin === "https://mcp.tixbit.com" && url.pathname === "/api/purchase";
|
|
61
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
|
|
62
|
+
throw new TypeError(
|
|
63
|
+
"paymentEndpoint must use HTTPS (HTTP is allowed only for loopback QA)"
|
|
64
|
+
);
|
|
16
65
|
}
|
|
17
|
-
|
|
66
|
+
if (url.username || url.password || url.hash || url.search) {
|
|
67
|
+
throw new TypeError("paymentEndpoint must not contain credentials or a fragment");
|
|
68
|
+
}
|
|
69
|
+
if (!isLoopback && !isTixBitHost) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
"paymentEndpoint must use a TixBit host or a loopback address for local QA"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return url.toString().replace(/\/$/, "");
|
|
75
|
+
}
|
|
76
|
+
function normalizeExternalEventId(eventId) {
|
|
77
|
+
return eventId;
|
|
18
78
|
}
|
|
19
79
|
function toAbsoluteUrl(baseUrl, value) {
|
|
20
80
|
if (!value) return null;
|
|
@@ -26,37 +86,44 @@ function toAbsoluteUrl(baseUrl, value) {
|
|
|
26
86
|
var TixBitClient = class {
|
|
27
87
|
baseUrl;
|
|
28
88
|
timeoutMs;
|
|
29
|
-
|
|
89
|
+
paymentEndpoint;
|
|
90
|
+
paymentFetch;
|
|
30
91
|
constructor(config = {}) {
|
|
31
92
|
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
32
93
|
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33
|
-
this.
|
|
94
|
+
this.paymentEndpoint = normalizePaymentEndpoint(
|
|
95
|
+
config.paymentEndpoint ?? DEFAULT_PAYMENT_ENDPOINT
|
|
96
|
+
);
|
|
97
|
+
this.paymentFetch = config.paymentFetch ?? ((input, init) => fetch(input, init));
|
|
34
98
|
}
|
|
35
99
|
// ── HTTP helpers ──────────────────────────────────────────────────────────
|
|
36
|
-
async request(path
|
|
100
|
+
async request(path) {
|
|
37
101
|
const url = `${this.baseUrl}${path}`;
|
|
38
102
|
const controller = new AbortController();
|
|
39
103
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
40
104
|
const headers = {
|
|
41
105
|
Accept: "application/json",
|
|
42
|
-
"User-Agent": USER_AGENT
|
|
43
|
-
...this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}
|
|
106
|
+
"User-Agent": USER_AGENT
|
|
44
107
|
};
|
|
45
108
|
try {
|
|
46
109
|
const res = await fetch(url, {
|
|
47
|
-
|
|
48
|
-
headers
|
|
110
|
+
redirect: "error",
|
|
111
|
+
headers,
|
|
49
112
|
signal: controller.signal
|
|
50
113
|
});
|
|
51
114
|
if (!res.ok) {
|
|
52
|
-
const text = await res.text().catch(() => "");
|
|
53
115
|
throw new TixBitApiError(
|
|
54
|
-
|
|
116
|
+
`Request failed (HTTP ${res.status})`,
|
|
55
117
|
res.status,
|
|
56
118
|
url
|
|
57
119
|
);
|
|
58
120
|
}
|
|
59
121
|
return await res.json();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (controller.signal.aborted) {
|
|
124
|
+
throw new TixBitTimeoutError(url, this.timeoutMs);
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
60
127
|
} finally {
|
|
61
128
|
clearTimeout(timer);
|
|
62
129
|
}
|
|
@@ -80,15 +147,40 @@ var TixBitClient = class {
|
|
|
80
147
|
city: params.city,
|
|
81
148
|
state: params.state,
|
|
82
149
|
category: params.category,
|
|
150
|
+
league: params.league,
|
|
151
|
+
categoryEventType: params.categoryEventType,
|
|
152
|
+
performerId: params.performerId,
|
|
153
|
+
venueId: params.venueId,
|
|
154
|
+
parkingFilter: params.parkingFilter,
|
|
155
|
+
nearLat: params.nearLat,
|
|
156
|
+
nearLng: params.nearLng,
|
|
157
|
+
locationMode: params.locationMode,
|
|
83
158
|
startDate: params.startDate,
|
|
84
159
|
endDate: params.endDate,
|
|
85
160
|
page: params.page,
|
|
86
161
|
size: params.size ?? 25
|
|
87
162
|
});
|
|
88
163
|
const data = await this.request(`/api/events/search${query}`);
|
|
164
|
+
const pagination = data.pagination;
|
|
89
165
|
return {
|
|
90
166
|
events: (data.events ?? []).map(normalizeEvent),
|
|
91
|
-
pagination:
|
|
167
|
+
pagination: {
|
|
168
|
+
...pagination,
|
|
169
|
+
totalExact: pagination.totalExact ?? pagination.total !== null
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Get one public event by ID or slug. */
|
|
174
|
+
async getEvent(eventId) {
|
|
175
|
+
const normalizedEventId = normalizeExternalEventId(eventId);
|
|
176
|
+
const data = await this.request(
|
|
177
|
+
`/api/events/${encodeURIComponent(normalizedEventId)}`
|
|
178
|
+
);
|
|
179
|
+
const publicEventId = normalizeExternalEventId(data.external_id ?? data.id);
|
|
180
|
+
return {
|
|
181
|
+
...data,
|
|
182
|
+
id: publicEventId,
|
|
183
|
+
external_id: publicEventId
|
|
92
184
|
};
|
|
93
185
|
}
|
|
94
186
|
// ── Browse (Location-Aware) ───────────────────────────────────────────────
|
|
@@ -102,19 +194,32 @@ var TixBitClient = class {
|
|
|
102
194
|
*/
|
|
103
195
|
async browse(params = {}) {
|
|
104
196
|
const query = this.qs({
|
|
197
|
+
page: params.page ?? 1,
|
|
105
198
|
size: params.size ?? 18,
|
|
106
|
-
|
|
107
|
-
|
|
199
|
+
query: params.query,
|
|
200
|
+
category: params.category,
|
|
201
|
+
league: params.league,
|
|
202
|
+
date: params.date,
|
|
203
|
+
context: params.context ?? "homepage",
|
|
204
|
+
recommendation: params.recommendation ?? "upcoming",
|
|
108
205
|
nearLat: params.latitude,
|
|
109
206
|
nearLng: params.longitude,
|
|
110
207
|
preferCity: params.city,
|
|
111
208
|
preferState: params.state,
|
|
112
|
-
categoryEventType: params.categoryEventType
|
|
209
|
+
categoryEventType: params.categoryEventType,
|
|
210
|
+
locationMode: params.locationMode,
|
|
211
|
+
searchScope: params.searchScope,
|
|
212
|
+
parkingFilter: params.parkingFilter
|
|
113
213
|
});
|
|
114
214
|
const data = await this.request(`/api/events${query}`);
|
|
115
215
|
return {
|
|
116
216
|
events: (data.events ?? []).map(normalizeEvent),
|
|
117
|
-
total: data.total
|
|
217
|
+
total: data.total,
|
|
218
|
+
totalExact: data.totalExact ?? data.total !== null,
|
|
219
|
+
hasMore: data.hasMore ?? false,
|
|
220
|
+
page: data.page ?? params.page ?? 1,
|
|
221
|
+
pageSize: data.pageSize ?? params.size ?? 18,
|
|
222
|
+
...data.degraded === void 0 ? {} : { degraded: data.degraded }
|
|
118
223
|
};
|
|
119
224
|
}
|
|
120
225
|
// ── Listings ──────────────────────────────────────────────────────────────
|
|
@@ -123,14 +228,16 @@ var TixBitClient = class {
|
|
|
123
228
|
*
|
|
124
229
|
* @example
|
|
125
230
|
* ```ts
|
|
126
|
-
* const listings = await client.getListings({ eventId: "
|
|
231
|
+
* const listings = await client.getListings({ eventId: "EVENT123" });
|
|
127
232
|
* ```
|
|
128
233
|
*/
|
|
129
234
|
async getListings(params) {
|
|
130
235
|
const query = this.qs({
|
|
131
236
|
size: params.size ?? 50,
|
|
132
237
|
page: params.page ?? 1,
|
|
133
|
-
order_by_direction: params.orderByDirection ?? "asc"
|
|
238
|
+
order_by_direction: params.orderByDirection ?? "asc",
|
|
239
|
+
includeAll: params.includeAll,
|
|
240
|
+
refresh: params.refresh
|
|
134
241
|
});
|
|
135
242
|
const normalizedEventId = normalizeExternalEventId(params.eventId);
|
|
136
243
|
const data = await this.request(`/api/events/${encodeURIComponent(normalizedEventId)}/listings${query}`);
|
|
@@ -138,13 +245,121 @@ var TixBitClient = class {
|
|
|
138
245
|
return {
|
|
139
246
|
listings,
|
|
140
247
|
meta: {
|
|
141
|
-
total: data.meta
|
|
142
|
-
page: data.meta
|
|
143
|
-
size: data.meta
|
|
248
|
+
total: readListingMetaNumber(data.meta, "total_count", "total") ?? listings.length,
|
|
249
|
+
page: readListingMetaNumber(data.meta, "current_page_number", "page") ?? params.page ?? 1,
|
|
250
|
+
size: readListingMetaNumber(data.meta, "current_page_size", "size") ?? params.size ?? 50,
|
|
251
|
+
totalPages: readListingMetaNumber(data.meta, "total_pages") ?? void 0,
|
|
252
|
+
currentPageTotalCount: readListingMetaNumber(data.meta, "current_page_total_count") ?? void 0,
|
|
253
|
+
cache: readBoolean(data.meta?.cache) ?? void 0,
|
|
254
|
+
cacheExpiresAt: readNullableString(data.meta?.cache_expires_at),
|
|
255
|
+
cacheState: readEnum(data.meta?.cache_state, ["fresh", "stale", "unavailable"]),
|
|
256
|
+
freshness: readEnum(data.meta?.freshness, ["live", "cached"]),
|
|
144
257
|
cacheSource: data.meta?.cacheSource
|
|
145
258
|
}
|
|
146
259
|
};
|
|
147
260
|
}
|
|
261
|
+
/** Get one public listing by listing ID. */
|
|
262
|
+
async getListing(listingId) {
|
|
263
|
+
const data = await this.request(`/api/listings/${encodeURIComponent(listingId)}`);
|
|
264
|
+
return {
|
|
265
|
+
listing: normalizeListing(data.listing),
|
|
266
|
+
disclosures: data.disclosures ?? []
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Refreshed inventory, not a reservation or negotiated offer. */
|
|
270
|
+
async quoteListings(params) {
|
|
271
|
+
if (!params.eventId || params.eventId !== params.eventId.trim()) throw new TypeError("Copy the exact event ID from search");
|
|
272
|
+
if (params.size !== void 0 && (!Number.isInteger(params.size) || params.size < 1 || params.size > 100)) throw new RangeError("size must be 1-100");
|
|
273
|
+
const result = await this.getListings({ ...params, refresh: true });
|
|
274
|
+
if (result.meta.freshness !== "live") throw new Error("Live inventory is unavailable; no live quote can be provided");
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
async agentRequest(path, body, token) {
|
|
278
|
+
const base = trustedWebBase(this.baseUrl);
|
|
279
|
+
const headers = { Accept: "application/json", "Content-Type": "application/json" };
|
|
280
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
281
|
+
const response = await fetch(`${base}${path}`, {
|
|
282
|
+
method: body === void 0 ? "GET" : "POST",
|
|
283
|
+
headers,
|
|
284
|
+
redirect: "error",
|
|
285
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
286
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
287
|
+
}).catch(() => {
|
|
288
|
+
throw new Error("Request did not complete; reconcile any write before retrying");
|
|
289
|
+
});
|
|
290
|
+
if (!response.ok) throw new TixBitApiError(`Request failed (HTTP ${response.status})`, response.status, `${base}${path}`);
|
|
291
|
+
const result = await response.json().catch(() => {
|
|
292
|
+
throw new Error("Invalid server response");
|
|
293
|
+
});
|
|
294
|
+
if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) throw new Error("Invalid server response");
|
|
295
|
+
return redact(result, [token ?? ""]);
|
|
296
|
+
}
|
|
297
|
+
/** Public browser entry points. This does not create or import a CLI session. */
|
|
298
|
+
getAuthorizationInfo() {
|
|
299
|
+
return {
|
|
300
|
+
apiKeyRequired: false,
|
|
301
|
+
cliSessionSupported: false,
|
|
302
|
+
signInUrl: "https://www.tixbit.com/sign-in",
|
|
303
|
+
sellerUrl: "https://www.tixbit.com/sell",
|
|
304
|
+
linkWalletUrl: "https://link.com/agents",
|
|
305
|
+
action: "Sign in in your browser for selling or browser checkout. Browser sign-in does not authenticate this CLI. No TixBit developer API key is needed."
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
sellerAuthorizationRequired() {
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
status: "authorization_required",
|
|
312
|
+
data: this.getAuthorizationInfo(),
|
|
313
|
+
error: { code: "SELLER_SIGN_IN_REQUIRED", message: "Seller operations require your signed-in user and seller access." },
|
|
314
|
+
action: "Open the sign-in URL, then continue on the seller page. Browser-to-CLI session authorization is not supported. No listing was read or submitted; do not copy browser cookies or tokens."
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** Stripe Link checkout. No confirmation means quote only. Never retries payment. */
|
|
318
|
+
async buyTickets(params) {
|
|
319
|
+
trustedWebBase(this.baseUrl);
|
|
320
|
+
if (!/^[A-Za-z0-9]{4,12}$/.test(params.listingId)) throw new TypeError("Link supports 4-12 alphanumeric listing IDs only; use browser checkout for native listings");
|
|
321
|
+
if (!Number.isInteger(params.quantity) || params.quantity < 1 || params.quantity > 8) throw new RangeError("quantity must be 1-8");
|
|
322
|
+
if (!Number.isSafeInteger(params.maxAmountCents) || params.maxAmountCents <= 0) throw new RangeError("A positive total maxAmountCents is required");
|
|
323
|
+
const email = normalizeBuyerEmail(params.email);
|
|
324
|
+
if (params.confirm === true && !params.sharedPaymentToken) {
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
status: "authorization_required",
|
|
328
|
+
data: { checkoutUrl: this.createCheckoutLink(params).url, maxAmountCents: params.maxAmountCents, automaticPayment: false },
|
|
329
|
+
error: { code: "PAYMENT_AUTHORIZATION_REQUIRED", message: "A user-approved payment method is required, not a TixBit API key." },
|
|
330
|
+
action: "Open the checkout URL to sign in and pay in your browser. Review and approve the final total there; this CLI cap is not transferred to browser checkout. No payment was sent. Automatic Link authorization is unavailable without public merchant discovery."
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (params.confirm === true && !/^spt_[A-Za-z0-9_]+$/.test(params.sharedPaymentToken ?? "")) throw new TypeError("Invalid user-approved Link payment credential");
|
|
334
|
+
const body = { listingId: params.listingId, quantity: params.quantity, email, maxAmountCents: params.maxAmountCents };
|
|
335
|
+
const quote = await this.agentRequest("/api/agentic/checkout", body);
|
|
336
|
+
const validQuote = (data) => data && data.listingId === params.listingId && data.quantity === params.quantity && data.currency === "usd" && Number.isSafeInteger(data.amountCents) && data.amountCents > 0 && data.amountCents <= params.maxAmountCents;
|
|
337
|
+
if (!validQuote(quote.data)) throw new Error("Checkout quote is invalid or exceeds the approved total; no payment sent");
|
|
338
|
+
if (params.confirm !== true) return quote;
|
|
339
|
+
if (quote.data?.maxAmountCentsSupported !== true) throw new Error("Server does not support the total cap; no payment sent");
|
|
340
|
+
try {
|
|
341
|
+
const paid = await this.agentRequest("/api/agentic/checkout", { ...body, sharedPaymentToken: params.sharedPaymentToken });
|
|
342
|
+
if (!validQuote(paid.data) || typeof paid.data?.orderId !== "string" || typeof paid.data?.status !== "string") throw new Error("Invalid payment outcome");
|
|
343
|
+
return { success: true, data: { listingId: params.listingId, quantity: params.quantity, amountCents: paid.data.amountCents, currency: "usd", orderId: paid.data.orderId, status: paid.data.status }, action: "Payment accepted. Check order fulfillment before any further purchase." };
|
|
344
|
+
} catch {
|
|
345
|
+
return { success: false, status: "unknown", error: { code: "PAYMENT_OUTCOME_UNKNOWN", message: "Payment or fulfillment status is unknown." }, action: "Do not retry or send another token. Reconcile this purchase with TixBit support and Stripe Link first." };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async listSellerListings(accessToken = "") {
|
|
349
|
+
if (!accessToken) return this.sellerAuthorizationRequired();
|
|
350
|
+
return this.agentRequest("/api/sell/listings", void 0, accessToken);
|
|
351
|
+
}
|
|
352
|
+
async createSellerListing(body, accessToken, confirm) {
|
|
353
|
+
trustedWebBase(this.baseUrl);
|
|
354
|
+
if (confirm !== true) throw new TypeError("Seller creation requires explicit confirmation");
|
|
355
|
+
if (!accessToken) return this.sellerAuthorizationRequired();
|
|
356
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || !("termsAccepted" in body) || body.termsAccepted !== true) throw new TypeError("Listing JSON must explicitly include termsAccepted: true after seller approval");
|
|
357
|
+
try {
|
|
358
|
+
return await this.agentRequest("/api/sell/listings", body, accessToken);
|
|
359
|
+
} catch {
|
|
360
|
+
return { success: false, status: "unknown", error: { code: "SELLER_CREATE_UNCONFIRMED", message: "Listing creation was not confirmed." }, action: "Read sell list and reconcile before retrying. Submission does not guarantee a live listing." };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
148
363
|
// ── Checkout Link ──────────────────────────────────────────────────────────
|
|
149
364
|
/**
|
|
150
365
|
* Create a checkout link for a listing.
|
|
@@ -155,26 +370,141 @@ var TixBitClient = class {
|
|
|
155
370
|
* @example
|
|
156
371
|
* ```ts
|
|
157
372
|
* const link = client.createCheckoutLink({
|
|
158
|
-
* listingId: "
|
|
373
|
+
* listingId: "LISTING123",
|
|
159
374
|
* quantity: 2,
|
|
160
375
|
* });
|
|
161
376
|
*
|
|
162
377
|
* console.log(link.url);
|
|
163
|
-
* // → "https://tixbit.com/checkout/process?listing=
|
|
378
|
+
* // → "https://www.tixbit.com/checkout/process?listing=LISTING123&quantity=2"
|
|
164
379
|
* ```
|
|
165
380
|
*/
|
|
166
381
|
createCheckoutLink(params) {
|
|
167
|
-
const
|
|
168
|
-
|
|
382
|
+
const listingId = params.listingId;
|
|
383
|
+
if (!/^[A-Za-z0-9_-]{2,50}$/.test(listingId)) {
|
|
384
|
+
throw new TypeError(
|
|
385
|
+
"listingId must be 2-50 letters, numbers, hyphens, or underscores"
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (!Number.isInteger(params.quantity) || params.quantity < 1 || params.quantity > 8) {
|
|
389
|
+
throw new RangeError("quantity must be an integer from 1 to 8");
|
|
390
|
+
}
|
|
391
|
+
const url = `${this.baseUrl}/checkout/process?listing=${encodeURIComponent(listingId)}&quantity=${params.quantity}`;
|
|
169
392
|
return {
|
|
170
393
|
url,
|
|
171
|
-
listingId
|
|
172
|
-
quantity
|
|
394
|
+
listingId,
|
|
395
|
+
quantity: params.quantity
|
|
173
396
|
};
|
|
174
397
|
}
|
|
398
|
+
// ── Machine checkout ─────────────────────────────────────────────────────
|
|
399
|
+
/**
|
|
400
|
+
* Purchase tickets through the public MPP endpoint.
|
|
401
|
+
*
|
|
402
|
+
* Configure `paymentFetch` with the official mppx client to automatically
|
|
403
|
+
* answer the server's 402 challenge. Reuse the returned idempotency key for
|
|
404
|
+
* any recovery check after a timeout or pending result.
|
|
405
|
+
*/
|
|
406
|
+
async purchaseTickets(params) {
|
|
407
|
+
const { listingId, quantity, email, idempotencyKey, name } = normalizePurchaseTicketsParams(params);
|
|
408
|
+
const controller = new AbortController();
|
|
409
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
410
|
+
try {
|
|
411
|
+
const response = await this.paymentFetch(this.paymentEndpoint, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
redirect: "error",
|
|
414
|
+
headers: {
|
|
415
|
+
Accept: "application/json",
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
"User-Agent": USER_AGENT
|
|
418
|
+
},
|
|
419
|
+
body: JSON.stringify({
|
|
420
|
+
listingId,
|
|
421
|
+
quantity,
|
|
422
|
+
email,
|
|
423
|
+
idempotencyKey,
|
|
424
|
+
...name ? { name } : {}
|
|
425
|
+
}),
|
|
426
|
+
signal: controller.signal
|
|
427
|
+
});
|
|
428
|
+
const body = await response.json().catch(() => ({}));
|
|
429
|
+
const orderReference = normalizeOrderReference(body.orderReference);
|
|
430
|
+
const receiptUrl = normalizeReceiptUrl(body.receiptUrl);
|
|
431
|
+
if (response.status === 402) {
|
|
432
|
+
const order = normalizePurchaseOrder(
|
|
433
|
+
body.order,
|
|
434
|
+
orderReference,
|
|
435
|
+
"payment_required"
|
|
436
|
+
);
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
status: "payment_required",
|
|
440
|
+
idempotencyKey,
|
|
441
|
+
orderReference,
|
|
442
|
+
receiptUrl,
|
|
443
|
+
...order ? { order } : {},
|
|
444
|
+
action: "Configure the official mppx payment client, then retry with the same idempotency key."
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (isPurchaseStatus(body.status)) {
|
|
448
|
+
if (body.status === "fulfilled" && response.ok && body.success === true && !orderReference) {
|
|
449
|
+
return {
|
|
450
|
+
success: false,
|
|
451
|
+
status: "pending",
|
|
452
|
+
idempotencyKey,
|
|
453
|
+
orderReference: null,
|
|
454
|
+
receiptUrl,
|
|
455
|
+
error: {
|
|
456
|
+
code: "PURCHASE_OUTCOME_AMBIGUOUS",
|
|
457
|
+
message: "The purchase response did not include an order reference."
|
|
458
|
+
},
|
|
459
|
+
action: "Retry with the same idempotency key. Do not create a new payment or purchase attempt."
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (body.status === "fulfilled" && (!response.ok || body.success !== true)) {
|
|
463
|
+
return rejectedPurchaseResult(
|
|
464
|
+
idempotencyKey,
|
|
465
|
+
orderReference,
|
|
466
|
+
receiptUrl,
|
|
467
|
+
response.status
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const order = normalizePurchaseOrder(body.order, orderReference, body.status);
|
|
471
|
+
return {
|
|
472
|
+
success: body.status === "fulfilled",
|
|
473
|
+
status: body.status,
|
|
474
|
+
idempotencyKey,
|
|
475
|
+
orderReference,
|
|
476
|
+
receiptUrl,
|
|
477
|
+
...order ? { order } : {},
|
|
478
|
+
...body.status === "manual_review_required" ? { paid: true } : {},
|
|
479
|
+
...safePurchaseOutcomeDetails(body.status)
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return rejectedPurchaseResult(
|
|
483
|
+
idempotencyKey,
|
|
484
|
+
orderReference,
|
|
485
|
+
receiptUrl,
|
|
486
|
+
response.status
|
|
487
|
+
);
|
|
488
|
+
} catch {
|
|
489
|
+
return {
|
|
490
|
+
success: false,
|
|
491
|
+
status: "pending",
|
|
492
|
+
idempotencyKey,
|
|
493
|
+
orderReference: null,
|
|
494
|
+
receiptUrl: null,
|
|
495
|
+
error: {
|
|
496
|
+
code: controller.signal.aborted ? "PURCHASE_TIMEOUT" : "PURCHASE_NETWORK_ERROR",
|
|
497
|
+
message: controller.signal.aborted ? `Purchase status is unknown after ${this.timeoutMs}ms.` : "Purchase status is unknown because the request did not complete."
|
|
498
|
+
},
|
|
499
|
+
action: "Retry with the same idempotency key. Do not create a new payment or purchase attempt."
|
|
500
|
+
};
|
|
501
|
+
} finally {
|
|
502
|
+
clearTimeout(timer);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
175
505
|
// ── Event URL helper ──────────────────────────────────────────────────────
|
|
176
506
|
/**
|
|
177
|
-
* Build the direct URL to an event page on tixbit.com.
|
|
507
|
+
* Build the direct URL to an event page on www.tixbit.com.
|
|
178
508
|
*/
|
|
179
509
|
eventUrl(slugOrId) {
|
|
180
510
|
if (slugOrId.includes("/")) {
|
|
@@ -193,7 +523,7 @@ var TixBitClient = class {
|
|
|
193
523
|
*
|
|
194
524
|
* @example
|
|
195
525
|
* ```ts
|
|
196
|
-
* const seatmap = await client.getSeatmap({ eventId: "
|
|
526
|
+
* const seatmap = await client.getSeatmap({ eventId: "EVENT123" });
|
|
197
527
|
* console.log(seatmap.venue_name); // "State Farm Arena"
|
|
198
528
|
* console.log(seatmap.section_names); // ["101", "102", ...]
|
|
199
529
|
* ```
|
|
@@ -273,6 +603,129 @@ var TixBitClient = class {
|
|
|
273
603
|
}
|
|
274
604
|
}
|
|
275
605
|
};
|
|
606
|
+
function normalizeBuyerEmail(value) {
|
|
607
|
+
const email = value.trim().toLowerCase();
|
|
608
|
+
const match = email.match(/^([^\s@]{1,64})@([^\s@]{1,253})$/);
|
|
609
|
+
if (!match || email.length > 254 || match[1].startsWith(".") || match[1].endsWith(".") || match[1].includes("..") || !match[2].includes(".") || match[2].split(".").some(
|
|
610
|
+
(label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)
|
|
611
|
+
)) {
|
|
612
|
+
throw new TypeError("email must be a valid email address");
|
|
613
|
+
}
|
|
614
|
+
return email;
|
|
615
|
+
}
|
|
616
|
+
function normalizePurchaseTicketsParams(params) {
|
|
617
|
+
const listingId = params.listingId;
|
|
618
|
+
if (!/^[A-Za-z0-9_-]{2,100}$/.test(listingId)) {
|
|
619
|
+
throw new TypeError(
|
|
620
|
+
"listingId must be 2-100 letters, numbers, hyphens, or underscores"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (!Number.isInteger(params.quantity) || params.quantity < 1 || params.quantity > 8) {
|
|
624
|
+
throw new RangeError("quantity must be an integer from 1 to 8");
|
|
625
|
+
}
|
|
626
|
+
const email = normalizeBuyerEmail(params.email);
|
|
627
|
+
const idempotencyKey = params.idempotencyKey ?? crypto.randomUUID();
|
|
628
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
629
|
+
idempotencyKey
|
|
630
|
+
)) {
|
|
631
|
+
throw new TypeError("idempotencyKey must be an unguessable UUID v4");
|
|
632
|
+
}
|
|
633
|
+
const name = params.name?.trim();
|
|
634
|
+
if (name && name.length > 120) {
|
|
635
|
+
throw new TypeError("name must be at most 120 characters");
|
|
636
|
+
}
|
|
637
|
+
return { listingId, quantity: params.quantity, email, idempotencyKey, name };
|
|
638
|
+
}
|
|
639
|
+
function normalizePurchaseOrder(value, orderReference, status) {
|
|
640
|
+
if (!value || typeof value !== "object" || !orderReference) return void 0;
|
|
641
|
+
const order = value;
|
|
642
|
+
if (order.reference !== orderReference || order.status !== status || typeof order.listingId !== "string" || !/^[A-Za-z0-9_-]{2,100}$/.test(order.listingId) || !Number.isInteger(order.quantity) || order.quantity < 1 || order.quantity > 8 || !isNonNegativeNumber(order.pricePerTicket) || !isNonNegativeNumber(order.subtotal) || !isNonNegativeNumber(order.fees) || !isNonNegativeNumber(order.total) || typeof order.currency !== "string" || !/^[A-Z]{3}$/.test(order.currency)) {
|
|
643
|
+
return void 0;
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
reference: orderReference,
|
|
647
|
+
status,
|
|
648
|
+
listingId: order.listingId,
|
|
649
|
+
quantity: order.quantity,
|
|
650
|
+
pricePerTicket: order.pricePerTicket,
|
|
651
|
+
subtotal: order.subtotal,
|
|
652
|
+
fees: order.fees,
|
|
653
|
+
total: order.total,
|
|
654
|
+
currency: order.currency,
|
|
655
|
+
...typeof order.section === "string" && order.section.length <= 120 ? { section: order.section } : {},
|
|
656
|
+
...typeof order.row === "string" && order.row.length <= 120 ? { row: order.row } : {}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
function isNonNegativeNumber(value) {
|
|
660
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
661
|
+
}
|
|
662
|
+
function normalizeOrderReference(value) {
|
|
663
|
+
return typeof value === "string" && /^TBM-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{10}$/.test(value) ? value : null;
|
|
664
|
+
}
|
|
665
|
+
function normalizeReceiptUrl(value) {
|
|
666
|
+
if (typeof value !== "string") return null;
|
|
667
|
+
try {
|
|
668
|
+
const url = new URL(value);
|
|
669
|
+
return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null;
|
|
670
|
+
} catch {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function safePurchaseOutcomeDetails(status) {
|
|
675
|
+
switch (status) {
|
|
676
|
+
case "pending":
|
|
677
|
+
return {
|
|
678
|
+
error: {
|
|
679
|
+
code: "PURCHASE_PENDING",
|
|
680
|
+
message: "Purchase status is still pending."
|
|
681
|
+
},
|
|
682
|
+
action: "Retry with the same idempotency key. Do not create a new payment or purchase attempt."
|
|
683
|
+
};
|
|
684
|
+
case "manual_review_required":
|
|
685
|
+
return {
|
|
686
|
+
error: {
|
|
687
|
+
code: "FULFILLMENT_REVIEW_REQUIRED",
|
|
688
|
+
message: "Payment was received, but fulfillment requires manual review."
|
|
689
|
+
},
|
|
690
|
+
action: "Do not submit another payment or purchase. Contact TixBit support with the order reference."
|
|
691
|
+
};
|
|
692
|
+
case "payment_failed":
|
|
693
|
+
return {
|
|
694
|
+
error: {
|
|
695
|
+
code: "PAYMENT_VERIFICATION_FAILED",
|
|
696
|
+
message: "Payment verification failed."
|
|
697
|
+
},
|
|
698
|
+
action: "Obtain a new MPP credential for the same idempotency key."
|
|
699
|
+
};
|
|
700
|
+
case "rejected":
|
|
701
|
+
return {
|
|
702
|
+
error: {
|
|
703
|
+
code: "PURCHASE_REJECTED",
|
|
704
|
+
message: "The purchase request was rejected."
|
|
705
|
+
},
|
|
706
|
+
action: "Correct the request and retry with the same idempotency key."
|
|
707
|
+
};
|
|
708
|
+
default:
|
|
709
|
+
return {};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function rejectedPurchaseResult(idempotencyKey, orderReference, receiptUrl, httpStatus) {
|
|
713
|
+
return {
|
|
714
|
+
success: false,
|
|
715
|
+
status: "rejected",
|
|
716
|
+
idempotencyKey,
|
|
717
|
+
orderReference,
|
|
718
|
+
receiptUrl,
|
|
719
|
+
error: {
|
|
720
|
+
code: `HTTP_${httpStatus}`,
|
|
721
|
+
message: "Purchase request was rejected or returned an invalid response."
|
|
722
|
+
},
|
|
723
|
+
action: "Review the request and retry with the same idempotency key if appropriate."
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function isPurchaseStatus(value) {
|
|
727
|
+
return value === "fulfilled" || value === "payment_required" || value === "pending" || value === "manual_review_required" || value === "payment_failed" || value === "rejected";
|
|
728
|
+
}
|
|
276
729
|
function normalizeEvent(raw) {
|
|
277
730
|
const e = raw;
|
|
278
731
|
const rawId = str(e.external_event_id) ?? str(e.externalEventId) ?? str(e.id) ?? "";
|
|
@@ -286,9 +739,12 @@ function normalizeEvent(raw) {
|
|
|
286
739
|
venue_name: str(e.venue_name) ?? str(e.venueName) ?? null,
|
|
287
740
|
venue_city: str(e.venue_city) ?? str(e.venueCity) ?? null,
|
|
288
741
|
venue_state: str(e.venue_state) ?? str(e.venueState) ?? null,
|
|
742
|
+
venue_country: str(e.venue_country) ?? str(e.venueCountry) ?? null,
|
|
289
743
|
image_url: str(e.image_url) ?? str(e.imageUrl) ?? null,
|
|
744
|
+
category_slug: str(e.category_slug) ?? str(e.categorySlug) ?? null,
|
|
290
745
|
category_name: str(e.category_name) ?? str(e.categoryName) ?? null,
|
|
291
746
|
category_event_type: str(e.category_event_type) ?? str(e.categoryEventType) ?? null,
|
|
747
|
+
metadata: e.metadata && typeof e.metadata === "object" ? e.metadata : null,
|
|
292
748
|
has_listings: Boolean(e.has_listings),
|
|
293
749
|
inventory: normalizeInventory(e.inventory)
|
|
294
750
|
};
|
|
@@ -298,10 +754,17 @@ function normalizeInventory(raw) {
|
|
|
298
754
|
return { total_available: 0, min_price: 0, max_price: 0 };
|
|
299
755
|
}
|
|
300
756
|
const inv = raw;
|
|
757
|
+
const sources = inv.sources;
|
|
301
758
|
return {
|
|
302
759
|
total_available: num(inv.total_available) ?? 0,
|
|
303
760
|
min_price: num(inv.min_price) ?? 0,
|
|
304
|
-
max_price: num(inv.max_price) ?? 0
|
|
761
|
+
max_price: num(inv.max_price) ?? 0,
|
|
762
|
+
...sources ? {
|
|
763
|
+
sources: {
|
|
764
|
+
local: num(sources.local) ?? 0,
|
|
765
|
+
marketplace: num(sources.marketplace) ?? 0
|
|
766
|
+
}
|
|
767
|
+
} : {}
|
|
305
768
|
};
|
|
306
769
|
}
|
|
307
770
|
function normalizeListing(raw) {
|
|
@@ -309,16 +772,30 @@ function normalizeListing(raw) {
|
|
|
309
772
|
const attrs = outer.attributes ?? outer;
|
|
310
773
|
return {
|
|
311
774
|
id: str(outer.id) ?? str(attrs.id) ?? "",
|
|
775
|
+
event_id: str(attrs.event_id) ?? null,
|
|
312
776
|
// price_per_ticket is the fee-inclusive per-ticket price (in dollars, not cents)
|
|
313
777
|
price: num(attrs.price_per_ticket) ?? num(attrs.price) ?? 0,
|
|
778
|
+
total_price: num(attrs.total_price),
|
|
779
|
+
base_price_per_ticket: num(attrs.base_price_per_ticket),
|
|
780
|
+
base_total_price: num(attrs.base_total_price),
|
|
781
|
+
face_value: num(attrs.face_value),
|
|
782
|
+
fee_amount_per_ticket: num(attrs.fee_amount_per_ticket),
|
|
783
|
+
fee_amount_total: num(attrs.fee_amount_total),
|
|
784
|
+
fee_percent: num(attrs.fee_percent),
|
|
785
|
+
currency: str(attrs.currency) ?? "USD",
|
|
314
786
|
quantity: num(attrs.quantity) ?? num(attrs.available_quantity) ?? 0,
|
|
315
787
|
quantities_list: Array.isArray(attrs.quantities_list) ? attrs.quantities_list : [],
|
|
316
788
|
section: str(attrs.section) ?? null,
|
|
317
789
|
row: str(attrs.row) ?? null,
|
|
790
|
+
seat_from: str(attrs.seat_from) ?? null,
|
|
791
|
+
seat_to: str(attrs.seat_to) ?? null,
|
|
318
792
|
seat_numbers: str(attrs.seat_numbers) ?? null,
|
|
319
793
|
listing_hash: str(attrs.listing_hash) ?? "",
|
|
320
794
|
notes: str(attrs.notes) ?? null,
|
|
321
795
|
delivery_method: str(attrs.delivery_method) ?? str(attrs.delivery_type) ?? null,
|
|
796
|
+
in_hand_date: str(attrs.in_hand_date) ?? null,
|
|
797
|
+
stock_type: str(attrs.stock_type) ?? null,
|
|
798
|
+
disclosure_ids: Array.isArray(attrs.disclosure_ids) ? attrs.disclosure_ids.filter((id) => typeof id === "string") : [],
|
|
322
799
|
splits: Array.isArray(attrs.splits) ? attrs.splits : [],
|
|
323
800
|
raw: outer
|
|
324
801
|
};
|
|
@@ -341,6 +818,23 @@ function str(v) {
|
|
|
341
818
|
function num(v) {
|
|
342
819
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
343
820
|
}
|
|
821
|
+
function readBoolean(value) {
|
|
822
|
+
return typeof value === "boolean" ? value : null;
|
|
823
|
+
}
|
|
824
|
+
function readNullableString(value) {
|
|
825
|
+
return value === null ? null : typeof value === "string" ? value : void 0;
|
|
826
|
+
}
|
|
827
|
+
function readEnum(value, choices) {
|
|
828
|
+
return typeof value === "string" && choices.includes(value) ? value : void 0;
|
|
829
|
+
}
|
|
830
|
+
function readListingMetaNumber(meta, ...keys) {
|
|
831
|
+
if (!meta) return null;
|
|
832
|
+
for (const key of keys) {
|
|
833
|
+
const value = num(meta[key]);
|
|
834
|
+
if (value !== null) return value;
|
|
835
|
+
}
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
344
838
|
var TixBitApiError = class extends Error {
|
|
345
839
|
constructor(message, status, url) {
|
|
346
840
|
super(message);
|
|
@@ -349,13 +843,287 @@ var TixBitApiError = class extends Error {
|
|
|
349
843
|
this.name = "TixBitApiError";
|
|
350
844
|
}
|
|
351
845
|
};
|
|
846
|
+
var TixBitTimeoutError = class extends Error {
|
|
847
|
+
constructor(url, timeoutMs) {
|
|
848
|
+
super(`Request timed out after ${timeoutMs}ms: ${url}`);
|
|
849
|
+
this.url = url;
|
|
850
|
+
this.timeoutMs = timeoutMs;
|
|
851
|
+
this.name = "TixBitTimeoutError";
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
// src/link.ts
|
|
856
|
+
import { execFile } from "child_process";
|
|
857
|
+
import { randomUUID } from "crypto";
|
|
858
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
859
|
+
import { homedir } from "os";
|
|
860
|
+
import { dirname, join } from "path";
|
|
861
|
+
import { createRequire } from "module";
|
|
862
|
+
import { promisify } from "util";
|
|
863
|
+
import { Challenge } from "mppx";
|
|
864
|
+
import { Mppx, stripe } from "mppx/client";
|
|
865
|
+
var execFileAsync = promisify(execFile);
|
|
866
|
+
var PAYMENT_URL = "https://mcp.tixbit.com/api/purchase";
|
|
867
|
+
var ORDER_REFERENCE = /^TBM-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{10}$/;
|
|
868
|
+
var NETWORK_ID = /^profile_[A-Za-z0-9]+$/;
|
|
869
|
+
var SPEND_REQUEST_ID = /^lsrq_[A-Za-z0-9]+$/;
|
|
870
|
+
var require2 = createRequire(import.meta.url);
|
|
871
|
+
function linkCliEntry() {
|
|
872
|
+
if (Number(process.versions.node.split(".")[0]) < 22) {
|
|
873
|
+
throw new LinkCheckoutError("LINK_NODE_UNSUPPORTED", "TixBit 0.2.0 needs Node.js 22 or later.");
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
return join(dirname(require2.resolve("@stripe/link-cli/package.json")), "dist", "cli.js");
|
|
877
|
+
} catch {
|
|
878
|
+
throw new LinkCheckoutError("LINK_CLI_MISSING", "The bundled Link CLI is missing. Reinstall TixBit, then retry.");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
var LinkCheckoutError = class extends Error {
|
|
882
|
+
constructor(code, message) {
|
|
883
|
+
super(message);
|
|
884
|
+
this.code = code;
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
function paymentEndpoint() {
|
|
888
|
+
const endpoint = process.env.TIXBIT_PAYMENT_URL ?? PAYMENT_URL;
|
|
889
|
+
new TixBitClient({ paymentEndpoint: endpoint });
|
|
890
|
+
return endpoint;
|
|
891
|
+
}
|
|
892
|
+
function statePath(orderReference) {
|
|
893
|
+
if (!ORDER_REFERENCE.test(orderReference)) throw new LinkCheckoutError("INVALID_ORDER_REFERENCE", "Enter the order reference shown by link start.");
|
|
894
|
+
const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
895
|
+
return join(base, "tixbit", "link", `${orderReference}.json`);
|
|
896
|
+
}
|
|
897
|
+
async function saveState(state) {
|
|
898
|
+
const path = statePath(state.orderReference);
|
|
899
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
900
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
901
|
+
await writeFile(temporary, JSON.stringify(state), { mode: 384, flag: "wx" });
|
|
902
|
+
await rename(temporary, path);
|
|
903
|
+
}
|
|
904
|
+
async function loadState(orderReference) {
|
|
905
|
+
let value;
|
|
906
|
+
try {
|
|
907
|
+
value = JSON.parse(await readFile(statePath(orderReference), "utf8"));
|
|
908
|
+
} catch {
|
|
909
|
+
throw new LinkCheckoutError("CHECKOUT_NOT_FOUND", "This Link checkout was not found on this device.");
|
|
910
|
+
}
|
|
911
|
+
if (value.version !== 1 || value.orderReference !== orderReference || !SPEND_REQUEST_ID.test(value.spendRequestId) || !NETWORK_ID.test(value.networkId) || !Number.isSafeInteger(value.amountCents) || value.amountCents <= 0 || typeof value.externalId !== "string" || !value.externalId || typeof value.requestBody !== "string") {
|
|
912
|
+
throw new LinkCheckoutError("INVALID_CHECKOUT_STATE", "The saved Link checkout is invalid. Contact TixBit support before starting another purchase.");
|
|
913
|
+
}
|
|
914
|
+
return value;
|
|
915
|
+
}
|
|
916
|
+
async function linkCli(args) {
|
|
917
|
+
let stdout;
|
|
918
|
+
const entry = linkCliEntry();
|
|
919
|
+
try {
|
|
920
|
+
const result = await execFileAsync(process.execPath, [entry, ...args, "--format", "json"], {
|
|
921
|
+
timeout: 3e4,
|
|
922
|
+
maxBuffer: 1024 * 1024,
|
|
923
|
+
windowsHide: true
|
|
924
|
+
});
|
|
925
|
+
stdout = typeof result === "string" ? result : result.stdout;
|
|
926
|
+
} catch {
|
|
927
|
+
throw new LinkCheckoutError("LINK_CLI_FAILED", "Link could not complete this step. Run `npx @stripe/link-cli auth login`, then retry with the same order reference.");
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
const parsed = JSON.parse(stdout);
|
|
931
|
+
const data = parsed && typeof parsed === "object" && !Array.isArray(parsed) && "data" in parsed ? parsed.data : parsed;
|
|
932
|
+
const item = Array.isArray(data) ? data[0] : data;
|
|
933
|
+
if (item && typeof item === "object") return item;
|
|
934
|
+
} catch {
|
|
935
|
+
}
|
|
936
|
+
throw new LinkCheckoutError("LINK_CLI_RESPONSE_INVALID", "Link returned an invalid response. No payment was submitted.");
|
|
937
|
+
}
|
|
938
|
+
function stripeChallenge(response, maxAmountCents) {
|
|
939
|
+
let challenges;
|
|
940
|
+
try {
|
|
941
|
+
challenges = Challenge.fromResponseList(response);
|
|
942
|
+
} catch {
|
|
943
|
+
throw new LinkCheckoutError("STRIPE_CHALLENGE_MISSING", "TixBit did not offer a valid Stripe Link payment challenge.");
|
|
944
|
+
}
|
|
945
|
+
const challenge = challenges.find((item) => item.method === "stripe" && item.intent === "charge");
|
|
946
|
+
const request = challenge?.request;
|
|
947
|
+
const amount = Number(request?.amount);
|
|
948
|
+
const details = request?.methodDetails;
|
|
949
|
+
if (!challenge || request?.currency !== "usd" || !Number.isSafeInteger(amount) || amount <= 0 || amount > maxAmountCents || typeof details?.networkId !== "string" || !NETWORK_ID.test(details.networkId) || typeof request.externalId !== "string" || !request.externalId) {
|
|
950
|
+
throw new LinkCheckoutError("STRIPE_CHALLENGE_UNSAFE", "The Stripe challenge is missing order details or exceeds your total price limit.");
|
|
951
|
+
}
|
|
952
|
+
return { amountCents: amount, networkId: details.networkId, externalId: request.externalId };
|
|
953
|
+
}
|
|
954
|
+
function approvalUrl(value) {
|
|
955
|
+
if (typeof value !== "string") throw new LinkCheckoutError("LINK_APPROVAL_MISSING", "Link did not return an approval link.");
|
|
956
|
+
let url;
|
|
957
|
+
try {
|
|
958
|
+
url = new URL(value);
|
|
959
|
+
} catch {
|
|
960
|
+
throw new LinkCheckoutError("LINK_APPROVAL_INVALID", "Link returned an unexpected approval link.");
|
|
961
|
+
}
|
|
962
|
+
if (url.origin !== "https://app.link.com" || !url.pathname.startsWith("/activity/approve/")) {
|
|
963
|
+
throw new LinkCheckoutError("LINK_APPROVAL_INVALID", "Link returned an unexpected approval link.");
|
|
964
|
+
}
|
|
965
|
+
return url.toString();
|
|
966
|
+
}
|
|
967
|
+
async function startLinkPurchase(input) {
|
|
968
|
+
linkCliEntry();
|
|
969
|
+
const purchase = normalizePurchaseTicketsParams({ ...input, idempotencyKey: randomUUID() });
|
|
970
|
+
if (!Number.isSafeInteger(input.maxAmountCents) || input.maxAmountCents <= 0) {
|
|
971
|
+
throw new LinkCheckoutError("INVALID_MAX_PRICE", "Set a positive total price limit.");
|
|
972
|
+
}
|
|
973
|
+
const requestBody = JSON.stringify(purchase);
|
|
974
|
+
const response = await fetch(paymentEndpoint(), {
|
|
975
|
+
method: "POST",
|
|
976
|
+
redirect: "error",
|
|
977
|
+
headers: { "content-type": "application/json", "accept-payment": "stripe/charge" },
|
|
978
|
+
body: requestBody,
|
|
979
|
+
signal: AbortSignal.timeout(3e4)
|
|
980
|
+
});
|
|
981
|
+
if (response.status !== 402) throw new LinkCheckoutError("PURCHASE_PREPARE_FAILED", `TixBit could not prepare this checkout (HTTP ${response.status}).`);
|
|
982
|
+
const { amountCents, networkId, externalId } = stripeChallenge(response, input.maxAmountCents);
|
|
983
|
+
const body = await response.json();
|
|
984
|
+
if (typeof body.orderReference !== "string" || !ORDER_REFERENCE.test(body.orderReference)) {
|
|
985
|
+
throw new LinkCheckoutError("ORDER_REFERENCE_MISSING", "TixBit did not return an order reference.");
|
|
986
|
+
}
|
|
987
|
+
const context = `Approve one TixBit ticket purchase for listing ${purchase.listingId}, quantity ${purchase.quantity}. The total charge is USD ${(amountCents / 100).toFixed(2)}. This request permits one machine payment for this selected order only.`;
|
|
988
|
+
const created = await linkCli([
|
|
989
|
+
"spend-request",
|
|
990
|
+
"create",
|
|
991
|
+
"--credential-type",
|
|
992
|
+
"shared_payment_token",
|
|
993
|
+
"--network-id",
|
|
994
|
+
networkId,
|
|
995
|
+
"--amount",
|
|
996
|
+
String(amountCents),
|
|
997
|
+
"--currency",
|
|
998
|
+
"usd",
|
|
999
|
+
"--context",
|
|
1000
|
+
context,
|
|
1001
|
+
"--request-approval"
|
|
1002
|
+
]);
|
|
1003
|
+
if (typeof created.id !== "string" || !SPEND_REQUEST_ID.test(created.id)) {
|
|
1004
|
+
throw new LinkCheckoutError("LINK_SPEND_REQUEST_INVALID", "Link did not return a valid spend request.");
|
|
1005
|
+
}
|
|
1006
|
+
const url = approvalUrl(created.approval_url);
|
|
1007
|
+
await saveState({
|
|
1008
|
+
version: 1,
|
|
1009
|
+
orderReference: body.orderReference,
|
|
1010
|
+
requestBody,
|
|
1011
|
+
spendRequestId: created.id,
|
|
1012
|
+
approvalUrl: url,
|
|
1013
|
+
amountCents,
|
|
1014
|
+
networkId,
|
|
1015
|
+
externalId
|
|
1016
|
+
});
|
|
1017
|
+
return {
|
|
1018
|
+
success: false,
|
|
1019
|
+
status: "approval_required",
|
|
1020
|
+
orderReference: body.orderReference,
|
|
1021
|
+
amountCents,
|
|
1022
|
+
approvalUrl: url,
|
|
1023
|
+
action: `Approve in Link, then run: tixbit link complete ${body.orderReference}`
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
async function completeLinkPurchase(orderReference) {
|
|
1027
|
+
const state = await loadState(orderReference);
|
|
1028
|
+
if (state.result?.status === "fulfilled" || state.result?.status === "manual_review_required") return state.result;
|
|
1029
|
+
const approval = await linkCli(["spend-request", "retrieve", state.spendRequestId, "--include", "shared_payment_token"]);
|
|
1030
|
+
if (["denied", "declined", "expired", "canceled", "cancelled"].includes(String(approval.status))) {
|
|
1031
|
+
return {
|
|
1032
|
+
success: false,
|
|
1033
|
+
status: "rejected",
|
|
1034
|
+
orderReference,
|
|
1035
|
+
amountCents: state.amountCents,
|
|
1036
|
+
error: { code: "LINK_APPROVAL_ENDED", message: "The Link spend approval ended without payment." },
|
|
1037
|
+
action: "Start a new Link checkout if you still want this ticket."
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (approval.status !== "approved") {
|
|
1041
|
+
return {
|
|
1042
|
+
success: false,
|
|
1043
|
+
status: "approval_required",
|
|
1044
|
+
orderReference,
|
|
1045
|
+
amountCents: state.amountCents,
|
|
1046
|
+
approvalUrl: state.approvalUrl,
|
|
1047
|
+
action: "Approve this spend request in Link, then run the same complete command."
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const token = approval.shared_payment_token?.id;
|
|
1051
|
+
if (approval.credential_type !== "shared_payment_token" || approval.amount !== state.amountCents || approval.currency !== "usd" || approval.network_id !== state.networkId || typeof token !== "string" || !/^spt_[A-Za-z0-9_]+$/.test(token)) {
|
|
1052
|
+
throw new LinkCheckoutError("LINK_APPROVAL_MISMATCH", "The Link approval does not match this order. No payment was submitted.");
|
|
1053
|
+
}
|
|
1054
|
+
let challengeChanged = false;
|
|
1055
|
+
const payments = Mppx.create({
|
|
1056
|
+
methods: [stripe.charge({ paymentMethod: "link", createToken: async ({ amount, currency, networkId }) => {
|
|
1057
|
+
if (Number(amount) !== state.amountCents || currency !== "usd" || networkId !== state.networkId) {
|
|
1058
|
+
challengeChanged = true;
|
|
1059
|
+
throw new LinkCheckoutError("CHALLENGE_CHANGED", "The payment amount or network changed. No payment was submitted.");
|
|
1060
|
+
}
|
|
1061
|
+
return token;
|
|
1062
|
+
} })],
|
|
1063
|
+
polyfill: false,
|
|
1064
|
+
maxPaymentRetries: 1,
|
|
1065
|
+
onChallenge: async (challenge) => {
|
|
1066
|
+
const request2 = challenge.request;
|
|
1067
|
+
const details = request2.methodDetails;
|
|
1068
|
+
if (challenge.method !== "stripe" || challenge.intent !== "charge" || Number(request2.amount) !== state.amountCents || request2.currency !== "usd" || details?.networkId !== state.networkId || request2.externalId !== state.externalId) {
|
|
1069
|
+
challengeChanged = true;
|
|
1070
|
+
throw new LinkCheckoutError("CHALLENGE_CHANGED", "The order or payment amount changed. No payment was submitted.");
|
|
1071
|
+
}
|
|
1072
|
+
return void 0;
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
let decline = false;
|
|
1076
|
+
const client2 = new TixBitClient({
|
|
1077
|
+
paymentEndpoint: paymentEndpoint(),
|
|
1078
|
+
timeoutMs: 9e4,
|
|
1079
|
+
paymentFetch: async (url, init) => {
|
|
1080
|
+
const response = await payments.fetch(url, { ...init, headers: { ...init?.headers, "accept-payment": "stripe/charge" } });
|
|
1081
|
+
if (response.status === 402) {
|
|
1082
|
+
const body = await response.clone().json().catch(() => ({}));
|
|
1083
|
+
decline = typeof body.detail === "string" && /card was declined/i.test(body.detail);
|
|
1084
|
+
}
|
|
1085
|
+
return response;
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
const request = JSON.parse(state.requestBody);
|
|
1089
|
+
const result = await client2.purchaseTickets(request);
|
|
1090
|
+
if (challengeChanged) throw new LinkCheckoutError("CHALLENGE_CHANGED", "The order or payment amount changed. No payment was submitted.");
|
|
1091
|
+
if (result.orderReference && result.orderReference !== orderReference) {
|
|
1092
|
+
throw new LinkCheckoutError("ORDER_REFERENCE_CHANGED", "TixBit returned a different order reference. Check the order before another payment.");
|
|
1093
|
+
}
|
|
1094
|
+
const output2 = {
|
|
1095
|
+
success: result.success,
|
|
1096
|
+
status: decline ? "payment_failed" : result.status,
|
|
1097
|
+
orderReference,
|
|
1098
|
+
amountCents: state.amountCents,
|
|
1099
|
+
...result.order && !decline ? { order: result.order } : {},
|
|
1100
|
+
...!result.success ? {
|
|
1101
|
+
error: decline ? { code: "CARD_DECLINED", message: "Stripe declined the card selected in Link." } : result.error,
|
|
1102
|
+
action: decline ? "Select another card in Link and start a new spend approval. No ticket was issued." : result.action
|
|
1103
|
+
} : {}
|
|
1104
|
+
};
|
|
1105
|
+
if (output2.status === "fulfilled" || output2.status === "manual_review_required") {
|
|
1106
|
+
state.result = output2;
|
|
1107
|
+
await saveState(state);
|
|
1108
|
+
}
|
|
1109
|
+
return output2;
|
|
1110
|
+
}
|
|
352
1111
|
|
|
353
1112
|
// src/cli.ts
|
|
354
|
-
var client =
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
})
|
|
1113
|
+
var client = (() => {
|
|
1114
|
+
try {
|
|
1115
|
+
return new TixBitClient({ baseUrl: process.env.TIXBIT_BASE_URL });
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
return handleError(error);
|
|
1118
|
+
}
|
|
1119
|
+
})();
|
|
1120
|
+
var packageJson = createRequire2(import.meta.url)("../package.json");
|
|
358
1121
|
function output(data, json) {
|
|
1122
|
+
data = redact(data, [process.env.TIXBIT_LINK_TOKEN ?? "", process.env.TIXBIT_ACCESS_TOKEN ?? "", process.env.MPPX_PRIVATE_KEY ?? ""]);
|
|
1123
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
1124
|
+
data = { success: true, ...data };
|
|
1125
|
+
if (data.success === false) process.exitCode = 2;
|
|
1126
|
+
}
|
|
359
1127
|
if (json) {
|
|
360
1128
|
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
361
1129
|
return;
|
|
@@ -368,6 +1136,30 @@ function output(data, json) {
|
|
|
368
1136
|
}
|
|
369
1137
|
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
370
1138
|
}
|
|
1139
|
+
function outputPurchaseResult(result) {
|
|
1140
|
+
if (result.success && result.order) {
|
|
1141
|
+
process.stdout.write(
|
|
1142
|
+
`
|
|
1143
|
+
Purchase complete
|
|
1144
|
+
|
|
1145
|
+
Order: ${result.orderReference}
|
|
1146
|
+
Tickets: ${result.order.quantity}
|
|
1147
|
+
Total: ${result.order.currency} ${result.order.total.toFixed(2)}
|
|
1148
|
+
` + (result.receiptUrl ? ` Receipt: ${result.receiptUrl}
|
|
1149
|
+
` : "") + "\n"
|
|
1150
|
+
);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
process.stdout.write(
|
|
1154
|
+
`
|
|
1155
|
+
Purchase ${result.status}
|
|
1156
|
+
|
|
1157
|
+
Order: ${result.orderReference ?? "not assigned"}
|
|
1158
|
+
Action: ${result.action ?? "Review the JSON result before retrying."}
|
|
1159
|
+
|
|
1160
|
+
`
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
371
1163
|
function formatItem(item) {
|
|
372
1164
|
if (!item || typeof item !== "object") return String(item);
|
|
373
1165
|
const e = item;
|
|
@@ -403,26 +1195,33 @@ function formatItem(item) {
|
|
|
403
1195
|
return JSON.stringify(item, null, 2);
|
|
404
1196
|
}
|
|
405
1197
|
function handleError(err) {
|
|
406
|
-
const msg = err instanceof
|
|
407
|
-
|
|
408
|
-
`);
|
|
1198
|
+
const msg = err instanceof TypeError || err instanceof RangeError ? err.message : "Request failed. Check input and service availability; do not retry an uncertain write.";
|
|
1199
|
+
output({ success: false, error: { code: "COMMAND_FAILED", message: msg } }, true);
|
|
409
1200
|
process.exit(1);
|
|
410
1201
|
}
|
|
411
|
-
var program = new Command().name("tixbit").description("Search events, browse listings, and buy tickets
|
|
412
|
-
program.command("search [query]").description("Search for events by keyword, city, state, or category").option("--city <city>", "Filter by city").option("--state <state>", "Filter by state (2-letter code)").option("--category <slug>", "Filter by category slug (e.g. nba-basketball)").option("--start-date <date>", "Events on or after this date (ISO)").option("--end-date <date>", "Events on or before this date (ISO)").option("--page <n>", "Page number", "1").option("--size <n>", "Results per page", "10").option("--json", "Output raw JSON (for agents)",
|
|
1202
|
+
var program = new Command().name("tixbit").description("Search events, browse listings, and buy TixBit tickets").version(packageJson.version);
|
|
1203
|
+
program.command("search [query]").description("Search for events by keyword, city, state, or category").option("--city <city>", "Filter by city").option("--state <state>", "Filter by state (2-letter code)").option("--category <slug>", "Filter by category slug (e.g. nba-basketball)").option("--league <league>", "Filter by league abbreviation (e.g. NBA)").option("--event-type <type>", "SPORT, CONCERT, THEATER, or ALL").option("--performer-id <id>", "Filter by public performer ID").option("--venue-id <id>", "Filter by public venue ID").option("--parking <mode>", "Parking filter: exclude, only, or include").option("--near-lat <lat>", "Latitude for manual location filtering").option("--near-lng <lng>", "Longitude for manual location filtering").option("--location-mode <mode>", "Location mode: inferred, manual, or none").option("--start-date <date>", "Events on or after this date (ISO)").option("--end-date <date>", "Events on or before this date (ISO)").option("--page <n>", "Page number", "1").option("--size <n>", "Results per page", "10").option("--json", "Output raw JSON (for agents)", true).action(async (query, opts) => {
|
|
413
1204
|
try {
|
|
414
1205
|
const params = {
|
|
415
1206
|
query,
|
|
416
1207
|
city: opts.city,
|
|
417
1208
|
state: opts.state,
|
|
418
1209
|
category: opts.category,
|
|
1210
|
+
league: opts.league,
|
|
1211
|
+
categoryEventType: opts.eventType,
|
|
1212
|
+
performerId: opts.performerId,
|
|
1213
|
+
venueId: opts.venueId,
|
|
1214
|
+
parkingFilter: opts.parking,
|
|
1215
|
+
nearLat: opts.nearLat ? parseFloat(opts.nearLat) : void 0,
|
|
1216
|
+
nearLng: opts.nearLng ? parseFloat(opts.nearLng) : void 0,
|
|
1217
|
+
locationMode: opts.locationMode,
|
|
419
1218
|
startDate: opts.startDate,
|
|
420
1219
|
endDate: opts.endDate,
|
|
421
1220
|
page: parseInt(opts.page, 10),
|
|
422
1221
|
size: parseInt(opts.size, 10)
|
|
423
1222
|
};
|
|
424
1223
|
const result = await client.searchEvents(params);
|
|
425
|
-
const isJson = opts.json
|
|
1224
|
+
const isJson = opts.json;
|
|
426
1225
|
if (isJson) {
|
|
427
1226
|
output(result, true);
|
|
428
1227
|
} else {
|
|
@@ -439,7 +1238,7 @@ Found ${pagination.total} event(s) \u2014 page ${pagination.page}/${pagination.t
|
|
|
439
1238
|
handleError(err);
|
|
440
1239
|
}
|
|
441
1240
|
});
|
|
442
|
-
program.command("browse").description("Browse upcoming events near a location").option("--city <city>", "Preferred city").option("--state <state>", "Preferred state (2-letter code)").option("--lat <lat>", "Latitude").option("--lng <lng>", "Longitude").option("--category <type>", "SPORT, CONCERT, THEATER, or ALL", "ALL").option("--size <n>", "Number of results", "10").option("--json", "Output raw JSON (for agents)",
|
|
1241
|
+
program.command("browse").description("Browse upcoming events near a location").option("--city <city>", "Preferred city").option("--state <state>", "Preferred state (2-letter code)").option("--lat <lat>", "Latitude").option("--lng <lng>", "Longitude").option("--category <type>", "SPORT, CONCERT, THEATER, or ALL", "ALL").option("--category-slug <slug>", "Filter by category slug").option("--query <query>", "Filter by search query").option("--league <league>", "Filter by league abbreviation").option("--date <date>", "Filter by event date (YYYY-MM-DD)").option("--page <n>", "Page number", "1").option("--context <context>", "homepage, events, or category", "homepage").option("--recommendation <kind>", "upcoming or trending", "upcoming").option("--parking <mode>", "Parking filter: exclude, only, or include").option("--location-mode <mode>", "Location mode: inferred, manual, or none").option("--size <n>", "Number of results", "10").option("--json", "Output raw JSON (for agents)", true).action(async (opts) => {
|
|
443
1242
|
try {
|
|
444
1243
|
const params = {
|
|
445
1244
|
city: opts.city,
|
|
@@ -447,10 +1246,19 @@ program.command("browse").description("Browse upcoming events near a location").
|
|
|
447
1246
|
latitude: opts.lat ? parseFloat(opts.lat) : void 0,
|
|
448
1247
|
longitude: opts.lng ? parseFloat(opts.lng) : void 0,
|
|
449
1248
|
categoryEventType: opts.category ?? "ALL",
|
|
1249
|
+
category: opts.categorySlug,
|
|
1250
|
+
query: opts.query,
|
|
1251
|
+
league: opts.league,
|
|
1252
|
+
date: opts.date,
|
|
1253
|
+
page: parseInt(opts.page, 10),
|
|
1254
|
+
context: opts.context,
|
|
1255
|
+
recommendation: opts.recommendation,
|
|
1256
|
+
parkingFilter: opts.parking,
|
|
1257
|
+
locationMode: opts.locationMode,
|
|
450
1258
|
size: parseInt(opts.size, 10)
|
|
451
1259
|
};
|
|
452
1260
|
const result = await client.browse(params);
|
|
453
|
-
const isJson = opts.json
|
|
1261
|
+
const isJson = opts.json;
|
|
454
1262
|
if (isJson) {
|
|
455
1263
|
output(result, true);
|
|
456
1264
|
} else {
|
|
@@ -466,16 +1274,18 @@ ${result.events.length} upcoming event(s) near ${opts.city ?? "you"}
|
|
|
466
1274
|
handleError(err);
|
|
467
1275
|
}
|
|
468
1276
|
});
|
|
469
|
-
program.command("listings <eventId>").description("Get available ticket listings for an event").option("--size <n>", "Results per page", "20").option("--page <n>", "Page number", "1").option("--sort <dir>", "Price sort: asc or desc", "asc").option("--json", "Output raw JSON (for agents)",
|
|
1277
|
+
program.command("listings <eventId>").description("Get available ticket listings for an event").option("--size <n>", "Results per page", "20").option("--page <n>", "Page number", "1").option("--sort <dir>", "Price sort: asc or desc", "asc").option("--all", "Return all available listings", false).option("--refresh", "Bypass the listings cache", false).option("--json", "Output raw JSON (for agents)", true).action(async (eventId, opts) => {
|
|
470
1278
|
try {
|
|
471
1279
|
const params = {
|
|
472
1280
|
eventId,
|
|
473
1281
|
size: parseInt(opts.size, 10),
|
|
474
1282
|
page: parseInt(opts.page, 10),
|
|
475
|
-
orderByDirection: opts.sort
|
|
1283
|
+
orderByDirection: opts.sort,
|
|
1284
|
+
includeAll: opts.all,
|
|
1285
|
+
refresh: opts.refresh
|
|
476
1286
|
};
|
|
477
1287
|
const result = await client.getListings(params);
|
|
478
|
-
const isJson = opts.json
|
|
1288
|
+
const isJson = opts.json;
|
|
479
1289
|
if (isJson) {
|
|
480
1290
|
output(result, true);
|
|
481
1291
|
} else {
|
|
@@ -491,25 +1301,68 @@ ${result.listings.length} listing(s) for event ${eventId}
|
|
|
491
1301
|
handleError(err);
|
|
492
1302
|
}
|
|
493
1303
|
});
|
|
494
|
-
program.command("
|
|
1304
|
+
program.command("quote <eventId>").description("Get refreshed live listings; not a reservation or offer").option("--page <n>", "Page number", "1").option("--size <n>", "Page size (max 100)", "100").option("--json", "Output JSON", true).action(async (eventId, opts) => {
|
|
495
1305
|
try {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1306
|
+
output(await client.quoteListings({ eventId, page: positiveInteger(opts.page, "--page", 1e4), size: positiveInteger(opts.size, "--size", 100) }), true);
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
handleError(error);
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
program.command("auth").description("Show public browser sign-in and wallet setup; does not create a CLI session").option("--json", "Output JSON", true).action(() => output(client.getAuthorizationInfo(), true));
|
|
1312
|
+
program.command("buy <listingId>").description("Quote or confirm Stripe Link checkout (4-12 alphanumeric listing IDs only)").requiredOption("--quantity <n>", "Number of tickets (1-8)").requiredOption("--max-price <total>", "Maximum TOTAL USD charge including fees").option("--email <email>", "Buyer delivery email (or TIXBIT_EMAIL)").option("--confirm", "Approve payment; return browser handoff if wallet authorization is missing", false).option("--json", "Output JSON", true).action(async (listingId, opts) => {
|
|
1313
|
+
try {
|
|
1314
|
+
output(await client.buyTickets({ listingId, quantity: positiveInteger(opts.quantity, "--quantity", 8), maxAmountCents: usdCents(opts.maxPrice), email: opts.email ?? process.env.TIXBIT_EMAIL ?? "", confirm: opts.confirm, sharedPaymentToken: process.env.TIXBIT_LINK_TOKEN }), true);
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
handleError(error);
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
var sell = program.command("sell").description("Seller browser sign-in or optional authorized user integration");
|
|
1320
|
+
sell.command("list").option("--json", "Output JSON", true).action(async () => {
|
|
1321
|
+
try {
|
|
1322
|
+
output(await client.listSellerListings(process.env.TIXBIT_ACCESS_TOKEN ?? ""), true);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
handleError(error);
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
sell.command("create").option("--confirm", "Approve listing creation and seller terms", false).option("--json", "Output JSON", true).action(async (opts) => {
|
|
1328
|
+
try {
|
|
1329
|
+
if (!opts.confirm) throw new TypeError("sell create requires --confirm and approved listing JSON on stdin");
|
|
1330
|
+
if (!process.env.TIXBIT_ACCESS_TOKEN) {
|
|
1331
|
+
output(await client.listSellerListings(), true);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (process.stdin.isTTY) throw new TypeError("Provide listing JSON on stdin");
|
|
1335
|
+
let input = "";
|
|
1336
|
+
for await (const chunk of process.stdin) {
|
|
1337
|
+
input += chunk.toString();
|
|
1338
|
+
if (Buffer.byteLength(input) > 65536) throw new TypeError("Listing JSON exceeds 64 KiB");
|
|
1339
|
+
}
|
|
1340
|
+
let body;
|
|
1341
|
+
try {
|
|
1342
|
+
body = JSON.parse(input);
|
|
1343
|
+
} catch {
|
|
1344
|
+
throw new TypeError("Invalid listing JSON on stdin");
|
|
1345
|
+
}
|
|
1346
|
+
output(await client.createSellerListing(body, process.env.TIXBIT_ACCESS_TOKEN ?? "", opts.confirm), true);
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
handleError(error);
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
program.command("checkout <listingId>").description("Get a checkout link to buy tickets for a listing").requiredOption("--quantity <n>", "Number of tickets to buy").option("--json", "Output raw JSON", true).action(async (listingId, opts) => {
|
|
1352
|
+
try {
|
|
1353
|
+
const quantity = Number(opts.quantity);
|
|
1354
|
+
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 8) {
|
|
1355
|
+
throw new TypeError("--quantity must be an integer from 1 to 8");
|
|
500
1356
|
}
|
|
501
|
-
const isJson = opts.json === true
|
|
1357
|
+
const isJson = opts.json === true;
|
|
502
1358
|
let listingInfo;
|
|
503
1359
|
try {
|
|
504
|
-
|
|
505
|
-
if (result.listings.length > 0) {
|
|
506
|
-
listingInfo = result.listings.find((l) => l.id === listingId);
|
|
507
|
-
}
|
|
1360
|
+
listingInfo = (await client.getListing(listingId)).listing;
|
|
508
1361
|
} catch {
|
|
509
1362
|
}
|
|
510
|
-
const
|
|
1363
|
+
const link2 = client.createCheckoutLink({ listingId, quantity });
|
|
511
1364
|
if (isJson) {
|
|
512
|
-
output({ ...
|
|
1365
|
+
output({ ...link2, listing: listingInfo ?? null }, true);
|
|
513
1366
|
return;
|
|
514
1367
|
}
|
|
515
1368
|
process.stdout.write("\n\u{1F39F} Checkout Link\n\n");
|
|
@@ -530,7 +1383,7 @@ program.command("checkout <listingId>").description("Get a checkout link to buy
|
|
|
530
1383
|
process.stdout.write(` Quantity: ${quantity}
|
|
531
1384
|
|
|
532
1385
|
`);
|
|
533
|
-
process.stdout.write(` ${
|
|
1386
|
+
process.stdout.write(` ${link2.url}
|
|
534
1387
|
|
|
535
1388
|
`);
|
|
536
1389
|
process.stdout.write(
|
|
@@ -540,10 +1393,120 @@ program.command("checkout <listingId>").description("Get a checkout link to buy
|
|
|
540
1393
|
handleError(err);
|
|
541
1394
|
}
|
|
542
1395
|
});
|
|
543
|
-
program.command("
|
|
1396
|
+
var link = program.command("link").description("Buy tickets with a Stripe Link spend approval");
|
|
1397
|
+
link.command("start <listingId>").description("Prepare a capped purchase and return a Link approval URL").requiredOption("--quantity <n>", "Number of tickets to buy").requiredOption("--email <email>", "Buyer email for ticket delivery").requiredOption("--max-price <total>", "Maximum total USD charge").option("--name <name>", "Optional buyer name").action(async (listingId, opts) => {
|
|
1398
|
+
try {
|
|
1399
|
+
output(await startLinkPurchase({
|
|
1400
|
+
listingId,
|
|
1401
|
+
quantity: positiveInteger(opts.quantity, "--quantity", 8),
|
|
1402
|
+
email: opts.email,
|
|
1403
|
+
name: opts.name,
|
|
1404
|
+
maxAmountCents: usdCents(opts.maxPrice)
|
|
1405
|
+
}), true);
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
const known = error instanceof LinkCheckoutError || error instanceof TypeError || error instanceof RangeError;
|
|
1408
|
+
output({ success: false, status: "rejected", error: {
|
|
1409
|
+
code: error instanceof LinkCheckoutError ? error.code : "LINK_CHECKOUT_FAILED",
|
|
1410
|
+
message: known && error instanceof Error ? error.message : "Link checkout could not start. Check your connection and try again."
|
|
1411
|
+
} }, true);
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
link.command("complete <orderReference>").description("Complete an approved Link purchase with the saved order").action(async (orderReference) => {
|
|
1415
|
+
try {
|
|
1416
|
+
output(await completeLinkPurchase(orderReference), true);
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
const noPayment = error instanceof LinkCheckoutError && [
|
|
1419
|
+
"INVALID_ORDER_REFERENCE",
|
|
1420
|
+
"CHECKOUT_NOT_FOUND",
|
|
1421
|
+
"INVALID_CHECKOUT_STATE",
|
|
1422
|
+
"LINK_APPROVAL_MISMATCH",
|
|
1423
|
+
"CHALLENGE_CHANGED"
|
|
1424
|
+
].includes(error.code);
|
|
1425
|
+
output({ success: false, status: noPayment ? "rejected" : "pending", orderReference, error: {
|
|
1426
|
+
code: error instanceof LinkCheckoutError ? error.code : "LINK_CHECKOUT_FAILED",
|
|
1427
|
+
message: error instanceof LinkCheckoutError ? error.message : "Payment outcome is unknown. Check this order before another payment."
|
|
1428
|
+
}, action: noPayment ? "Check the saved order and approval before another payment." : "Run the same complete command again to check this order. Do not start a new purchase yet." }, true);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
program.command("purchase <listingId>").description("Buy tickets through MPP machine checkout").requiredOption("--quantity <n>", "Number of tickets to buy").option("--email <email>", "Buyer email for confirmation and delivery").option("--name <name>", "Optional buyer/recipient name").option(
|
|
1432
|
+
"--idempotency-key <uuid>",
|
|
1433
|
+
"Stable UUID v4 to reuse for retries and recovery"
|
|
1434
|
+
).option("--account <name>", "mppx account name").requiredOption("--max-price <total>", "Maximum total USD payment").option("--confirm", "Approve this MPP payment", false).option("--json", "Output agent-readable JSON", true).action(async (listingId, opts) => {
|
|
1435
|
+
let publicErrorCode = "INVALID_PURCHASE_REQUEST";
|
|
1436
|
+
let publicErrorMessage = "Invalid purchase request.";
|
|
1437
|
+
try {
|
|
1438
|
+
if (!opts.confirm) throw new TypeError("purchase requires --confirm before loading the wallet");
|
|
1439
|
+
const maxAmountCents = usdCents(opts.maxPrice);
|
|
1440
|
+
if (!opts.idempotencyKey) throw new TypeError("purchase requires --idempotency-key for safe recovery");
|
|
1441
|
+
if (!opts.email?.trim()) {
|
|
1442
|
+
throw new TypeError("--email is required for machine purchases");
|
|
1443
|
+
}
|
|
1444
|
+
const purchase = normalizePurchaseTicketsParams({
|
|
1445
|
+
listingId,
|
|
1446
|
+
quantity: Number(opts.quantity),
|
|
1447
|
+
email: opts.email,
|
|
1448
|
+
name: opts.name,
|
|
1449
|
+
idempotencyKey: opts.idempotencyKey
|
|
1450
|
+
});
|
|
1451
|
+
const paymentEndpoint2 = process.env.TIXBIT_PAYMENT_URL;
|
|
1452
|
+
new TixBitClient({ paymentEndpoint: paymentEndpoint2 });
|
|
1453
|
+
publicErrorCode = "MPP_CLIENT_SETUP_FAILED";
|
|
1454
|
+
publicErrorMessage = "MPP payment client setup failed. Check the selected mppx account and local wallet configuration.";
|
|
1455
|
+
const [{ Mppx: Mppx2, tempo }, { resolveAccount }] = await Promise.all([
|
|
1456
|
+
import("mppx/client"),
|
|
1457
|
+
import("mppx/cli")
|
|
1458
|
+
]);
|
|
1459
|
+
const account = await resolveAccount(opts.account);
|
|
1460
|
+
const payments = Mppx2.create({
|
|
1461
|
+
methods: [tempo({ account })],
|
|
1462
|
+
polyfill: false,
|
|
1463
|
+
maxPaymentRetries: 1,
|
|
1464
|
+
onChallenge: async (challenge) => {
|
|
1465
|
+
assertMppChallenge(challenge, maxAmountCents);
|
|
1466
|
+
return void 0;
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
const purchaseClient = new TixBitClient({
|
|
1470
|
+
baseUrl: process.env.TIXBIT_BASE_URL,
|
|
1471
|
+
paymentEndpoint: paymentEndpoint2,
|
|
1472
|
+
paymentFetch: (input, init) => payments.fetch(input, init)
|
|
1473
|
+
});
|
|
1474
|
+
const result = await purchaseClient.purchaseTickets(purchase);
|
|
1475
|
+
if (opts.json) {
|
|
1476
|
+
output(result, true);
|
|
1477
|
+
} else {
|
|
1478
|
+
outputPurchaseResult(result);
|
|
1479
|
+
}
|
|
1480
|
+
if (!result.success) process.exitCode = 2;
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
const message = publicErrorCode === "INVALID_PURCHASE_REQUEST" && err instanceof Error ? err.message : publicErrorMessage;
|
|
1483
|
+
if (!opts.json) {
|
|
1484
|
+
process.stderr.write(`Error: ${message}
|
|
1485
|
+
`);
|
|
1486
|
+
process.exitCode = 1;
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
output(
|
|
1490
|
+
{
|
|
1491
|
+
success: false,
|
|
1492
|
+
status: "rejected",
|
|
1493
|
+
orderReference: null,
|
|
1494
|
+
receiptUrl: null,
|
|
1495
|
+
error: {
|
|
1496
|
+
code: publicErrorCode,
|
|
1497
|
+
message
|
|
1498
|
+
},
|
|
1499
|
+
action: publicErrorCode === "INVALID_PURCHASE_REQUEST" ? "Correct the request before attempting payment." : "Fix the local mppx account configuration, then retry with the same idempotency key."
|
|
1500
|
+
},
|
|
1501
|
+
true
|
|
1502
|
+
);
|
|
1503
|
+
process.exitCode = 1;
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
program.command("seatmap <eventId>").description("Show the seating chart / section map for an event's venue").option("--section <name>", "Highlight a specific section (case-insensitive)").option("--json", "Output raw JSON (for agents)", true).action(async (eventId, opts) => {
|
|
544
1507
|
try {
|
|
545
1508
|
const result = await client.getSeatmap({ eventId });
|
|
546
|
-
const isJson = opts.json
|
|
1509
|
+
const isJson = opts.json;
|
|
547
1510
|
if (isJson) {
|
|
548
1511
|
output(result, true);
|
|
549
1512
|
return;
|
|
@@ -616,8 +1579,8 @@ program.command("seatmap <eventId>").description("Show the seating chart / secti
|
|
|
616
1579
|
handleError(err);
|
|
617
1580
|
}
|
|
618
1581
|
});
|
|
619
|
-
program.command("url <slug>").description("Print the TixBit event page URL for a slug or ID").action((slug) => {
|
|
620
|
-
|
|
1582
|
+
program.command("url <slug>").description("Print the TixBit event page URL for a slug or ID").option("--json", "Output JSON", true).action((slug) => {
|
|
1583
|
+
output({ url: client.eventUrl(slug) }, true);
|
|
621
1584
|
});
|
|
622
1585
|
function categorizeSections(sections) {
|
|
623
1586
|
const groups = {};
|
|
@@ -676,4 +1639,22 @@ function describePosition(section, allSections) {
|
|
|
676
1639
|
}
|
|
677
1640
|
return [vertical, horizontal].filter((p) => p !== "center").join(", ");
|
|
678
1641
|
}
|
|
679
|
-
|
|
1642
|
+
function configureCommands(command) {
|
|
1643
|
+
command.exitOverride();
|
|
1644
|
+
command.configureOutput({
|
|
1645
|
+
writeOut: (text) => output({ help: text }, true),
|
|
1646
|
+
writeErr: () => {
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
for (const child of command.commands) configureCommands(child);
|
|
1650
|
+
}
|
|
1651
|
+
configureCommands(program);
|
|
1652
|
+
try {
|
|
1653
|
+
await program.parseAsync();
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
const code = error.code;
|
|
1656
|
+
if (code !== "commander.helpDisplayed" && code !== "commander.version") {
|
|
1657
|
+
output({ success: false, error: { code: "INVALID_COMMAND", message: "Invalid command or options. Use --help." } }, true);
|
|
1658
|
+
process.exitCode = 1;
|
|
1659
|
+
}
|
|
1660
|
+
}
|