@aura-labs-ai/scout 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/src/session.js ADDED
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Scout Session
3
+ *
4
+ * Represents an active commerce session with AURA Core.
5
+ * Handles offer polling, constraint evaluation, and transaction commitment.
6
+ */
7
+
8
+ import { SessionError, OfferError, ConstraintError } from './errors.js';
9
+ import { ScoutActivityEventTypes } from './activity.js';
10
+
11
+ /**
12
+ * Session states
13
+ */
14
+ export const SessionStatus = {
15
+ CREATED: 'created',
16
+ MARKET_FORMING: 'market_forming',
17
+ OFFERS_AVAILABLE: 'offers_available',
18
+ NEGOTIATING: 'negotiating',
19
+ COMMITTED: 'committed',
20
+ COMPLETED: 'completed',
21
+ CANCELLED: 'cancelled',
22
+ EXPIRED: 'expired',
23
+ };
24
+
25
+ /**
26
+ * Commerce Session
27
+ */
28
+ export class Session {
29
+ #data;
30
+ #client;
31
+ #config;
32
+ #offers = [];
33
+ #constraints;
34
+ #pollInterval = null;
35
+ #activityLogger = null;
36
+
37
+ constructor(data, client, config) {
38
+ this.#data = data;
39
+ this.#client = client;
40
+ this.#config = config;
41
+ this.#constraints = new Constraints(config.constraints || {});
42
+ this.#activityLogger = config.activityLogger || null;
43
+
44
+ if (this.#activityLogger) {
45
+ this.#activityLogger.record(ScoutActivityEventTypes.SESSION_CREATED, {
46
+ sessionId: this.id,
47
+ metadata: {
48
+ status: this.status,
49
+ },
50
+ });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Session ID
56
+ */
57
+ get id() {
58
+ return this.#data.sessionId;
59
+ }
60
+
61
+ /**
62
+ * Current session status
63
+ */
64
+ get status() {
65
+ return this.#data.status;
66
+ }
67
+
68
+ /**
69
+ * Parsed intent (as interpreted by Core)
70
+ */
71
+ get intent() {
72
+ return this.#data.intent;
73
+ }
74
+
75
+ /**
76
+ * Available HATEOAS links
77
+ */
78
+ get links() {
79
+ return this.#data._links || {};
80
+ }
81
+
82
+ /**
83
+ * Is session still active?
84
+ */
85
+ get isActive() {
86
+ return ![
87
+ SessionStatus.COMPLETED,
88
+ SessionStatus.CANCELLED,
89
+ SessionStatus.EXPIRED,
90
+ ].includes(this.status);
91
+ }
92
+
93
+ /**
94
+ * Get current offers
95
+ */
96
+ get offers() {
97
+ return this.#offers;
98
+ }
99
+
100
+ /**
101
+ * Refresh session state from Core
102
+ */
103
+ async refresh() {
104
+ this.#data = await this.#client.get(`/sessions/${this.id}`);
105
+
106
+ if (this.#activityLogger) {
107
+ this.#activityLogger.record(ScoutActivityEventTypes.SESSION_REFRESHED, {
108
+ sessionId: this.id,
109
+ metadata: {
110
+ status: this.status,
111
+ },
112
+ });
113
+ }
114
+
115
+ return this;
116
+ }
117
+
118
+ /**
119
+ * Poll for offers until available or timeout
120
+ *
121
+ * @param {object} options
122
+ * @param {number} options.timeout - Max time to wait in ms (default: 30000)
123
+ * @param {number} options.interval - Poll interval in ms (default: 2000)
124
+ * @returns {Promise<Offer[]>}
125
+ */
126
+ async waitForOffers(options = {}) {
127
+ const { timeout = 30000, interval = 2000 } = options;
128
+ const startTime = Date.now();
129
+
130
+ const finish = this.#activityLogger?.startTimer(ScoutActivityEventTypes.WAIT_FOR_OFFERS, {
131
+ sessionId: this.id,
132
+ metadata: { timeout, interval },
133
+ });
134
+
135
+ try {
136
+ while (Date.now() - startTime < timeout) {
137
+ await this.refresh();
138
+
139
+ if (this.status === SessionStatus.OFFERS_AVAILABLE) {
140
+ this.#offers = await this.#fetchOffers();
141
+
142
+ finish?.({
143
+ success: true,
144
+ metadata: {
145
+ offersCount: this.#offers.length,
146
+ },
147
+ });
148
+
149
+ return this.#offers;
150
+ }
151
+
152
+ if (!this.isActive) {
153
+ throw new SessionError(`Session is no longer active: ${this.status}`);
154
+ }
155
+
156
+ await this.#sleep(interval);
157
+ }
158
+
159
+ throw new SessionError('Timed out waiting for offers', 'TIMEOUT');
160
+ } catch (error) {
161
+ this.#activityLogger?.record(ScoutActivityEventTypes.WAIT_FOR_OFFERS_FAILED, {
162
+ sessionId: this.id,
163
+ success: false,
164
+ error: error.message,
165
+ });
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Fetch current offers from Core
172
+ */
173
+ async #fetchOffers() {
174
+ if (!this.links.offers) {
175
+ return [];
176
+ }
177
+
178
+ const response = await this.#client.get(`/sessions/${this.id}/offers`);
179
+ return (response.offers || []).map(o => new Offer(o, this.#constraints));
180
+ }
181
+
182
+ /**
183
+ * Get offers that satisfy all constraints
184
+ */
185
+ get validOffers() {
186
+ return this.#offers.filter(o => o.meetsConstraints);
187
+ }
188
+
189
+ /**
190
+ * Get best offer based on constraint ranking
191
+ */
192
+ get bestOffer() {
193
+ const valid = this.validOffers;
194
+ if (valid.length === 0) return null;
195
+
196
+ return valid.reduce((best, offer) =>
197
+ offer.score > best.score ? offer : best
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Commit to an offer
203
+ *
204
+ * @param {string} offerId - ID of the offer to commit to
205
+ * @returns {Promise<Transaction>}
206
+ */
207
+ async commit(offerId) {
208
+ const offer = this.#offers.find(o => o.id === offerId);
209
+
210
+ if (!offer) {
211
+ throw new OfferError(`Offer not found: ${offerId}`, offerId);
212
+ }
213
+
214
+ if (!offer.meetsConstraints) {
215
+ const violations = offer.constraintViolations;
216
+ throw new ConstraintError(
217
+ `Offer violates constraints: ${violations.join(', ')}`,
218
+ violations[0]
219
+ );
220
+ }
221
+
222
+ const finish = this.#activityLogger?.startTimer(ScoutActivityEventTypes.SESSION_COMMITTED, {
223
+ sessionId: this.id,
224
+ metadata: {
225
+ offerId,
226
+ offerPrice: offer.totalPrice,
227
+ },
228
+ });
229
+
230
+ try {
231
+ const response = await this.#client.post(`/sessions/${this.id}/commit`, {
232
+ offerId,
233
+ });
234
+
235
+ this.#data.status = SessionStatus.COMMITTED;
236
+
237
+ const transaction = new Transaction(response, this.#client, this.#activityLogger);
238
+
239
+ finish?.({
240
+ success: true,
241
+ metadata: {
242
+ transactionId: transaction.id,
243
+ },
244
+ });
245
+
246
+ return transaction;
247
+ } catch (error) {
248
+ this.#activityLogger?.record(ScoutActivityEventTypes.SESSION_COMMIT_FAILED, {
249
+ sessionId: this.id,
250
+ success: false,
251
+ error: error.message,
252
+ metadata: { offerId },
253
+ });
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Cancel the session
260
+ */
261
+ async cancel() {
262
+ try {
263
+ await this.#client.post(`/sessions/${this.id}/cancel`);
264
+ this.#data.status = SessionStatus.CANCELLED;
265
+
266
+ this.#activityLogger?.record(ScoutActivityEventTypes.SESSION_CANCELLED, {
267
+ sessionId: this.id,
268
+ success: true,
269
+ });
270
+ } catch (error) {
271
+ this.#activityLogger?.record(ScoutActivityEventTypes.SESSION_CANCEL_FAILED, {
272
+ sessionId: this.id,
273
+ success: false,
274
+ error: error.message,
275
+ });
276
+ throw error;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Update constraints for this session
282
+ */
283
+ updateConstraints(constraints) {
284
+ this.#constraints = new Constraints({
285
+ ...this.#constraints.raw,
286
+ ...constraints,
287
+ });
288
+ }
289
+
290
+ #sleep(ms) {
291
+ return new Promise(resolve => setTimeout(resolve, ms));
292
+ }
293
+
294
+ /**
295
+ * Convert to JSON for serialization
296
+ */
297
+ toJSON() {
298
+ return {
299
+ id: this.id,
300
+ status: this.status,
301
+ intent: this.intent,
302
+ offers: this.#offers.map(o => o.toJSON()),
303
+ links: this.links,
304
+ };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Constraints Engine
310
+ *
311
+ * Evaluates offers against user-defined constraints.
312
+ */
313
+ export class Constraints {
314
+ #constraints;
315
+
316
+ constructor(constraints = {}) {
317
+ this.#constraints = {
318
+ maxBudget: constraints.maxBudget || null,
319
+ deliveryBy: constraints.deliveryBy ? new Date(constraints.deliveryBy) : null,
320
+ hardConstraints: constraints.hardConstraints || [],
321
+ softPreferences: constraints.softPreferences || [],
322
+ ...constraints,
323
+ };
324
+ }
325
+
326
+ get raw() {
327
+ return { ...this.#constraints };
328
+ }
329
+
330
+ /**
331
+ * Check if offer meets all hard constraints
332
+ */
333
+ meetsConstraints(offer) {
334
+ return this.getViolations(offer).length === 0;
335
+ }
336
+
337
+ /**
338
+ * Get list of constraint violations
339
+ */
340
+ getViolations(offer) {
341
+ const violations = [];
342
+
343
+ // Check budget
344
+ if (this.#constraints.maxBudget !== null) {
345
+ if (offer.totalPrice > this.#constraints.maxBudget) {
346
+ violations.push(`price_exceeds_budget:${offer.totalPrice}>${this.#constraints.maxBudget}`);
347
+ }
348
+ }
349
+
350
+ // Check delivery date
351
+ if (this.#constraints.deliveryBy !== null) {
352
+ if (offer.deliveryDate && new Date(offer.deliveryDate) > this.#constraints.deliveryBy) {
353
+ violations.push(`delivery_too_late:${offer.deliveryDate}`);
354
+ }
355
+ }
356
+
357
+ // Check custom hard constraints
358
+ for (const constraint of this.#constraints.hardConstraints) {
359
+ if (!this.#evaluateConstraint(constraint, offer)) {
360
+ violations.push(constraint.name || constraint.field);
361
+ }
362
+ }
363
+
364
+ return violations;
365
+ }
366
+
367
+ /**
368
+ * Score offer based on soft preferences (0-100)
369
+ */
370
+ score(offer) {
371
+ let score = 50; // Base score
372
+
373
+ // Budget utilization (prefer lower prices)
374
+ if (this.#constraints.maxBudget && offer.totalPrice) {
375
+ const utilizationRatio = offer.totalPrice / this.#constraints.maxBudget;
376
+ score += (1 - utilizationRatio) * 20; // Up to +20 for lower prices
377
+ }
378
+
379
+ // Delivery speed (prefer earlier delivery)
380
+ if (this.#constraints.deliveryBy && offer.deliveryDate) {
381
+ const deliveryDate = new Date(offer.deliveryDate);
382
+ const deadline = this.#constraints.deliveryBy;
383
+ const now = new Date();
384
+ const totalWindow = deadline - now;
385
+ const actualWindow = deliveryDate - now;
386
+
387
+ if (totalWindow > 0 && actualWindow < totalWindow) {
388
+ score += ((totalWindow - actualWindow) / totalWindow) * 15; // Up to +15 for faster delivery
389
+ }
390
+ }
391
+
392
+ // Soft preferences
393
+ for (const pref of this.#constraints.softPreferences) {
394
+ if (this.#evaluateConstraint(pref, offer)) {
395
+ score += pref.weight || 5;
396
+ }
397
+ }
398
+
399
+ // Clamp to 0-100
400
+ return Math.max(0, Math.min(100, score));
401
+ }
402
+
403
+ // SECURITY: Operator allowlist — unknown operators fail closed (reject, not accept)
404
+ static #ALLOWED_OPERATORS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']);
405
+
406
+ #evaluateConstraint(constraint, offer) {
407
+ // Validate constraint has required fields
408
+ if (!constraint.field || typeof constraint.field !== 'string') {
409
+ return false; // Malformed constraint → fail closed
410
+ }
411
+ if (!constraint.operator || !Constraints.#ALLOWED_OPERATORS.has(constraint.operator)) {
412
+ return false; // Unknown/missing operator → fail closed
413
+ }
414
+
415
+ const value = offer[constraint.field];
416
+
417
+ switch (constraint.operator) {
418
+ case 'eq': return value === constraint.value;
419
+ case 'ne': return value !== constraint.value;
420
+ case 'gt': return value > constraint.value;
421
+ case 'gte': return value >= constraint.value;
422
+ case 'lt': return value < constraint.value;
423
+ case 'lte': return value <= constraint.value;
424
+ case 'contains': return String(value).includes(String(constraint.value));
425
+ case 'in': return Array.isArray(constraint.value) && constraint.value.includes(value);
426
+ default: return false; // Unreachable, but fail closed
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Offer from a Beacon
433
+ */
434
+ export class Offer {
435
+ #data;
436
+ #constraints;
437
+
438
+ constructor(data, constraints) {
439
+ this.#data = data;
440
+ this.#constraints = constraints;
441
+ }
442
+
443
+ get id() { return this.#data.id; }
444
+ get beaconId() { return this.#data.beaconId; }
445
+ get beaconName() { return this.#data.beaconName; }
446
+ get product() { return this.#data.product; }
447
+ get unitPrice() { return this.#data.unitPrice; }
448
+ get quantity() { return this.#data.quantity; }
449
+ get totalPrice() { return this.#data.totalPrice || (this.unitPrice * this.quantity); }
450
+ get currency() { return this.#data.currency || 'USD'; }
451
+ get deliveryDate() { return this.#data.deliveryDate; }
452
+ get terms() { return this.#data.terms || {}; }
453
+ get metadata() { return this.#data.metadata || {}; }
454
+
455
+ get meetsConstraints() {
456
+ return this.#constraints.meetsConstraints(this.#data);
457
+ }
458
+
459
+ get constraintViolations() {
460
+ return this.#constraints.getViolations(this.#data);
461
+ }
462
+
463
+ get score() {
464
+ return this.#constraints.score(this.#data);
465
+ }
466
+
467
+ toJSON() {
468
+ return {
469
+ ...this.#data,
470
+ meetsConstraints: this.meetsConstraints,
471
+ constraintViolations: this.constraintViolations,
472
+ score: this.score,
473
+ };
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Committed Transaction
479
+ */
480
+ export class Transaction {
481
+ #data;
482
+ #client;
483
+ #activityLogger;
484
+
485
+ constructor(data, client, activityLogger = null) {
486
+ this.#data = data;
487
+ this.#client = client;
488
+ this.#activityLogger = activityLogger;
489
+
490
+ if (this.#activityLogger) {
491
+ this.#activityLogger.record(ScoutActivityEventTypes.TRANSACTION_CREATED, {
492
+ transactionId: this.id,
493
+ metadata: {
494
+ status: this.status,
495
+ paymentStatus: this.paymentStatus,
496
+ fulfillmentStatus: this.fulfillmentStatus,
497
+ },
498
+ });
499
+ }
500
+ }
501
+
502
+ get id() { return this.#data.transactionId; }
503
+ get status() { return this.#data.status; }
504
+ get offerId() { return this.#data.offerId; }
505
+ get paymentStatus() { return this.#data.paymentStatus; }
506
+ get fulfillmentStatus() { return this.#data.fulfillmentStatus; }
507
+
508
+ /**
509
+ * Refresh transaction state from Core
510
+ *
511
+ * @returns {Promise<Transaction>} Returns this for chaining
512
+ */
513
+ async refresh() {
514
+ this.#data = await this.#client.get(`/transactions/${this.id}`);
515
+
516
+ if (this.#activityLogger) {
517
+ this.#activityLogger.record(ScoutActivityEventTypes.TRANSACTION_REFRESHED, {
518
+ transactionId: this.id,
519
+ metadata: {
520
+ status: this.status,
521
+ paymentStatus: this.paymentStatus,
522
+ fulfillmentStatus: this.fulfillmentStatus,
523
+ },
524
+ });
525
+ }
526
+
527
+ return this;
528
+ }
529
+
530
+ /**
531
+ * Wait for transaction fulfillment
532
+ *
533
+ * @param {object} options
534
+ * @param {number} options.timeout - Max time to wait in ms (default: 300000 / 5 min)
535
+ * @param {number} options.interval - Poll interval in ms (default: 5000)
536
+ * @returns {Promise<Transaction>} Returns this when fulfilled
537
+ * @throws {Error} If fulfillment fails or timeout exceeded
538
+ */
539
+ async waitForFulfillment(options = {}) {
540
+ const { timeout = 300000, interval = 5000 } = options;
541
+ const startTime = Date.now();
542
+
543
+ const finish = this.#activityLogger?.startTimer(ScoutActivityEventTypes.WAIT_FOR_FULFILLMENT, {
544
+ transactionId: this.id,
545
+ metadata: { timeout, interval },
546
+ });
547
+
548
+ try {
549
+ while (Date.now() - startTime < timeout) {
550
+ await this.refresh();
551
+
552
+ if (this.fulfillmentStatus === 'delivered') {
553
+ finish?.({
554
+ success: true,
555
+ metadata: {
556
+ fulfillmentStatus: this.fulfillmentStatus,
557
+ },
558
+ });
559
+ return this;
560
+ }
561
+
562
+ if (this.fulfillmentStatus === 'failed') {
563
+ throw new Error(`Transaction fulfillment failed: ${this.id}`);
564
+ }
565
+
566
+ await this.#sleep(interval);
567
+ }
568
+
569
+ throw new Error(`Timed out waiting for transaction fulfillment: ${this.id}`);
570
+ } catch (error) {
571
+ this.#activityLogger?.record(ScoutActivityEventTypes.WAIT_FOR_FULFILLMENT_FAILED, {
572
+ transactionId: this.id,
573
+ success: false,
574
+ error: error.message,
575
+ metadata: {
576
+ fulfillmentStatus: this.fulfillmentStatus,
577
+ },
578
+ });
579
+ throw error;
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Get beacon information
585
+ */
586
+ get beacon() {
587
+ return {
588
+ id: this.#data.beaconId,
589
+ name: this.#data.beaconName,
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Convert to JSON for serialization
595
+ */
596
+ toJSON() {
597
+ return { ...this.#data };
598
+ }
599
+
600
+ #sleep(ms) {
601
+ return new Promise(resolve => setTimeout(resolve, ms));
602
+ }
603
+ }