tixbit 0.1.1 → 0.1.2
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 +205 -50
- package/dist/cli.js +755 -66
- package/dist/index.d.ts +240 -14
- package/dist/index.js +526 -37
- package/package.json +11 -6
- package/skills/tixbit/SKILL.md +64 -0
package/dist/index.js
CHANGED
|
@@ -1,15 +1,60 @@
|
|
|
1
|
+
// src/safety.ts
|
|
2
|
+
function trustedWebBase(base) {
|
|
3
|
+
const url = new URL(base);
|
|
4
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
5
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !(url.origin === "https://www.tixbit.com" || local && ["http:", "https:"].includes(url.protocol))) {
|
|
6
|
+
throw new TypeError("Credential requests require https://www.tixbit.com or a loopback QA origin");
|
|
7
|
+
}
|
|
8
|
+
return url.origin;
|
|
9
|
+
}
|
|
10
|
+
function assertMppChallenge(challenge, maxAmountCents) {
|
|
11
|
+
const { amount, currency } = challenge.request;
|
|
12
|
+
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)) {
|
|
13
|
+
throw new TypeError("MPP challenge exceeds the approved total or uses an unsupported payment asset or intent");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function redact(value, secrets = []) {
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
let result = value.replace(/\bspt_[A-Za-z0-9_]+/g, "[REDACTED]");
|
|
19
|
+
for (const secret of secrets.filter(Boolean)) result = result.split(secret).join("[REDACTED]");
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) return value.map((item) => redact(item, secrets));
|
|
23
|
+
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)]));
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
1
27
|
// src/client.ts
|
|
2
|
-
var DEFAULT_BASE_URL = "https://tixbit.com";
|
|
28
|
+
var DEFAULT_BASE_URL = "https://www.tixbit.com";
|
|
29
|
+
var DEFAULT_PAYMENT_ENDPOINT = "https://mcp.tixbit.com/api/purchase";
|
|
3
30
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
4
31
|
var USER_AGENT = "@tixbit/sdk";
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
32
|
+
function normalizePaymentEndpoint(value) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(value);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new TypeError("paymentEndpoint must be an absolute URL");
|
|
11
38
|
}
|
|
12
|
-
|
|
39
|
+
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
40
|
+
const isTixBitHost = url.origin === "https://mcp.tixbit.com" && url.pathname === "/api/purchase";
|
|
41
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
|
|
42
|
+
throw new TypeError(
|
|
43
|
+
"paymentEndpoint must use HTTPS (HTTP is allowed only for loopback QA)"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (url.username || url.password || url.hash || url.search) {
|
|
47
|
+
throw new TypeError("paymentEndpoint must not contain credentials or a fragment");
|
|
48
|
+
}
|
|
49
|
+
if (!isLoopback && !isTixBitHost) {
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
"paymentEndpoint must use a TixBit host or a loopback address for local QA"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return url.toString().replace(/\/$/, "");
|
|
55
|
+
}
|
|
56
|
+
function normalizeExternalEventId(eventId) {
|
|
57
|
+
return eventId;
|
|
13
58
|
}
|
|
14
59
|
function toAbsoluteUrl(baseUrl, value) {
|
|
15
60
|
if (!value) return null;
|
|
@@ -21,37 +66,44 @@ function toAbsoluteUrl(baseUrl, value) {
|
|
|
21
66
|
var TixBitClient = class {
|
|
22
67
|
baseUrl;
|
|
23
68
|
timeoutMs;
|
|
24
|
-
|
|
69
|
+
paymentEndpoint;
|
|
70
|
+
paymentFetch;
|
|
25
71
|
constructor(config = {}) {
|
|
26
72
|
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
27
73
|
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
28
|
-
this.
|
|
74
|
+
this.paymentEndpoint = normalizePaymentEndpoint(
|
|
75
|
+
config.paymentEndpoint ?? DEFAULT_PAYMENT_ENDPOINT
|
|
76
|
+
);
|
|
77
|
+
this.paymentFetch = config.paymentFetch ?? ((input, init) => fetch(input, init));
|
|
29
78
|
}
|
|
30
79
|
// ── HTTP helpers ──────────────────────────────────────────────────────────
|
|
31
|
-
async request(path
|
|
80
|
+
async request(path) {
|
|
32
81
|
const url = `${this.baseUrl}${path}`;
|
|
33
82
|
const controller = new AbortController();
|
|
34
83
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
35
84
|
const headers = {
|
|
36
85
|
Accept: "application/json",
|
|
37
|
-
"User-Agent": USER_AGENT
|
|
38
|
-
...this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}
|
|
86
|
+
"User-Agent": USER_AGENT
|
|
39
87
|
};
|
|
40
88
|
try {
|
|
41
89
|
const res = await fetch(url, {
|
|
42
|
-
|
|
43
|
-
headers
|
|
90
|
+
redirect: "error",
|
|
91
|
+
headers,
|
|
44
92
|
signal: controller.signal
|
|
45
93
|
});
|
|
46
94
|
if (!res.ok) {
|
|
47
|
-
const text = await res.text().catch(() => "");
|
|
48
95
|
throw new TixBitApiError(
|
|
49
|
-
|
|
96
|
+
`Request failed (HTTP ${res.status})`,
|
|
50
97
|
res.status,
|
|
51
98
|
url
|
|
52
99
|
);
|
|
53
100
|
}
|
|
54
101
|
return await res.json();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (controller.signal.aborted) {
|
|
104
|
+
throw new TixBitTimeoutError(url, this.timeoutMs);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
55
107
|
} finally {
|
|
56
108
|
clearTimeout(timer);
|
|
57
109
|
}
|
|
@@ -75,15 +127,40 @@ var TixBitClient = class {
|
|
|
75
127
|
city: params.city,
|
|
76
128
|
state: params.state,
|
|
77
129
|
category: params.category,
|
|
130
|
+
league: params.league,
|
|
131
|
+
categoryEventType: params.categoryEventType,
|
|
132
|
+
performerId: params.performerId,
|
|
133
|
+
venueId: params.venueId,
|
|
134
|
+
parkingFilter: params.parkingFilter,
|
|
135
|
+
nearLat: params.nearLat,
|
|
136
|
+
nearLng: params.nearLng,
|
|
137
|
+
locationMode: params.locationMode,
|
|
78
138
|
startDate: params.startDate,
|
|
79
139
|
endDate: params.endDate,
|
|
80
140
|
page: params.page,
|
|
81
141
|
size: params.size ?? 25
|
|
82
142
|
});
|
|
83
143
|
const data = await this.request(`/api/events/search${query}`);
|
|
144
|
+
const pagination = data.pagination;
|
|
84
145
|
return {
|
|
85
146
|
events: (data.events ?? []).map(normalizeEvent),
|
|
86
|
-
pagination:
|
|
147
|
+
pagination: {
|
|
148
|
+
...pagination,
|
|
149
|
+
totalExact: pagination.totalExact ?? pagination.total !== null
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Get one public event by ID or slug. */
|
|
154
|
+
async getEvent(eventId) {
|
|
155
|
+
const normalizedEventId = normalizeExternalEventId(eventId);
|
|
156
|
+
const data = await this.request(
|
|
157
|
+
`/api/events/${encodeURIComponent(normalizedEventId)}`
|
|
158
|
+
);
|
|
159
|
+
const publicEventId = normalizeExternalEventId(data.external_id ?? data.id);
|
|
160
|
+
return {
|
|
161
|
+
...data,
|
|
162
|
+
id: publicEventId,
|
|
163
|
+
external_id: publicEventId
|
|
87
164
|
};
|
|
88
165
|
}
|
|
89
166
|
// ── Browse (Location-Aware) ───────────────────────────────────────────────
|
|
@@ -97,19 +174,32 @@ var TixBitClient = class {
|
|
|
97
174
|
*/
|
|
98
175
|
async browse(params = {}) {
|
|
99
176
|
const query = this.qs({
|
|
177
|
+
page: params.page ?? 1,
|
|
100
178
|
size: params.size ?? 18,
|
|
101
|
-
|
|
102
|
-
|
|
179
|
+
query: params.query,
|
|
180
|
+
category: params.category,
|
|
181
|
+
league: params.league,
|
|
182
|
+
date: params.date,
|
|
183
|
+
context: params.context ?? "homepage",
|
|
184
|
+
recommendation: params.recommendation ?? "upcoming",
|
|
103
185
|
nearLat: params.latitude,
|
|
104
186
|
nearLng: params.longitude,
|
|
105
187
|
preferCity: params.city,
|
|
106
188
|
preferState: params.state,
|
|
107
|
-
categoryEventType: params.categoryEventType
|
|
189
|
+
categoryEventType: params.categoryEventType,
|
|
190
|
+
locationMode: params.locationMode,
|
|
191
|
+
searchScope: params.searchScope,
|
|
192
|
+
parkingFilter: params.parkingFilter
|
|
108
193
|
});
|
|
109
194
|
const data = await this.request(`/api/events${query}`);
|
|
110
195
|
return {
|
|
111
196
|
events: (data.events ?? []).map(normalizeEvent),
|
|
112
|
-
total: data.total
|
|
197
|
+
total: data.total,
|
|
198
|
+
totalExact: data.totalExact ?? data.total !== null,
|
|
199
|
+
hasMore: data.hasMore ?? false,
|
|
200
|
+
page: data.page ?? params.page ?? 1,
|
|
201
|
+
pageSize: data.pageSize ?? params.size ?? 18,
|
|
202
|
+
...data.degraded === void 0 ? {} : { degraded: data.degraded }
|
|
113
203
|
};
|
|
114
204
|
}
|
|
115
205
|
// ── Listings ──────────────────────────────────────────────────────────────
|
|
@@ -118,14 +208,16 @@ var TixBitClient = class {
|
|
|
118
208
|
*
|
|
119
209
|
* @example
|
|
120
210
|
* ```ts
|
|
121
|
-
* const listings = await client.getListings({ eventId: "
|
|
211
|
+
* const listings = await client.getListings({ eventId: "EVENT123" });
|
|
122
212
|
* ```
|
|
123
213
|
*/
|
|
124
214
|
async getListings(params) {
|
|
125
215
|
const query = this.qs({
|
|
126
216
|
size: params.size ?? 50,
|
|
127
217
|
page: params.page ?? 1,
|
|
128
|
-
order_by_direction: params.orderByDirection ?? "asc"
|
|
218
|
+
order_by_direction: params.orderByDirection ?? "asc",
|
|
219
|
+
includeAll: params.includeAll,
|
|
220
|
+
refresh: params.refresh
|
|
129
221
|
});
|
|
130
222
|
const normalizedEventId = normalizeExternalEventId(params.eventId);
|
|
131
223
|
const data = await this.request(`/api/events/${encodeURIComponent(normalizedEventId)}/listings${query}`);
|
|
@@ -133,13 +225,121 @@ var TixBitClient = class {
|
|
|
133
225
|
return {
|
|
134
226
|
listings,
|
|
135
227
|
meta: {
|
|
136
|
-
total: data.meta
|
|
137
|
-
page: data.meta
|
|
138
|
-
size: data.meta
|
|
228
|
+
total: readListingMetaNumber(data.meta, "total_count", "total") ?? listings.length,
|
|
229
|
+
page: readListingMetaNumber(data.meta, "current_page_number", "page") ?? params.page ?? 1,
|
|
230
|
+
size: readListingMetaNumber(data.meta, "current_page_size", "size") ?? params.size ?? 50,
|
|
231
|
+
totalPages: readListingMetaNumber(data.meta, "total_pages") ?? void 0,
|
|
232
|
+
currentPageTotalCount: readListingMetaNumber(data.meta, "current_page_total_count") ?? void 0,
|
|
233
|
+
cache: readBoolean(data.meta?.cache) ?? void 0,
|
|
234
|
+
cacheExpiresAt: readNullableString(data.meta?.cache_expires_at),
|
|
235
|
+
cacheState: readEnum(data.meta?.cache_state, ["fresh", "stale", "unavailable"]),
|
|
236
|
+
freshness: readEnum(data.meta?.freshness, ["live", "cached"]),
|
|
139
237
|
cacheSource: data.meta?.cacheSource
|
|
140
238
|
}
|
|
141
239
|
};
|
|
142
240
|
}
|
|
241
|
+
/** Get one public listing by listing ID. */
|
|
242
|
+
async getListing(listingId) {
|
|
243
|
+
const data = await this.request(`/api/listings/${encodeURIComponent(listingId)}`);
|
|
244
|
+
return {
|
|
245
|
+
listing: normalizeListing(data.listing),
|
|
246
|
+
disclosures: data.disclosures ?? []
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Refreshed inventory, not a reservation or negotiated offer. */
|
|
250
|
+
async quoteListings(params) {
|
|
251
|
+
if (!params.eventId || params.eventId !== params.eventId.trim()) throw new TypeError("Copy the exact event ID from search");
|
|
252
|
+
if (params.size !== void 0 && (!Number.isInteger(params.size) || params.size < 1 || params.size > 100)) throw new RangeError("size must be 1-100");
|
|
253
|
+
const result = await this.getListings({ ...params, refresh: true });
|
|
254
|
+
if (result.meta.freshness !== "live") throw new Error("Live inventory is unavailable; no live quote can be provided");
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
async agentRequest(path, body, token) {
|
|
258
|
+
const base = trustedWebBase(this.baseUrl);
|
|
259
|
+
const headers = { Accept: "application/json", "Content-Type": "application/json" };
|
|
260
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
261
|
+
const response = await fetch(`${base}${path}`, {
|
|
262
|
+
method: body === void 0 ? "GET" : "POST",
|
|
263
|
+
headers,
|
|
264
|
+
redirect: "error",
|
|
265
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
266
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
267
|
+
}).catch(() => {
|
|
268
|
+
throw new Error("Request did not complete; reconcile any write before retrying");
|
|
269
|
+
});
|
|
270
|
+
if (!response.ok) throw new TixBitApiError(`Request failed (HTTP ${response.status})`, response.status, `${base}${path}`);
|
|
271
|
+
const result = await response.json().catch(() => {
|
|
272
|
+
throw new Error("Invalid server response");
|
|
273
|
+
});
|
|
274
|
+
if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) throw new Error("Invalid server response");
|
|
275
|
+
return redact(result, [token ?? ""]);
|
|
276
|
+
}
|
|
277
|
+
/** Public browser entry points. This does not create or import a CLI session. */
|
|
278
|
+
getAuthorizationInfo() {
|
|
279
|
+
return {
|
|
280
|
+
apiKeyRequired: false,
|
|
281
|
+
cliSessionSupported: false,
|
|
282
|
+
signInUrl: "https://www.tixbit.com/sign-in",
|
|
283
|
+
sellerUrl: "https://www.tixbit.com/sell",
|
|
284
|
+
linkWalletUrl: "https://link.com/agents",
|
|
285
|
+
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."
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
sellerAuthorizationRequired() {
|
|
289
|
+
return {
|
|
290
|
+
success: false,
|
|
291
|
+
status: "authorization_required",
|
|
292
|
+
data: this.getAuthorizationInfo(),
|
|
293
|
+
error: { code: "SELLER_SIGN_IN_REQUIRED", message: "Seller operations require your signed-in user and seller access." },
|
|
294
|
+
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."
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** Stripe Link checkout. No confirmation means quote only. Never retries payment. */
|
|
298
|
+
async buyTickets(params) {
|
|
299
|
+
trustedWebBase(this.baseUrl);
|
|
300
|
+
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");
|
|
301
|
+
if (!Number.isInteger(params.quantity) || params.quantity < 1 || params.quantity > 8) throw new RangeError("quantity must be 1-8");
|
|
302
|
+
if (!Number.isSafeInteger(params.maxAmountCents) || params.maxAmountCents <= 0) throw new RangeError("A positive total maxAmountCents is required");
|
|
303
|
+
const email = normalizeBuyerEmail(params.email);
|
|
304
|
+
if (params.confirm === true && !params.sharedPaymentToken) {
|
|
305
|
+
return {
|
|
306
|
+
success: false,
|
|
307
|
+
status: "authorization_required",
|
|
308
|
+
data: { checkoutUrl: this.createCheckoutLink(params).url, maxAmountCents: params.maxAmountCents, automaticPayment: false },
|
|
309
|
+
error: { code: "PAYMENT_AUTHORIZATION_REQUIRED", message: "A user-approved payment method is required, not a TixBit API key." },
|
|
310
|
+
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."
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (params.confirm === true && !/^spt_[A-Za-z0-9_]+$/.test(params.sharedPaymentToken ?? "")) throw new TypeError("Invalid user-approved Link payment credential");
|
|
314
|
+
const body = { listingId: params.listingId, quantity: params.quantity, email, maxAmountCents: params.maxAmountCents };
|
|
315
|
+
const quote = await this.agentRequest("/api/agentic/checkout", body);
|
|
316
|
+
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;
|
|
317
|
+
if (!validQuote(quote.data)) throw new Error("Checkout quote is invalid or exceeds the approved total; no payment sent");
|
|
318
|
+
if (params.confirm !== true) return quote;
|
|
319
|
+
if (quote.data?.maxAmountCentsSupported !== true) throw new Error("Server does not support the total cap; no payment sent");
|
|
320
|
+
try {
|
|
321
|
+
const paid = await this.agentRequest("/api/agentic/checkout", { ...body, sharedPaymentToken: params.sharedPaymentToken });
|
|
322
|
+
if (!validQuote(paid.data) || typeof paid.data?.orderId !== "string" || typeof paid.data?.status !== "string") throw new Error("Invalid payment outcome");
|
|
323
|
+
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." };
|
|
324
|
+
} catch {
|
|
325
|
+
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." };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async listSellerListings(accessToken = "") {
|
|
329
|
+
if (!accessToken) return this.sellerAuthorizationRequired();
|
|
330
|
+
return this.agentRequest("/api/sell/listings", void 0, accessToken);
|
|
331
|
+
}
|
|
332
|
+
async createSellerListing(body, accessToken, confirm) {
|
|
333
|
+
trustedWebBase(this.baseUrl);
|
|
334
|
+
if (confirm !== true) throw new TypeError("Seller creation requires explicit confirmation");
|
|
335
|
+
if (!accessToken) return this.sellerAuthorizationRequired();
|
|
336
|
+
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");
|
|
337
|
+
try {
|
|
338
|
+
return await this.agentRequest("/api/sell/listings", body, accessToken);
|
|
339
|
+
} catch {
|
|
340
|
+
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." };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
143
343
|
// ── Checkout Link ──────────────────────────────────────────────────────────
|
|
144
344
|
/**
|
|
145
345
|
* Create a checkout link for a listing.
|
|
@@ -150,26 +350,141 @@ var TixBitClient = class {
|
|
|
150
350
|
* @example
|
|
151
351
|
* ```ts
|
|
152
352
|
* const link = client.createCheckoutLink({
|
|
153
|
-
* listingId: "
|
|
353
|
+
* listingId: "LISTING123",
|
|
154
354
|
* quantity: 2,
|
|
155
355
|
* });
|
|
156
356
|
*
|
|
157
357
|
* console.log(link.url);
|
|
158
|
-
* // → "https://tixbit.com/checkout/process?listing=
|
|
358
|
+
* // → "https://www.tixbit.com/checkout/process?listing=LISTING123&quantity=2"
|
|
159
359
|
* ```
|
|
160
360
|
*/
|
|
161
361
|
createCheckoutLink(params) {
|
|
162
|
-
const
|
|
163
|
-
|
|
362
|
+
const listingId = params.listingId;
|
|
363
|
+
if (!/^[A-Za-z0-9_-]{2,50}$/.test(listingId)) {
|
|
364
|
+
throw new TypeError(
|
|
365
|
+
"listingId must be 2-50 letters, numbers, hyphens, or underscores"
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (!Number.isInteger(params.quantity) || params.quantity < 1 || params.quantity > 8) {
|
|
369
|
+
throw new RangeError("quantity must be an integer from 1 to 8");
|
|
370
|
+
}
|
|
371
|
+
const url = `${this.baseUrl}/checkout/process?listing=${encodeURIComponent(listingId)}&quantity=${params.quantity}`;
|
|
164
372
|
return {
|
|
165
373
|
url,
|
|
166
|
-
listingId
|
|
167
|
-
quantity
|
|
374
|
+
listingId,
|
|
375
|
+
quantity: params.quantity
|
|
168
376
|
};
|
|
169
377
|
}
|
|
378
|
+
// ── Machine checkout ─────────────────────────────────────────────────────
|
|
379
|
+
/**
|
|
380
|
+
* Purchase tickets through the public MPP endpoint.
|
|
381
|
+
*
|
|
382
|
+
* Configure `paymentFetch` with the official mppx client to automatically
|
|
383
|
+
* answer the server's 402 challenge. Reuse the returned idempotency key for
|
|
384
|
+
* any recovery check after a timeout or pending result.
|
|
385
|
+
*/
|
|
386
|
+
async purchaseTickets(params) {
|
|
387
|
+
const { listingId, quantity, email, idempotencyKey, name } = normalizePurchaseTicketsParams(params);
|
|
388
|
+
const controller = new AbortController();
|
|
389
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
390
|
+
try {
|
|
391
|
+
const response = await this.paymentFetch(this.paymentEndpoint, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
redirect: "error",
|
|
394
|
+
headers: {
|
|
395
|
+
Accept: "application/json",
|
|
396
|
+
"Content-Type": "application/json",
|
|
397
|
+
"User-Agent": USER_AGENT
|
|
398
|
+
},
|
|
399
|
+
body: JSON.stringify({
|
|
400
|
+
listingId,
|
|
401
|
+
quantity,
|
|
402
|
+
email,
|
|
403
|
+
idempotencyKey,
|
|
404
|
+
...name ? { name } : {}
|
|
405
|
+
}),
|
|
406
|
+
signal: controller.signal
|
|
407
|
+
});
|
|
408
|
+
const body = await response.json().catch(() => ({}));
|
|
409
|
+
const orderReference = normalizeOrderReference(body.orderReference);
|
|
410
|
+
const receiptUrl = normalizeReceiptUrl(body.receiptUrl);
|
|
411
|
+
if (response.status === 402) {
|
|
412
|
+
const order = normalizePurchaseOrder(
|
|
413
|
+
body.order,
|
|
414
|
+
orderReference,
|
|
415
|
+
"payment_required"
|
|
416
|
+
);
|
|
417
|
+
return {
|
|
418
|
+
success: false,
|
|
419
|
+
status: "payment_required",
|
|
420
|
+
idempotencyKey,
|
|
421
|
+
orderReference,
|
|
422
|
+
receiptUrl,
|
|
423
|
+
...order ? { order } : {},
|
|
424
|
+
action: "Configure the official mppx payment client, then retry with the same idempotency key."
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (isPurchaseStatus(body.status)) {
|
|
428
|
+
if (body.status === "fulfilled" && response.ok && body.success === true && !orderReference) {
|
|
429
|
+
return {
|
|
430
|
+
success: false,
|
|
431
|
+
status: "pending",
|
|
432
|
+
idempotencyKey,
|
|
433
|
+
orderReference: null,
|
|
434
|
+
receiptUrl,
|
|
435
|
+
error: {
|
|
436
|
+
code: "PURCHASE_OUTCOME_AMBIGUOUS",
|
|
437
|
+
message: "The purchase response did not include an order reference."
|
|
438
|
+
},
|
|
439
|
+
action: "Retry with the same idempotency key. Do not create a new payment or purchase attempt."
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
if (body.status === "fulfilled" && (!response.ok || body.success !== true)) {
|
|
443
|
+
return rejectedPurchaseResult(
|
|
444
|
+
idempotencyKey,
|
|
445
|
+
orderReference,
|
|
446
|
+
receiptUrl,
|
|
447
|
+
response.status
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const order = normalizePurchaseOrder(body.order, orderReference, body.status);
|
|
451
|
+
return {
|
|
452
|
+
success: body.status === "fulfilled",
|
|
453
|
+
status: body.status,
|
|
454
|
+
idempotencyKey,
|
|
455
|
+
orderReference,
|
|
456
|
+
receiptUrl,
|
|
457
|
+
...order ? { order } : {},
|
|
458
|
+
...body.status === "manual_review_required" ? { paid: true } : {},
|
|
459
|
+
...safePurchaseOutcomeDetails(body.status)
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return rejectedPurchaseResult(
|
|
463
|
+
idempotencyKey,
|
|
464
|
+
orderReference,
|
|
465
|
+
receiptUrl,
|
|
466
|
+
response.status
|
|
467
|
+
);
|
|
468
|
+
} catch {
|
|
469
|
+
return {
|
|
470
|
+
success: false,
|
|
471
|
+
status: "pending",
|
|
472
|
+
idempotencyKey,
|
|
473
|
+
orderReference: null,
|
|
474
|
+
receiptUrl: null,
|
|
475
|
+
error: {
|
|
476
|
+
code: controller.signal.aborted ? "PURCHASE_TIMEOUT" : "PURCHASE_NETWORK_ERROR",
|
|
477
|
+
message: controller.signal.aborted ? `Purchase status is unknown after ${this.timeoutMs}ms.` : "Purchase status is unknown because the request did not complete."
|
|
478
|
+
},
|
|
479
|
+
action: "Retry with the same idempotency key. Do not create a new payment or purchase attempt."
|
|
480
|
+
};
|
|
481
|
+
} finally {
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
170
485
|
// ── Event URL helper ──────────────────────────────────────────────────────
|
|
171
486
|
/**
|
|
172
|
-
* Build the direct URL to an event page on tixbit.com.
|
|
487
|
+
* Build the direct URL to an event page on www.tixbit.com.
|
|
173
488
|
*/
|
|
174
489
|
eventUrl(slugOrId) {
|
|
175
490
|
if (slugOrId.includes("/")) {
|
|
@@ -188,7 +503,7 @@ var TixBitClient = class {
|
|
|
188
503
|
*
|
|
189
504
|
* @example
|
|
190
505
|
* ```ts
|
|
191
|
-
* const seatmap = await client.getSeatmap({ eventId: "
|
|
506
|
+
* const seatmap = await client.getSeatmap({ eventId: "EVENT123" });
|
|
192
507
|
* console.log(seatmap.venue_name); // "State Farm Arena"
|
|
193
508
|
* console.log(seatmap.section_names); // ["101", "102", ...]
|
|
194
509
|
* ```
|
|
@@ -268,6 +583,129 @@ var TixBitClient = class {
|
|
|
268
583
|
}
|
|
269
584
|
}
|
|
270
585
|
};
|
|
586
|
+
function normalizeBuyerEmail(value) {
|
|
587
|
+
const email = value.trim().toLowerCase();
|
|
588
|
+
const match = email.match(/^([^\s@]{1,64})@([^\s@]{1,253})$/);
|
|
589
|
+
if (!match || email.length > 254 || match[1].startsWith(".") || match[1].endsWith(".") || match[1].includes("..") || !match[2].includes(".") || match[2].split(".").some(
|
|
590
|
+
(label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)
|
|
591
|
+
)) {
|
|
592
|
+
throw new TypeError("email must be a valid email address");
|
|
593
|
+
}
|
|
594
|
+
return email;
|
|
595
|
+
}
|
|
596
|
+
function normalizePurchaseTicketsParams(params) {
|
|
597
|
+
const listingId = params.listingId;
|
|
598
|
+
if (!/^[A-Za-z0-9_-]{2,100}$/.test(listingId)) {
|
|
599
|
+
throw new TypeError(
|
|
600
|
+
"listingId must be 2-100 letters, numbers, hyphens, or underscores"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
if (!Number.isInteger(params.quantity) || params.quantity < 1 || params.quantity > 8) {
|
|
604
|
+
throw new RangeError("quantity must be an integer from 1 to 8");
|
|
605
|
+
}
|
|
606
|
+
const email = normalizeBuyerEmail(params.email);
|
|
607
|
+
const idempotencyKey = params.idempotencyKey ?? crypto.randomUUID();
|
|
608
|
+
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(
|
|
609
|
+
idempotencyKey
|
|
610
|
+
)) {
|
|
611
|
+
throw new TypeError("idempotencyKey must be an unguessable UUID v4");
|
|
612
|
+
}
|
|
613
|
+
const name = params.name?.trim();
|
|
614
|
+
if (name && name.length > 120) {
|
|
615
|
+
throw new TypeError("name must be at most 120 characters");
|
|
616
|
+
}
|
|
617
|
+
return { listingId, quantity: params.quantity, email, idempotencyKey, name };
|
|
618
|
+
}
|
|
619
|
+
function normalizePurchaseOrder(value, orderReference, status) {
|
|
620
|
+
if (!value || typeof value !== "object" || !orderReference) return void 0;
|
|
621
|
+
const order = value;
|
|
622
|
+
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)) {
|
|
623
|
+
return void 0;
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
reference: orderReference,
|
|
627
|
+
status,
|
|
628
|
+
listingId: order.listingId,
|
|
629
|
+
quantity: order.quantity,
|
|
630
|
+
pricePerTicket: order.pricePerTicket,
|
|
631
|
+
subtotal: order.subtotal,
|
|
632
|
+
fees: order.fees,
|
|
633
|
+
total: order.total,
|
|
634
|
+
currency: order.currency,
|
|
635
|
+
...typeof order.section === "string" && order.section.length <= 120 ? { section: order.section } : {},
|
|
636
|
+
...typeof order.row === "string" && order.row.length <= 120 ? { row: order.row } : {}
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function isNonNegativeNumber(value) {
|
|
640
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
641
|
+
}
|
|
642
|
+
function normalizeOrderReference(value) {
|
|
643
|
+
return typeof value === "string" && /^TBM-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{10}$/.test(value) ? value : null;
|
|
644
|
+
}
|
|
645
|
+
function normalizeReceiptUrl(value) {
|
|
646
|
+
if (typeof value !== "string") return null;
|
|
647
|
+
try {
|
|
648
|
+
const url = new URL(value);
|
|
649
|
+
return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null;
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function safePurchaseOutcomeDetails(status) {
|
|
655
|
+
switch (status) {
|
|
656
|
+
case "pending":
|
|
657
|
+
return {
|
|
658
|
+
error: {
|
|
659
|
+
code: "PURCHASE_PENDING",
|
|
660
|
+
message: "Purchase status is still pending."
|
|
661
|
+
},
|
|
662
|
+
action: "Retry with the same idempotency key. Do not create a new payment or purchase attempt."
|
|
663
|
+
};
|
|
664
|
+
case "manual_review_required":
|
|
665
|
+
return {
|
|
666
|
+
error: {
|
|
667
|
+
code: "FULFILLMENT_REVIEW_REQUIRED",
|
|
668
|
+
message: "Payment was received, but fulfillment requires manual review."
|
|
669
|
+
},
|
|
670
|
+
action: "Do not submit another payment or purchase. Contact TixBit support with the order reference."
|
|
671
|
+
};
|
|
672
|
+
case "payment_failed":
|
|
673
|
+
return {
|
|
674
|
+
error: {
|
|
675
|
+
code: "PAYMENT_VERIFICATION_FAILED",
|
|
676
|
+
message: "Payment verification failed."
|
|
677
|
+
},
|
|
678
|
+
action: "Obtain a new MPP credential for the same idempotency key."
|
|
679
|
+
};
|
|
680
|
+
case "rejected":
|
|
681
|
+
return {
|
|
682
|
+
error: {
|
|
683
|
+
code: "PURCHASE_REJECTED",
|
|
684
|
+
message: "The purchase request was rejected."
|
|
685
|
+
},
|
|
686
|
+
action: "Correct the request and retry with the same idempotency key."
|
|
687
|
+
};
|
|
688
|
+
default:
|
|
689
|
+
return {};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function rejectedPurchaseResult(idempotencyKey, orderReference, receiptUrl, httpStatus) {
|
|
693
|
+
return {
|
|
694
|
+
success: false,
|
|
695
|
+
status: "rejected",
|
|
696
|
+
idempotencyKey,
|
|
697
|
+
orderReference,
|
|
698
|
+
receiptUrl,
|
|
699
|
+
error: {
|
|
700
|
+
code: `HTTP_${httpStatus}`,
|
|
701
|
+
message: "Purchase request was rejected or returned an invalid response."
|
|
702
|
+
},
|
|
703
|
+
action: "Review the request and retry with the same idempotency key if appropriate."
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function isPurchaseStatus(value) {
|
|
707
|
+
return value === "fulfilled" || value === "payment_required" || value === "pending" || value === "manual_review_required" || value === "payment_failed" || value === "rejected";
|
|
708
|
+
}
|
|
271
709
|
function normalizeEvent(raw) {
|
|
272
710
|
const e = raw;
|
|
273
711
|
const rawId = str(e.external_event_id) ?? str(e.externalEventId) ?? str(e.id) ?? "";
|
|
@@ -281,9 +719,12 @@ function normalizeEvent(raw) {
|
|
|
281
719
|
venue_name: str(e.venue_name) ?? str(e.venueName) ?? null,
|
|
282
720
|
venue_city: str(e.venue_city) ?? str(e.venueCity) ?? null,
|
|
283
721
|
venue_state: str(e.venue_state) ?? str(e.venueState) ?? null,
|
|
722
|
+
venue_country: str(e.venue_country) ?? str(e.venueCountry) ?? null,
|
|
284
723
|
image_url: str(e.image_url) ?? str(e.imageUrl) ?? null,
|
|
724
|
+
category_slug: str(e.category_slug) ?? str(e.categorySlug) ?? null,
|
|
285
725
|
category_name: str(e.category_name) ?? str(e.categoryName) ?? null,
|
|
286
726
|
category_event_type: str(e.category_event_type) ?? str(e.categoryEventType) ?? null,
|
|
727
|
+
metadata: e.metadata && typeof e.metadata === "object" ? e.metadata : null,
|
|
287
728
|
has_listings: Boolean(e.has_listings),
|
|
288
729
|
inventory: normalizeInventory(e.inventory)
|
|
289
730
|
};
|
|
@@ -293,10 +734,17 @@ function normalizeInventory(raw) {
|
|
|
293
734
|
return { total_available: 0, min_price: 0, max_price: 0 };
|
|
294
735
|
}
|
|
295
736
|
const inv = raw;
|
|
737
|
+
const sources = inv.sources;
|
|
296
738
|
return {
|
|
297
739
|
total_available: num(inv.total_available) ?? 0,
|
|
298
740
|
min_price: num(inv.min_price) ?? 0,
|
|
299
|
-
max_price: num(inv.max_price) ?? 0
|
|
741
|
+
max_price: num(inv.max_price) ?? 0,
|
|
742
|
+
...sources ? {
|
|
743
|
+
sources: {
|
|
744
|
+
local: num(sources.local) ?? 0,
|
|
745
|
+
marketplace: num(sources.marketplace) ?? 0
|
|
746
|
+
}
|
|
747
|
+
} : {}
|
|
300
748
|
};
|
|
301
749
|
}
|
|
302
750
|
function normalizeListing(raw) {
|
|
@@ -304,16 +752,30 @@ function normalizeListing(raw) {
|
|
|
304
752
|
const attrs = outer.attributes ?? outer;
|
|
305
753
|
return {
|
|
306
754
|
id: str(outer.id) ?? str(attrs.id) ?? "",
|
|
755
|
+
event_id: str(attrs.event_id) ?? null,
|
|
307
756
|
// price_per_ticket is the fee-inclusive per-ticket price (in dollars, not cents)
|
|
308
757
|
price: num(attrs.price_per_ticket) ?? num(attrs.price) ?? 0,
|
|
758
|
+
total_price: num(attrs.total_price),
|
|
759
|
+
base_price_per_ticket: num(attrs.base_price_per_ticket),
|
|
760
|
+
base_total_price: num(attrs.base_total_price),
|
|
761
|
+
face_value: num(attrs.face_value),
|
|
762
|
+
fee_amount_per_ticket: num(attrs.fee_amount_per_ticket),
|
|
763
|
+
fee_amount_total: num(attrs.fee_amount_total),
|
|
764
|
+
fee_percent: num(attrs.fee_percent),
|
|
765
|
+
currency: str(attrs.currency) ?? "USD",
|
|
309
766
|
quantity: num(attrs.quantity) ?? num(attrs.available_quantity) ?? 0,
|
|
310
767
|
quantities_list: Array.isArray(attrs.quantities_list) ? attrs.quantities_list : [],
|
|
311
768
|
section: str(attrs.section) ?? null,
|
|
312
769
|
row: str(attrs.row) ?? null,
|
|
770
|
+
seat_from: str(attrs.seat_from) ?? null,
|
|
771
|
+
seat_to: str(attrs.seat_to) ?? null,
|
|
313
772
|
seat_numbers: str(attrs.seat_numbers) ?? null,
|
|
314
773
|
listing_hash: str(attrs.listing_hash) ?? "",
|
|
315
774
|
notes: str(attrs.notes) ?? null,
|
|
316
775
|
delivery_method: str(attrs.delivery_method) ?? str(attrs.delivery_type) ?? null,
|
|
776
|
+
in_hand_date: str(attrs.in_hand_date) ?? null,
|
|
777
|
+
stock_type: str(attrs.stock_type) ?? null,
|
|
778
|
+
disclosure_ids: Array.isArray(attrs.disclosure_ids) ? attrs.disclosure_ids.filter((id) => typeof id === "string") : [],
|
|
317
779
|
splits: Array.isArray(attrs.splits) ? attrs.splits : [],
|
|
318
780
|
raw: outer
|
|
319
781
|
};
|
|
@@ -336,6 +798,23 @@ function str(v) {
|
|
|
336
798
|
function num(v) {
|
|
337
799
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
338
800
|
}
|
|
801
|
+
function readBoolean(value) {
|
|
802
|
+
return typeof value === "boolean" ? value : null;
|
|
803
|
+
}
|
|
804
|
+
function readNullableString(value) {
|
|
805
|
+
return value === null ? null : typeof value === "string" ? value : void 0;
|
|
806
|
+
}
|
|
807
|
+
function readEnum(value, choices) {
|
|
808
|
+
return typeof value === "string" && choices.includes(value) ? value : void 0;
|
|
809
|
+
}
|
|
810
|
+
function readListingMetaNumber(meta, ...keys) {
|
|
811
|
+
if (!meta) return null;
|
|
812
|
+
for (const key of keys) {
|
|
813
|
+
const value = num(meta[key]);
|
|
814
|
+
if (value !== null) return value;
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
339
818
|
var TixBitApiError = class extends Error {
|
|
340
819
|
constructor(message, status, url) {
|
|
341
820
|
super(message);
|
|
@@ -344,7 +823,17 @@ var TixBitApiError = class extends Error {
|
|
|
344
823
|
this.name = "TixBitApiError";
|
|
345
824
|
}
|
|
346
825
|
};
|
|
826
|
+
var TixBitTimeoutError = class extends Error {
|
|
827
|
+
constructor(url, timeoutMs) {
|
|
828
|
+
super(`Request timed out after ${timeoutMs}ms: ${url}`);
|
|
829
|
+
this.url = url;
|
|
830
|
+
this.timeoutMs = timeoutMs;
|
|
831
|
+
this.name = "TixBitTimeoutError";
|
|
832
|
+
}
|
|
833
|
+
};
|
|
347
834
|
export {
|
|
348
835
|
TixBitApiError,
|
|
349
|
-
TixBitClient
|
|
836
|
+
TixBitClient,
|
|
837
|
+
TixBitTimeoutError,
|
|
838
|
+
assertMppChallenge
|
|
350
839
|
};
|