hospitable 0.1.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/dist/index.js ADDED
@@ -0,0 +1,622 @@
1
+ // src/auth/token-manager.ts
2
+ var TokenManager = class {
3
+ constructor(config) {
4
+ this.config = config;
5
+ this.expiresAt = 0;
6
+ this.refreshPromise = null;
7
+ if (config.token && !config.refreshToken && !config.clientId) {
8
+ this.accessToken = config.token;
9
+ this.expiresAt = Infinity;
10
+ } else if (config.token) {
11
+ this.accessToken = config.token;
12
+ this.refreshToken = config.refreshToken;
13
+ this.expiresAt = Date.now() + 6e4;
14
+ } else {
15
+ const envPat = process.env["HOSPITABLE_PAT"];
16
+ if (envPat) {
17
+ this.accessToken = envPat;
18
+ this.expiresAt = Infinity;
19
+ }
20
+ }
21
+ }
22
+ async getAuthHeader() {
23
+ if (this.needsRefresh()) {
24
+ await this.ensureRefreshed();
25
+ }
26
+ if (!this.accessToken) {
27
+ throw new Error("No access token available. Provide token or clientId+clientSecret.");
28
+ }
29
+ return `Bearer ${this.accessToken}`;
30
+ }
31
+ needsRefresh() {
32
+ if (this.expiresAt === Infinity) return false;
33
+ return Date.now() >= this.expiresAt - 6e4;
34
+ }
35
+ async ensureRefreshed() {
36
+ if (this.refreshPromise) {
37
+ await this.refreshPromise;
38
+ return;
39
+ }
40
+ this.refreshPromise = this.doRefresh().finally(() => {
41
+ this.refreshPromise = null;
42
+ });
43
+ await this.refreshPromise;
44
+ }
45
+ async doRefresh() {
46
+ const { clientId, clientSecret, baseURL } = this.config;
47
+ if (!clientId || !clientSecret) {
48
+ throw new Error("Cannot refresh token: clientId and clientSecret are required");
49
+ }
50
+ const body = this.refreshToken ? new URLSearchParams({
51
+ grant_type: "refresh_token",
52
+ refresh_token: this.refreshToken,
53
+ client_id: clientId,
54
+ client_secret: clientSecret
55
+ }) : new URLSearchParams({
56
+ grant_type: "client_credentials",
57
+ client_id: clientId,
58
+ client_secret: clientSecret
59
+ });
60
+ const response = await fetch(`${baseURL}/oauth/token`, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
63
+ body: body.toString()
64
+ });
65
+ if (!response.ok) {
66
+ const text = await response.text();
67
+ throw new Error(`Token refresh failed (${response.status}): ${text}`);
68
+ }
69
+ const data = await response.json();
70
+ this.accessToken = data.access_token;
71
+ if (data.refresh_token) this.refreshToken = data.refresh_token;
72
+ this.expiresAt = Date.now() + data.expires_in * 1e3;
73
+ }
74
+ async handleUnauthorized() {
75
+ this.expiresAt = 0;
76
+ await this.ensureRefreshed();
77
+ }
78
+ };
79
+
80
+ // src/errors.ts
81
+ var HospitableError = class extends Error {
82
+ constructor(message, statusCode, requestId) {
83
+ super(message);
84
+ this.name = "HospitableError";
85
+ this.statusCode = statusCode;
86
+ this.requestId = requestId;
87
+ Object.setPrototypeOf(this, new.target.prototype);
88
+ }
89
+ };
90
+ var AuthenticationError = class extends HospitableError {
91
+ constructor(message = "Authentication failed", requestId) {
92
+ super(message, 401, requestId);
93
+ this.name = "AuthenticationError";
94
+ }
95
+ };
96
+ var RateLimitError = class extends HospitableError {
97
+ constructor(retryAfter, requestId) {
98
+ super(`Rate limit exceeded. Retry after ${retryAfter}s`, 429, requestId);
99
+ this.name = "RateLimitError";
100
+ this.retryAfter = retryAfter;
101
+ }
102
+ };
103
+ var NotFoundError = class extends HospitableError {
104
+ constructor(message = "Resource not found", requestId, resource) {
105
+ super(message, 404, requestId);
106
+ this.name = "NotFoundError";
107
+ this.resource = resource;
108
+ }
109
+ };
110
+ var ValidationError = class extends HospitableError {
111
+ constructor(message, fields = {}, requestId) {
112
+ super(message, 422, requestId);
113
+ this.name = "ValidationError";
114
+ this.fields = fields;
115
+ }
116
+ };
117
+ var ForbiddenError = class extends HospitableError {
118
+ constructor(message = "Forbidden", requestId) {
119
+ super(message, 403, requestId);
120
+ this.name = "ForbiddenError";
121
+ }
122
+ };
123
+ var ServerError = class extends HospitableError {
124
+ constructor(message, statusCode, attempts, requestId) {
125
+ super(message, statusCode, requestId);
126
+ this.name = "ServerError";
127
+ this.attempts = attempts;
128
+ }
129
+ };
130
+ function createErrorFromResponse(statusCode, body, requestId, attempts = 1) {
131
+ const message = body["message"] ?? `HTTP ${statusCode}`;
132
+ switch (statusCode) {
133
+ case 401:
134
+ return new AuthenticationError(message, requestId);
135
+ case 403:
136
+ return new ForbiddenError(message, requestId);
137
+ case 404:
138
+ return new NotFoundError(message, requestId);
139
+ case 422: {
140
+ const errors = body["errors"] ?? {};
141
+ return new ValidationError(message, errors, requestId);
142
+ }
143
+ case 429: {
144
+ const retryAfter = body["retryAfter"] ?? 60;
145
+ return new RateLimitError(retryAfter, requestId);
146
+ }
147
+ default:
148
+ return new ServerError(message, statusCode, attempts, requestId);
149
+ }
150
+ }
151
+
152
+ // src/http/retry.ts
153
+ var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
154
+ function jitteredDelay(base, attempt, max) {
155
+ const exponential = Math.min(base * Math.pow(2, attempt - 1), max);
156
+ const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
157
+ return Math.max(0, exponential + jitter);
158
+ }
159
+ function sleep(ms) {
160
+ return new Promise((resolve) => setTimeout(resolve, ms));
161
+ }
162
+ async function withRetry(fn, endpoint, config = {}) {
163
+ const {
164
+ maxAttempts = 4,
165
+ baseDelay = 1e3,
166
+ maxDelay = 6e4,
167
+ onRateLimit
168
+ } = config;
169
+ let lastError;
170
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
171
+ try {
172
+ return await fn();
173
+ } catch (error) {
174
+ lastError = error;
175
+ const statusCode2 = getStatusCode(error);
176
+ if (statusCode2 === null || !RETRYABLE_STATUS_CODES.has(statusCode2)) {
177
+ throw error;
178
+ }
179
+ if (attempt === maxAttempts) {
180
+ break;
181
+ }
182
+ let delay;
183
+ if (statusCode2 === 429 && error instanceof Error) {
184
+ const retryAfter = extractRetryAfter(error);
185
+ delay = retryAfter > 0 ? retryAfter * 1e3 : jitteredDelay(baseDelay, attempt, maxDelay);
186
+ onRateLimit?.({ retryAfter, endpoint, attempt });
187
+ } else {
188
+ delay = jitteredDelay(baseDelay, attempt, maxDelay);
189
+ }
190
+ await sleep(delay);
191
+ }
192
+ }
193
+ const statusCode = getStatusCode(lastError) ?? 500;
194
+ const message = lastError instanceof Error ? lastError.message : `Request failed after ${maxAttempts} attempts`;
195
+ throw new ServerError(message, statusCode, maxAttempts);
196
+ }
197
+ function getStatusCode(error) {
198
+ if (error != null && typeof error === "object" && "statusCode" in error) {
199
+ const code = error.statusCode;
200
+ if (typeof code === "number") return code;
201
+ }
202
+ return null;
203
+ }
204
+ function extractRetryAfter(error) {
205
+ if ("retryAfter" in error && typeof error.retryAfter === "number") {
206
+ return error.retryAfter;
207
+ }
208
+ return 60;
209
+ }
210
+
211
+ // src/utils/sanitize.ts
212
+ var PII_FIELD_PATTERN = /^(email|phone|firstName|lastName|passportNumber|fullName|dateOfBirth)$/i;
213
+ var SENSITIVE_PATTERN = /token|secret|password|credential|apiKey|api_key/i;
214
+ function sanitize(value, depth = 0) {
215
+ if (depth > 10) return value;
216
+ if (value === null || typeof value !== "object") return value;
217
+ if (Array.isArray(value)) return value.map((item) => sanitize(item, depth + 1));
218
+ const result = {};
219
+ for (const [key, val] of Object.entries(value)) {
220
+ if (PII_FIELD_PATTERN.test(key) || SENSITIVE_PATTERN.test(key)) {
221
+ result[key] = "***";
222
+ } else {
223
+ result[key] = sanitize(val, depth + 1);
224
+ }
225
+ }
226
+ return result;
227
+ }
228
+
229
+ // src/http/client.ts
230
+ var HttpError = class extends Error {
231
+ constructor(statusCode, message, requestId, body, attempts = 1) {
232
+ super(message);
233
+ this.statusCode = statusCode;
234
+ this.requestId = requestId;
235
+ this.body = body;
236
+ this.attempts = attempts;
237
+ this.name = "HttpError";
238
+ }
239
+ };
240
+ function buildURL(base, path, params) {
241
+ const url = new URL(path, base);
242
+ if (params) {
243
+ for (const [key, value] of Object.entries(params)) {
244
+ if (value === void 0) continue;
245
+ if (Array.isArray(value)) {
246
+ value.forEach((v) => url.searchParams.append(key, v));
247
+ } else {
248
+ url.searchParams.set(key, String(value));
249
+ }
250
+ }
251
+ }
252
+ return url.toString();
253
+ }
254
+ var HttpClient = class {
255
+ constructor(config) {
256
+ this.config = config;
257
+ }
258
+ async request(path, options = {}) {
259
+ const { method = "GET", params, body, headers: extraHeaders = {} } = options;
260
+ const url = buildURL(this.config.baseURL, path, params);
261
+ return withRetry(
262
+ async () => {
263
+ const authHeader = await this.config.getAuthHeader();
264
+ const headers = {
265
+ "Content-Type": "application/json",
266
+ Accept: "application/json",
267
+ Authorization: authHeader,
268
+ "User-Agent": `hospitable-ts/${VERSION}`,
269
+ ...extraHeaders
270
+ };
271
+ if (this.config.debug) {
272
+ console.debug(`[hospitable] ${method} ${url}`);
273
+ if (body !== void 0) {
274
+ console.debug("[hospitable] body:", sanitize(body));
275
+ }
276
+ }
277
+ const response = await fetch(url, {
278
+ method,
279
+ headers,
280
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
281
+ });
282
+ const requestId = response.headers.get("x-request-id") ?? void 0;
283
+ if (!response.ok) {
284
+ let errorBody = {};
285
+ try {
286
+ errorBody = await response.json();
287
+ } catch {
288
+ }
289
+ const message = errorBody["message"] ?? `HTTP ${response.status}`;
290
+ if (this.config.debug) {
291
+ console.debug("[hospitable] error body:", sanitize(errorBody));
292
+ }
293
+ if (response.status === 401 && this.config.onUnauthorized) {
294
+ await this.config.onUnauthorized();
295
+ const freshAuth = await this.config.getAuthHeader();
296
+ const retryResponse = await fetch(url, {
297
+ method,
298
+ headers: { ...headers, Authorization: freshAuth },
299
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
300
+ });
301
+ if (retryResponse.ok) {
302
+ if (retryResponse.status === 204) return void 0;
303
+ return retryResponse.json();
304
+ }
305
+ let retryBody = {};
306
+ try {
307
+ retryBody = await retryResponse.json();
308
+ } catch {
309
+ }
310
+ const retryMessage = retryBody["message"] ?? `HTTP ${retryResponse.status}`;
311
+ throw new HttpError(retryResponse.status, retryMessage, retryResponse.headers.get("x-request-id") ?? void 0, retryBody);
312
+ }
313
+ throw new HttpError(response.status, message, requestId, errorBody);
314
+ }
315
+ if (response.status === 204) {
316
+ return void 0;
317
+ }
318
+ return response.json();
319
+ },
320
+ url,
321
+ this.config.retryConfig
322
+ );
323
+ }
324
+ get(path, params) {
325
+ return this.request(path, { method: "GET", ...params !== void 0 ? { params } : {} });
326
+ }
327
+ post(path, body) {
328
+ return this.request(path, { method: "POST", body });
329
+ }
330
+ put(path, body) {
331
+ return this.request(path, { method: "PUT", body });
332
+ }
333
+ patch(path, body) {
334
+ return this.request(path, { method: "PATCH", body });
335
+ }
336
+ delete(path) {
337
+ return this.request(path, { method: "DELETE" });
338
+ }
339
+ };
340
+
341
+ // src/resources/calendar.ts
342
+ var CalendarResource = class {
343
+ constructor(http) {
344
+ this.http = http;
345
+ }
346
+ async get(propertyId, startDate, endDate) {
347
+ return this.http.get(
348
+ `/v2/properties/${propertyId}/calendar`,
349
+ { startDate, endDate }
350
+ );
351
+ }
352
+ async update(propertyId, updates) {
353
+ await this.http.put(`/v2/properties/${propertyId}/calendar`, { data: updates });
354
+ }
355
+ async block(propertyId, startDate, endDate, reason) {
356
+ const body = { startDate, endDate };
357
+ if (reason !== void 0) body["reason"] = reason;
358
+ await this.http.post(`/v2/properties/${propertyId}/calendar/block`, body);
359
+ }
360
+ async unblock(propertyId, startDate, endDate) {
361
+ await this.http.post(`/v2/properties/${propertyId}/calendar/unblock`, {
362
+ startDate,
363
+ endDate
364
+ });
365
+ }
366
+ };
367
+
368
+ // src/resources/messages.ts
369
+ var MessagesResource = class {
370
+ constructor(http) {
371
+ this.http = http;
372
+ }
373
+ async list(reservationId) {
374
+ return this.http.get(`/v2/reservations/${reservationId}/messages`);
375
+ }
376
+ async send(reservationId, body) {
377
+ const payload = { body };
378
+ return this.http.post(`/v2/reservations/${reservationId}/messages`, payload);
379
+ }
380
+ async listTemplates() {
381
+ const response = await this.http.get("/v2/message-templates");
382
+ return response.data;
383
+ }
384
+ async sendTemplate(reservationId, templateId, variables = {}) {
385
+ return this.http.post(
386
+ `/v2/reservations/${reservationId}/messages/template`,
387
+ { templateId, variables }
388
+ );
389
+ }
390
+ };
391
+
392
+ // src/resources/properties.ts
393
+ var PropertiesResource = class {
394
+ constructor(http) {
395
+ this.http = http;
396
+ }
397
+ async list(params = {}) {
398
+ return this.http.get("/v2/properties", params);
399
+ }
400
+ async get(id) {
401
+ return this.http.get(`/v2/properties/${id}`);
402
+ }
403
+ async listTags(id) {
404
+ const response = await this.http.get(`/v2/properties/${id}/tags`);
405
+ return response.data;
406
+ }
407
+ async getCalendar(id, startDate, endDate) {
408
+ return this.http.get(`/v2/properties/${id}/calendar`, {
409
+ startDate,
410
+ endDate
411
+ });
412
+ }
413
+ async updateCalendar(id, updates) {
414
+ await this.http.put(`/v2/properties/${id}/calendar`, { data: updates });
415
+ }
416
+ async *iter(params = {}) {
417
+ let cursor = null;
418
+ do {
419
+ const listParams = { ...params };
420
+ if (cursor !== null) {
421
+ listParams.cursor = cursor;
422
+ }
423
+ const page = await this.list(listParams);
424
+ for (const item of page.data) {
425
+ yield item;
426
+ }
427
+ cursor = page.meta.nextCursor;
428
+ } while (cursor !== null);
429
+ }
430
+ };
431
+
432
+ // src/resources/reservations.ts
433
+ var ReservationsResource = class {
434
+ constructor(http) {
435
+ this.http = http;
436
+ }
437
+ async list(params = {}) {
438
+ const normalized = {
439
+ ...params,
440
+ properties: params.properties,
441
+ status: Array.isArray(params.status) ? params.status.join(",") : params.status
442
+ };
443
+ return this.http.get("/v2/reservations", normalized);
444
+ }
445
+ async get(id, include) {
446
+ return this.http.get(`/v2/reservations/${id}`, include ? { include } : void 0);
447
+ }
448
+ async getUpcoming(propertyIds, options = {}) {
449
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
450
+ return this.list({
451
+ properties: propertyIds,
452
+ startDate: today,
453
+ status: "confirmed",
454
+ include: options.include ?? "guest,properties"
455
+ });
456
+ }
457
+ async *iter(params = {}) {
458
+ let cursor;
459
+ do {
460
+ const pageParams = { ...params };
461
+ if (cursor !== void 0) pageParams.cursor = cursor;
462
+ const page = await this.list(pageParams);
463
+ for (const item of page.data) {
464
+ yield item;
465
+ }
466
+ cursor = page.meta.nextCursor ?? void 0;
467
+ } while (cursor !== void 0);
468
+ }
469
+ };
470
+
471
+ // src/resources/reviews.ts
472
+ var ReviewsResource = class {
473
+ constructor(http) {
474
+ this.http = http;
475
+ }
476
+ async list(params = {}) {
477
+ const normalized = {
478
+ propertyId: params.propertyId,
479
+ responded: params.responded,
480
+ cursor: params.cursor,
481
+ perPage: params.perPage
482
+ };
483
+ return this.http.get("/v2/reviews", normalized);
484
+ }
485
+ async get(id) {
486
+ return this.http.get(`/v2/reviews/${id}`);
487
+ }
488
+ async respond(id, responseText) {
489
+ return this.http.post(`/v2/reviews/${id}/response`, { response: responseText });
490
+ }
491
+ async *iter(params = {}) {
492
+ let cursor = null;
493
+ do {
494
+ const pageParams = { ...params };
495
+ if (cursor !== null) pageParams.cursor = cursor;
496
+ const page = await this.list(pageParams);
497
+ for (const item of page.data) yield item;
498
+ cursor = page.meta.nextCursor;
499
+ } while (cursor !== null);
500
+ }
501
+ };
502
+
503
+ // src/client.ts
504
+ var HospitableClient = class {
505
+ constructor(config = {}) {
506
+ const baseURL = config.baseURL ?? "https://api.hospitable.com";
507
+ const tokenConfig = {
508
+ ...config.token !== void 0 ? { token: config.token } : {},
509
+ ...config.refreshToken !== void 0 ? { refreshToken: config.refreshToken } : {},
510
+ ...config.clientId !== void 0 ? { clientId: config.clientId } : {},
511
+ ...config.clientSecret !== void 0 ? { clientSecret: config.clientSecret } : {},
512
+ baseURL
513
+ };
514
+ const tokenManager = new TokenManager(tokenConfig);
515
+ const httpClient = new HttpClient({
516
+ baseURL,
517
+ getAuthHeader: () => tokenManager.getAuthHeader(),
518
+ onUnauthorized: () => tokenManager.handleUnauthorized(),
519
+ ...config.debug !== void 0 ? { debug: config.debug } : {},
520
+ ...config.retry !== void 0 ? { retryConfig: config.retry } : {}
521
+ });
522
+ this.properties = new PropertiesResource(httpClient);
523
+ this.reservations = new ReservationsResource(httpClient);
524
+ this.calendar = new CalendarResource(httpClient);
525
+ this.messages = new MessagesResource(httpClient);
526
+ this.reviews = new ReviewsResource(httpClient);
527
+ }
528
+ };
529
+
530
+ // src/http/paginate.ts
531
+ async function* paginate(fetcher, params, perPage = 100) {
532
+ let cursor = null;
533
+ do {
534
+ const page = await fetcher({
535
+ ...params,
536
+ cursor: cursor ?? void 0,
537
+ perPage
538
+ });
539
+ for (const item of page.data) {
540
+ yield item;
541
+ }
542
+ cursor = page.meta.nextCursor;
543
+ } while (cursor !== null);
544
+ }
545
+ async function collectAll(fetcher, params, perPage = 100) {
546
+ const results = [];
547
+ for await (const item of paginate(fetcher, params, perPage)) {
548
+ results.push(item);
549
+ }
550
+ return results;
551
+ }
552
+
553
+ // src/filters/reservation-filter.ts
554
+ var ReservationFilter = class _ReservationFilter {
555
+ constructor(params = {}) {
556
+ this.params = params;
557
+ }
558
+ checkinAfter(date) {
559
+ return new _ReservationFilter({ ...this.params, startDate: date });
560
+ }
561
+ checkinBefore(date) {
562
+ return new _ReservationFilter({ ...this.params, endDate: date });
563
+ }
564
+ status(status) {
565
+ return new _ReservationFilter({ ...this.params, status });
566
+ }
567
+ properties(ids) {
568
+ return new _ReservationFilter({ ...this.params, properties: ids });
569
+ }
570
+ include(...fields) {
571
+ return new _ReservationFilter({ ...this.params, include: fields.join(",") });
572
+ }
573
+ perPage(n) {
574
+ return new _ReservationFilter({ ...this.params, perPage: n });
575
+ }
576
+ toParams() {
577
+ return { ...this.params };
578
+ }
579
+ };
580
+
581
+ // src/filters/property-filter.ts
582
+ var PropertyFilter = class _PropertyFilter {
583
+ constructor(params = {}) {
584
+ this.params = params;
585
+ }
586
+ tags(tagIds) {
587
+ return new _PropertyFilter({ ...this.params, tags: tagIds });
588
+ }
589
+ perPage(n) {
590
+ return new _PropertyFilter({ ...this.params, perPage: n });
591
+ }
592
+ toParams() {
593
+ return { ...this.params };
594
+ }
595
+ };
596
+
597
+ // src/index.ts
598
+ var VERSION = "0.1.0";
599
+ export {
600
+ AuthenticationError,
601
+ CalendarResource,
602
+ ForbiddenError,
603
+ HospitableClient,
604
+ HospitableError,
605
+ MessagesResource,
606
+ NotFoundError,
607
+ PropertiesResource,
608
+ PropertyFilter,
609
+ RateLimitError,
610
+ ReservationFilter,
611
+ ReservationsResource,
612
+ ReviewsResource,
613
+ ServerError,
614
+ TokenManager,
615
+ VERSION,
616
+ ValidationError,
617
+ collectAll,
618
+ createErrorFromResponse,
619
+ paginate,
620
+ sanitize
621
+ };
622
+ //# sourceMappingURL=index.js.map