@agentsbloom/sdk 0.2.0 → 0.4.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/LICENSE +21 -21
- package/README.md +184 -254
- package/SECURITY.md +24 -19
- package/index.d.ts +95 -2
- package/index.js +1994 -1111
- package/lib/ap2.js +153 -38
- package/lib/outcomes.js +108 -0
- package/lib/shared-store.js +172 -0
- package/package.json +85 -81
- package/telemetry.js +24 -17
- package/assets/logo-mark.svg +0 -25
package/index.js
CHANGED
|
@@ -1,1112 +1,1995 @@
|
|
|
1
|
-
import zlib from 'zlib';
|
|
2
|
-
import crypto from 'crypto';
|
|
3
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
5
|
-
import { trace, metrics, ValueType, context, propagation } from '@opentelemetry/api';
|
|
6
|
-
import { initExporter } from './telemetry.js';
|
|
7
|
-
import {
|
|
8
|
-
verifyAp2Mandate,
|
|
9
|
-
createAp2Mandate,
|
|
10
|
-
didKeyFromEd25519PublicKey,
|
|
11
|
-
ed25519PublicKeyFromDidKey,
|
|
12
|
-
resetAp2ReplayCache,
|
|
13
|
-
stopAp2ReplayCleanup,
|
|
14
|
-
} from './lib/ap2.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @param {
|
|
29
|
-
* @param {
|
|
30
|
-
* @param {string} options.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
.
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
return
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
(
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1
|
+
import zlib from 'zlib';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
5
|
+
import { trace, metrics, ValueType, context, propagation } from '@opentelemetry/api';
|
|
6
|
+
import { initExporter } from './telemetry.js';
|
|
7
|
+
import {
|
|
8
|
+
verifyAp2Mandate,
|
|
9
|
+
createAp2Mandate,
|
|
10
|
+
didKeyFromEd25519PublicKey,
|
|
11
|
+
ed25519PublicKeyFromDidKey,
|
|
12
|
+
resetAp2ReplayCache,
|
|
13
|
+
stopAp2ReplayCleanup,
|
|
14
|
+
} from './lib/ap2.js';
|
|
15
|
+
import { createReplayCache } from './lib/shared-store.js';
|
|
16
|
+
import { createOutcomeReporter, stripeEventToOutcome } from './lib/outcomes.js';
|
|
17
|
+
|
|
18
|
+
const tracer = trace.getTracer('agentsbloom-sdk');
|
|
19
|
+
const meter = metrics.getMeter('agentsbloom-sdk');
|
|
20
|
+
|
|
21
|
+
const agentRequestsCounter = meter.createCounter('agent_visits_total', { description: 'Total AI Visits' });
|
|
22
|
+
const agentRevenueCounter = meter.createCounter('agent_revenue_usd', { description: 'Total AI Revenue', valueType: ValueType.DOUBLE });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize OpenTelemetry with OTLP exporters.
|
|
26
|
+
* Call this BEFORE using the agentsbloom() middleware.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {string} options.otlpEndpoint - OTLP collector URL (default: http://localhost:4318)
|
|
30
|
+
* @param {string} options.serviceName - Service name for traces (default: agentsbloom-merchant)
|
|
31
|
+
* @param {number} options.samplingRatio - Trace sampling ratio 0.0-1.0 (default: 1.0)
|
|
32
|
+
* @param {string} options.apiKey - API key for authenticating with the collector
|
|
33
|
+
*/
|
|
34
|
+
export async function setupTelemetry(options = {}) {
|
|
35
|
+
const {
|
|
36
|
+
otlpEndpoint = process.env.AGENTSBLOOM_OTEL_ENDPOINT || 'http://localhost:4318',
|
|
37
|
+
serviceName = 'agentsbloom-merchant',
|
|
38
|
+
samplingRatio = parseFloat(process.env.AGENTSBLOOM_SAMPLING_RATIO || '1.0'),
|
|
39
|
+
apiKey = process.env.AGENTSBLOOM_API_KEY || '',
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
// Store config for lazy initialization when OTel SDK packages are available
|
|
43
|
+
globalThis.__agentsbloom_otel_config = {
|
|
44
|
+
otlpEndpoint,
|
|
45
|
+
serviceName,
|
|
46
|
+
samplingRatio,
|
|
47
|
+
apiKey,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handle = await initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey });
|
|
51
|
+
globalThis.__agentsbloom_otel_handle = handle;
|
|
52
|
+
|
|
53
|
+
console.log(`🌸 AgentsBloom: Telemetry configured (sampling: ${samplingRatio * 100}%)`);
|
|
54
|
+
return { otlpEndpoint, serviceName, samplingRatio };
|
|
55
|
+
}
|
|
56
|
+
// v4 security hardening: there is deliberately NO default RFC 9421 JWKS
|
|
57
|
+
// source anymore. The previous default pointed at a third-party provider's
|
|
58
|
+
// live key set, which silently accepted that provider's signing keys for
|
|
59
|
+
// MERCHANT write actions whenever a merchant forgot to configure their own
|
|
60
|
+
// trust root. Verification now fails closed until the merchant configures
|
|
61
|
+
// `agentJwks` (inline JWKS object) or `agentJwksUrl` (HTTPS JWKS endpoint).
|
|
62
|
+
const LEGACY_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
63
|
+
const RFC_SIGNATURE_CLOCK_SKEW_MS = 30 * 1000;
|
|
64
|
+
const SIGNATURE_REPLAY_CACHE_MAX_SIZE = 50_000;
|
|
65
|
+
const MAX_RATE_TRACKED_CLIENTS = 50_000;
|
|
66
|
+
// Second-pass review: the JWKS cache was an unswept Map keyed by
|
|
67
|
+
// (config, keyid) - attacker-controlled keyids grew it without bound, and
|
|
68
|
+
// every UNKNOWN keyid triggered a live JWKS fetch (5s timeout), turning
|
|
69
|
+
// cheap requests into outbound-request amplification. The cache is now
|
|
70
|
+
// bounded, swept, and unknown keyids are negatively cached briefly so
|
|
71
|
+
// repeated junk kids cost one lookup instead of one network fetch each.
|
|
72
|
+
const JWKS_CACHE_MAX_ENTRIES = 500;
|
|
73
|
+
const JWKS_NEGATIVE_TTL_MS = 60 * 1000;
|
|
74
|
+
const jwksCache = new Map();
|
|
75
|
+
|
|
76
|
+
/** Returns the cached JWKS entry (positive or negative) or null. Sweeps lazily. */
|
|
77
|
+
function jwksCacheGet(cacheKey) {
|
|
78
|
+
const entry = jwksCache.get(cacheKey);
|
|
79
|
+
if (!entry) return null;
|
|
80
|
+
if (entry.expires <= Date.now()) {
|
|
81
|
+
jwksCache.delete(cacheKey);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Bounded insert: sweep expired first, then evict the oldest-tracked entry. */
|
|
88
|
+
function jwksCachePut(cacheKey, entry) {
|
|
89
|
+
if (jwksCache.size >= JWKS_CACHE_MAX_ENTRIES) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
for (const [key, value] of jwksCache.entries()) {
|
|
92
|
+
if (value.expires <= now) jwksCache.delete(key);
|
|
93
|
+
}
|
|
94
|
+
if (jwksCache.size >= JWKS_CACHE_MAX_ENTRIES) {
|
|
95
|
+
const oldestKey = jwksCache.keys().next().value;
|
|
96
|
+
if (oldestKey !== undefined) jwksCache.delete(oldestKey);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
jwksCache.set(cacheKey, entry);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Escapes JSON for embedding inside an HTML <script> element. */
|
|
103
|
+
function toSafeJson(value) {
|
|
104
|
+
return JSON.stringify(value)
|
|
105
|
+
.replace(/</g, '\\u003c')
|
|
106
|
+
.replace(/>/g, '\\u003e')
|
|
107
|
+
.replace(/&/g, '\\u0026')
|
|
108
|
+
.replace(/\u2028/g, '\\u2028')
|
|
109
|
+
.replace(/\u2029/g, '\\u2029');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalizes a client key for rate limiting: strips the IPv6-mapped IPv4
|
|
114
|
+
* prefix (::ffff:127.0.0.1 -> 127.0.0.1) so the same client cannot get a
|
|
115
|
+
* fresh bucket by alternating address representations.
|
|
116
|
+
*/
|
|
117
|
+
function normalizeRateLimitKey(ip) {
|
|
118
|
+
const raw = String(ip || '');
|
|
119
|
+
return raw.startsWith('::ffff:') ? raw.slice(7) : raw;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function verifySignature(signature, payload, secret) {
|
|
123
|
+
try {
|
|
124
|
+
if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/i.test(signature)) return false;
|
|
125
|
+
const computed = crypto.createHmac('sha256', secret).update(payload).digest();
|
|
126
|
+
const provided = Buffer.from(signature, 'hex');
|
|
127
|
+
return crypto.timingSafeEqual(computed, provided);
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildLegacySignaturePayload(req, identifier, timestamp, nonce) {
|
|
134
|
+
return JSON.stringify([
|
|
135
|
+
identifier,
|
|
136
|
+
String(req.method || '').toUpperCase(),
|
|
137
|
+
req.originalUrl || req.path,
|
|
138
|
+
timestamp,
|
|
139
|
+
nonce,
|
|
140
|
+
req.body ?? null,
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildRequestContentDigest(req) {
|
|
145
|
+
let bodyBytes;
|
|
146
|
+
if (req.rawBody !== undefined) {
|
|
147
|
+
bodyBytes = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(String(req.rawBody));
|
|
148
|
+
} else if (Buffer.isBuffer(req.body)) {
|
|
149
|
+
bodyBytes = req.body;
|
|
150
|
+
} else if (req.body !== undefined) {
|
|
151
|
+
bodyBytes = Buffer.from(JSON.stringify(req.body ?? null));
|
|
152
|
+
} else {
|
|
153
|
+
const contentLength = Number(req.headers['content-length'] || 0);
|
|
154
|
+
if (contentLength > 0) {
|
|
155
|
+
throw new Error('Protected HTTP signatures require parsed request bodies or req.rawBody');
|
|
156
|
+
}
|
|
157
|
+
bodyBytes = Buffer.alloc(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const digest = crypto.createHash('sha256').update(bodyBytes).digest('base64');
|
|
161
|
+
return `sha-256=:${digest}:`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* v4: validates/coerces action parameters against the action's declared
|
|
166
|
+
* `params` type map. Shared by the REST action route AND the MCP tools/call
|
|
167
|
+
* handler - previously the MCP path passed arguments straight through with
|
|
168
|
+
* no validation at all, so the type guarantees advertised in discovery
|
|
169
|
+
* documents were not actually enforced there.
|
|
170
|
+
*
|
|
171
|
+
* @returns {{ ok: boolean, params?: object, message?: string }}
|
|
172
|
+
*/
|
|
173
|
+
function validateActionParams(rawParams, action) {
|
|
174
|
+
const params = { ...rawParams };
|
|
175
|
+
if (!action.params) return { ok: true, params };
|
|
176
|
+
for (const [key, type] of Object.entries(action.params)) {
|
|
177
|
+
const val = params[key];
|
|
178
|
+
if (val === undefined) continue;
|
|
179
|
+
if (type === 'any') continue;
|
|
180
|
+
if (type === 'number') {
|
|
181
|
+
if (typeof val === 'number' && !isNaN(val)) {
|
|
182
|
+
// valid
|
|
183
|
+
} else if (typeof val === 'string' && val.trim() !== '' && !isNaN(Number(val))) {
|
|
184
|
+
params[key] = Number(val);
|
|
185
|
+
} else {
|
|
186
|
+
return { ok: false, message: `Expected ${type} for parameter ${key}` };
|
|
187
|
+
}
|
|
188
|
+
} else if (type === 'boolean') {
|
|
189
|
+
if (typeof val === 'boolean') {
|
|
190
|
+
// valid
|
|
191
|
+
} else if (typeof val === 'string' && (val === 'true' || val === 'false' || val === '1' || val === '0')) {
|
|
192
|
+
params[key] = val === 'true' || val === '1';
|
|
193
|
+
} else {
|
|
194
|
+
return { ok: false, message: `Expected ${type} for parameter ${key}` };
|
|
195
|
+
}
|
|
196
|
+
} else if (type === 'string') {
|
|
197
|
+
if (typeof val !== 'string') {
|
|
198
|
+
return { ok: false, message: `Expected ${type} for parameter ${key}` };
|
|
199
|
+
}
|
|
200
|
+
} else if (typeof val !== type && !(typeof type === 'string' && type.includes(typeof val))) {
|
|
201
|
+
return { ok: false, message: `Expected ${type} for parameter ${key}` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, params };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// In-memory rate limiting map
|
|
208
|
+
const rateLimitMap = new Map();
|
|
209
|
+
// v4: bounded count of live MCP SSE sessions (see /mcp handler).
|
|
210
|
+
let activeMcpSessions = 0;
|
|
211
|
+
|
|
212
|
+
// --- v4 medium pass (V15): the SaaS quota gate is now WIRED ---
|
|
213
|
+
// Previously `quotaExceededUntil` was a per-instance variable initialized
|
|
214
|
+
// to 0 that nothing ever set - dead code implying enforcement that did not
|
|
215
|
+
// exist. The hosted gateway (or any operator tooling) can now trip every
|
|
216
|
+
// middleware instance in the process via setQuotaExceededUntil(), or set
|
|
217
|
+
// AGENTSBLOOM_QUOTA_EXCEEDED_UNTIL (epoch ms) in the environment.
|
|
218
|
+
let quotaExceededUntilMs = Number(process.env.AGENTSBLOOM_QUOTA_EXCEEDED_UNTIL || 0) || 0;
|
|
219
|
+
|
|
220
|
+
/** Marks quota exceeded process-wide until the given epoch-ms timestamp. */
|
|
221
|
+
export function setQuotaExceededUntil(untilEpochMs) {
|
|
222
|
+
const value = Number(untilEpochMs);
|
|
223
|
+
quotaExceededUntilMs = Number.isFinite(value) ? value : 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Clears the process-wide quota-exceeded state immediately. */
|
|
227
|
+
export function clearQuotaExceeded() {
|
|
228
|
+
quotaExceededUntilMs = 0;
|
|
229
|
+
}
|
|
230
|
+
const rateLimitInterval = setInterval(() => {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
for (const [key, val] of rateLimitMap.entries()) {
|
|
233
|
+
if (val.resetTime < now) rateLimitMap.delete(key);
|
|
234
|
+
}
|
|
235
|
+
}, 60 * 1000);
|
|
236
|
+
rateLimitInterval.unref();
|
|
237
|
+
|
|
238
|
+
// In-memory Idempotency cache (per-instance optimization; order-level
|
|
239
|
+
// idempotency is enforced downstream by deterministic order refs).
|
|
240
|
+
// Signature nonces live in a replay cache that becomes cluster-wide when
|
|
241
|
+
// an Upstash REST endpoint is configured (lib/shared-store.js).
|
|
242
|
+
const idempotencyMap = new Map();
|
|
243
|
+
const signatureNonceMap = createReplayCache('agentsbloom:sig:nonce', SIGNATURE_REPLAY_CACHE_MAX_SIZE);
|
|
244
|
+
const idempotencyInterval = setInterval(() => {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
for (const [key, val] of idempotencyMap.entries()) {
|
|
247
|
+
if (val.expiry < now) idempotencyMap.delete(key);
|
|
248
|
+
}
|
|
249
|
+
}, 60 * 1000);
|
|
250
|
+
idempotencyInterval.unref();
|
|
251
|
+
|
|
252
|
+
let otelShutdownPromise = null;
|
|
253
|
+
|
|
254
|
+
// --- OpenAPI 3.1 document generation ---
|
|
255
|
+
//
|
|
256
|
+
// Generated from the merchant's declared actions so every action the store
|
|
257
|
+
// actually mounts appears in /openapi.json. Well-known commerce actions
|
|
258
|
+
// keep their curated, example-rich schemas; anything else a merchant
|
|
259
|
+
// declares is derived from its `params` type map. AP2 and protocol
|
|
260
|
+
// discovery endpoints are documented alongside the REST surface, and the
|
|
261
|
+
// security schemes describe the exact headers RFC 9421 / legacy /
|
|
262
|
+
// AP2 callers must send.
|
|
263
|
+
|
|
264
|
+
const OPENAPI_PARAM_TYPE_MAP = {
|
|
265
|
+
string: 'string',
|
|
266
|
+
number: 'number',
|
|
267
|
+
integer: 'integer',
|
|
268
|
+
boolean: 'boolean',
|
|
269
|
+
any: null, // schema-less when the action accepts anything
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function openApiSchemaForParamType(type) {
|
|
273
|
+
const mapped = OPENAPI_PARAM_TYPE_MAP[type];
|
|
274
|
+
return mapped ? { type: mapped } : {};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Common error responses referenced via $ref from every generated operation. */
|
|
278
|
+
function openApiErrorRefs() {
|
|
279
|
+
return {
|
|
280
|
+
"401": { $ref: '#/components/responses/VerificationRequired' },
|
|
281
|
+
"403": { $ref: '#/components/responses/Forbidden' },
|
|
282
|
+
"429": { $ref: '#/components/responses/RateLimited' },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Curated, example-rich entries for the canonical commerce actions. */
|
|
287
|
+
function curatedOpenApiPaths() {
|
|
288
|
+
return {
|
|
289
|
+
"/api/agentsbloom/products": {
|
|
290
|
+
get: {
|
|
291
|
+
operationId: "listProducts",
|
|
292
|
+
summary: "Retrieve product catalog or filter by category/query",
|
|
293
|
+
parameters: [
|
|
294
|
+
{ name: "category", in: "query", schema: { type: "string" }, description: "Category filter (e.g. shoes, apparel, accessories, electronics, home, books)" },
|
|
295
|
+
{ name: "query", in: "query", schema: { type: "string" }, description: "Search term" },
|
|
296
|
+
{ name: "limit", in: "query", schema: { type: "integer" }, description: "Maximum products to return" }
|
|
297
|
+
],
|
|
298
|
+
responses: { "200": { description: "List of matching products with price, sizes, stock, rating" }, ...openApiErrorRefs() }
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
"/api/agentsbloom/search": {
|
|
302
|
+
get: {
|
|
303
|
+
operationId: "searchProducts",
|
|
304
|
+
summary: "Search products by natural language query",
|
|
305
|
+
parameters: [
|
|
306
|
+
{ name: "query", in: "query", schema: { type: "string" }, description: "Product name or search keywords" },
|
|
307
|
+
{ name: "category", in: "query", schema: { type: "string" }, description: "Category filter" }
|
|
308
|
+
],
|
|
309
|
+
responses: { "200": { description: "Search results with count and product details" }, ...openApiErrorRefs() }
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
"/api/agentsbloom/cart": {
|
|
313
|
+
get: {
|
|
314
|
+
operationId: "getCart",
|
|
315
|
+
summary: "Retrieve current cart contents and total price",
|
|
316
|
+
responses: { "200": { description: "Cart items, item count, and price breakdown" }, ...openApiErrorRefs() }
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
"/api/agentsbloom/addToCart": {
|
|
320
|
+
post: {
|
|
321
|
+
operationId: "addToCart",
|
|
322
|
+
summary: "Add a product variant to cart",
|
|
323
|
+
security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }],
|
|
324
|
+
requestBody: {
|
|
325
|
+
required: true,
|
|
326
|
+
content: {
|
|
327
|
+
"application/json": {
|
|
328
|
+
schema: {
|
|
329
|
+
type: "object",
|
|
330
|
+
required: ["productId"],
|
|
331
|
+
properties: {
|
|
332
|
+
productId: { type: "string", description: "Product ID (e.g. puma-velocity-3, trail-master-x2, agent-pro-backpack)" },
|
|
333
|
+
size: { type: "string", description: "Product size or variant (e.g. 10, M, One Size)" },
|
|
334
|
+
quantity: { type: "integer", default: 1, description: "Quantity to add" }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
responses: { "200": { description: "Success confirmation and updated cart size" }, ...openApiErrorRefs() }
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
"/api/agentsbloom/checkout": {
|
|
344
|
+
post: {
|
|
345
|
+
operationId: "checkout",
|
|
346
|
+
summary: "Create a secure checkout link for payment",
|
|
347
|
+
security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }],
|
|
348
|
+
requestBody: {
|
|
349
|
+
required: false,
|
|
350
|
+
content: {
|
|
351
|
+
"application/json": {
|
|
352
|
+
schema: {
|
|
353
|
+
type: "object",
|
|
354
|
+
properties: {
|
|
355
|
+
address: { type: "string", description: "Customer shipping destination address" },
|
|
356
|
+
gateway: { type: "string", default: "stripe", description: "Payment gateway: stripe, razorpay, or paddle" }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
responses: { "200": { description: "Secure payment URL and session ID" }, ...openApiErrorRefs() }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Derives an operation entry for any declared action from its params map. */
|
|
369
|
+
function openApiOperationForAction(key, action, signatureAuthEnabled) {
|
|
370
|
+
const httpMethod = String(action.method || 'POST').toLowerCase();
|
|
371
|
+
const isWrite = ['post', 'put', 'patch', 'delete'].includes(httpMethod);
|
|
372
|
+
const paramSchema = {
|
|
373
|
+
type: "object",
|
|
374
|
+
properties: Object.fromEntries(
|
|
375
|
+
Object.entries(action.params || {}).map(([pkey, pval]) => [pkey, openApiSchemaForParamType(pval)])
|
|
376
|
+
),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const operation = {
|
|
380
|
+
operationId: key,
|
|
381
|
+
summary: action.description || key,
|
|
382
|
+
...(isWrite && signatureAuthEnabled
|
|
383
|
+
? { security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }] }
|
|
384
|
+
: {}),
|
|
385
|
+
...(httpMethod === 'get'
|
|
386
|
+
? {
|
|
387
|
+
parameters: Object.entries(action.params || {}).map(([pkey, pval]) => ({
|
|
388
|
+
name: pkey,
|
|
389
|
+
in: "query",
|
|
390
|
+
schema: openApiSchemaForParamType(pval),
|
|
391
|
+
})),
|
|
392
|
+
}
|
|
393
|
+
: {
|
|
394
|
+
requestBody: {
|
|
395
|
+
required: true,
|
|
396
|
+
content: { "application/json": { schema: paramSchema } },
|
|
397
|
+
},
|
|
398
|
+
}),
|
|
399
|
+
responses: {
|
|
400
|
+
"200": { description: action.description ? `${action.description} result` : `${key} result` },
|
|
401
|
+
...openApiErrorRefs(),
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
return { [httpMethod]: operation };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Builds the full OpenAPI 3.1 document: generated action paths, curated
|
|
410
|
+
* canonical entries, AP2 endpoints, discovery endpoints, reusable security
|
|
411
|
+
* schemes, and error responses.
|
|
412
|
+
*/
|
|
413
|
+
function buildOpenApiDocument({ name, description, requestUrl, actions = {}, signatureAuthEnabled = true }) {
|
|
414
|
+
const paths = {};
|
|
415
|
+
|
|
416
|
+
// Every declared action gets a path (curated entries override generated
|
|
417
|
+
// ones for the canonical five).
|
|
418
|
+
for (const [key, action] of Object.entries(actions)) {
|
|
419
|
+
paths[`/api/agentsbloom/${key}`] = openApiOperationForAction(key, action, signatureAuthEnabled);
|
|
420
|
+
}
|
|
421
|
+
Object.assign(paths, curatedOpenApiPaths());
|
|
422
|
+
|
|
423
|
+
// AP2 protocol surface.
|
|
424
|
+
paths["/ap2/capabilities"] = {
|
|
425
|
+
get: {
|
|
426
|
+
operationId: "ap2Capabilities",
|
|
427
|
+
summary: "AP2 capabilities, mandate types, and verification methods",
|
|
428
|
+
responses: { "200": { description: "AP2 capability document" }, ...openApiErrorRefs() },
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
paths["/ap2/intent"] = {
|
|
432
|
+
post: {
|
|
433
|
+
operationId: "ap2Intent",
|
|
434
|
+
summary: "Announce purchase intent with a verified AP2 Intent Mandate",
|
|
435
|
+
security: [{ Ap2MandateAuth: [] }],
|
|
436
|
+
requestBody: {
|
|
437
|
+
required: false,
|
|
438
|
+
content: {
|
|
439
|
+
"application/json": {
|
|
440
|
+
schema: {
|
|
441
|
+
type: "object",
|
|
442
|
+
properties: {
|
|
443
|
+
requestedCategories: { type: "array", items: { type: "string" }, description: "Cart categories the intent covers" },
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
responses: {
|
|
450
|
+
"200": { description: "Intent accepted with mandate verification evidence" },
|
|
451
|
+
"401": { $ref: '#/components/responses/VerificationRequired' },
|
|
452
|
+
"403": { $ref: '#/components/responses/Forbidden' },
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
paths["/ap2/checkout"] = {
|
|
457
|
+
post: {
|
|
458
|
+
operationId: "ap2Checkout",
|
|
459
|
+
summary: "Mandate-gated checkout: creates a payment URL only when a verified AP2 mandate with a positive maxBudget covers the order total",
|
|
460
|
+
security: [{ Ap2MandateAuth: [] }],
|
|
461
|
+
requestBody: {
|
|
462
|
+
required: true,
|
|
463
|
+
content: {
|
|
464
|
+
"application/json": {
|
|
465
|
+
schema: {
|
|
466
|
+
type: "object",
|
|
467
|
+
required: ["orderTotal"],
|
|
468
|
+
properties: {
|
|
469
|
+
orderTotal: { type: "number", description: "Order total that must be covered by the mandate's maxBudget" },
|
|
470
|
+
address: { type: "string", description: "Customer shipping destination address" },
|
|
471
|
+
gateway: { type: "string", default: "stripe", description: "Payment gateway: stripe, razorpay, or paddle" },
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
responses: {
|
|
478
|
+
"200": { description: "Authorized checkout with payment URL, session id, and budget-enforcement evidence" },
|
|
479
|
+
"401": { $ref: '#/components/responses/VerificationRequired' },
|
|
480
|
+
"403": { $ref: '#/components/responses/Forbidden' },
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Protocol discovery surface.
|
|
486
|
+
paths["/.well-known/agent-spec"] = {
|
|
487
|
+
get: {
|
|
488
|
+
operationId: "agentSpec",
|
|
489
|
+
summary: "Legacy/compatibility discovery document",
|
|
490
|
+
responses: { "200": { description: "Agent spec manifest" } },
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
paths["/.well-known/ucp"] = {
|
|
494
|
+
get: {
|
|
495
|
+
operationId: "ucpProfile",
|
|
496
|
+
summary: "UCP (Universal Commerce Protocol) profile",
|
|
497
|
+
responses: { "200": { description: "UCP profile with capabilities and endpoint declarations" } },
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
paths["/ai-catalog.json"] = {
|
|
501
|
+
get: {
|
|
502
|
+
operationId: "aiCatalog",
|
|
503
|
+
summary: "WebMCP/ARD-style action catalog",
|
|
504
|
+
responses: { "200": { description: "Action catalog document" } },
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
paths["/llms.txt"] = {
|
|
508
|
+
get: {
|
|
509
|
+
operationId: "llmsDoc",
|
|
510
|
+
summary: "LLM-oriented developer guide",
|
|
511
|
+
responses: { "200": { description: "Plain-text guide for LLM agents" } },
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
openapi: "3.1.0",
|
|
517
|
+
info: { title: name, description, version: "1.0.0" },
|
|
518
|
+
servers: [{ url: requestUrl }],
|
|
519
|
+
// Reads are open; writes carry their own operation-level security.
|
|
520
|
+
security: [],
|
|
521
|
+
tags: [
|
|
522
|
+
{ name: "catalog", description: "Product browsing and search" },
|
|
523
|
+
{ name: "cart", description: "Cart operations (write actions require agent signatures)" },
|
|
524
|
+
{ name: "checkout", description: "Checkout link creation" },
|
|
525
|
+
{ name: "ap2", description: "AP2 mandate-gated payment authorization" },
|
|
526
|
+
{ name: "discovery", description: "Protocol discovery documents" },
|
|
527
|
+
],
|
|
528
|
+
paths,
|
|
529
|
+
components: {
|
|
530
|
+
securitySchemes: {
|
|
531
|
+
HttpSignatureAuth: {
|
|
532
|
+
type: "apiKey",
|
|
533
|
+
description: "RFC 9421 HTTP Message Signatures: send `Signature` and `Signature-Input` headers; the signature must cover @method, @path, and content-digest for bodied requests, with created/expires/nonce parameters and a keyid resolvable through the configured JWKS.",
|
|
534
|
+
in: "header",
|
|
535
|
+
name: "Signature",
|
|
536
|
+
},
|
|
537
|
+
LegacyAgentSignature: {
|
|
538
|
+
type: "apiKey",
|
|
539
|
+
description: "Legacy HMAC scheme: X-Agent-Signature plus X-Agent-Identifier, X-Agent-Timestamp, X-Agent-Nonce headers, signed with the shared agent secret.",
|
|
540
|
+
in: "header",
|
|
541
|
+
name: "X-Agent-Signature",
|
|
542
|
+
},
|
|
543
|
+
Ap2MandateAuth: {
|
|
544
|
+
type: "apiKey",
|
|
545
|
+
description: "AP2 budget mandate: an SD-JWT with intentMandate.maxBudget, audience-bound to this store, sent as `X-AP2-Mandate: Bearer <sd-jwt>`.",
|
|
546
|
+
in: "header",
|
|
547
|
+
name: "X-AP2-Mandate",
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
responses: {
|
|
551
|
+
VerificationRequired: {
|
|
552
|
+
description: "Credential missing - write actions need an agent signature; AP2 endpoints need a mandate.",
|
|
553
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, message: { type: "string" } } } } },
|
|
554
|
+
},
|
|
555
|
+
Forbidden: {
|
|
556
|
+
description: "Credential presented but invalid (bad signature, expired mandate, budget exceeded, wrong audience, replay detected).",
|
|
557
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, reason: { type: "string" }, protocol: { type: "string" } } } } },
|
|
558
|
+
},
|
|
559
|
+
RateLimited: {
|
|
560
|
+
description: "Too many requests - respect X-RateLimit-Reset and Retry-After.",
|
|
561
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, retryAfter: { type: "number" } } } } },
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
export async function shutdown() {
|
|
570
|
+
clearInterval(rateLimitInterval);
|
|
571
|
+
clearInterval(idempotencyInterval);
|
|
572
|
+
rateLimitMap.clear();
|
|
573
|
+
idempotencyMap.clear();
|
|
574
|
+
signatureNonceMap.clear();
|
|
575
|
+
jwksCache.clear();
|
|
576
|
+
stopAp2ReplayCleanup();
|
|
577
|
+
|
|
578
|
+
if (otelShutdownPromise) {
|
|
579
|
+
return otelShutdownPromise;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const otelHandle = globalThis.__agentsbloom_otel_handle;
|
|
583
|
+
if (!otelHandle) return;
|
|
584
|
+
|
|
585
|
+
// Clear the handle before awaiting so a repeated/concurrent shutdown cannot
|
|
586
|
+
// start a second provider shutdown, even if the first one rejects.
|
|
587
|
+
globalThis.__agentsbloom_otel_handle = null;
|
|
588
|
+
otelShutdownPromise = Promise.resolve().then(() => otelHandle.provider.shutdown());
|
|
589
|
+
try {
|
|
590
|
+
await otelShutdownPromise;
|
|
591
|
+
} finally {
|
|
592
|
+
otelShutdownPromise = null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function agentsbloom(config = {}) {
|
|
597
|
+
const {
|
|
598
|
+
apiKey = null,
|
|
599
|
+
name = "My Agent-Ready Store",
|
|
600
|
+
description = "An e-commerce store optimized for human and machine AI agents.",
|
|
601
|
+
actions = {},
|
|
602
|
+
llmsDoc = "",
|
|
603
|
+
baseUrl = ""
|
|
604
|
+
} = config;
|
|
605
|
+
|
|
606
|
+
if (!apiKey) {
|
|
607
|
+
console.error("🌸 AgentsBloom SDK Error: Missing `apiKey`. You must provide an API Key to use the SDK. Get one at dashboard.agentsbloom.com");
|
|
608
|
+
}
|
|
609
|
+
if (!baseUrl) {
|
|
610
|
+
console.error("🌸 AgentsBloom SDK Error: Missing `baseUrl` in config. This is required for secure telemetry.");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const MAX_REQUESTS = config.rateLimit?.max || 30;
|
|
614
|
+
const RATE_LIMIT_WINDOW = config.rateLimit?.windowMs || 60 * 1000;
|
|
615
|
+
const IDEMPOTENCY_TTL = config.idempotency?.ttlMs || 5 * 60 * 1000;
|
|
616
|
+
// v4 medium pass (V18): bound the per-process idempotency cache so
|
|
617
|
+
// unique-key spam cannot grow memory without limit between TTL sweeps.
|
|
618
|
+
const IDEMPOTENCY_MAX_ENTRIES = Number.isFinite(config.idempotency?.maxEntries) && config.idempotency.maxEntries > 0
|
|
619
|
+
? config.idempotency.maxEntries
|
|
620
|
+
: 10_000;
|
|
621
|
+
const signatureMaxAgeMs = Number.isFinite(config.signature?.maxAgeMs) && config.signature.maxAgeMs > 0
|
|
622
|
+
? config.signature.maxAgeMs
|
|
623
|
+
: LEGACY_SIGNATURE_MAX_AGE_MS;
|
|
624
|
+
// v4 medium pass (V14): require the @authority covered component so the
|
|
625
|
+
// host is bound into every RFC 9421 signature (off by default for
|
|
626
|
+
// backward compatibility with agents that don't sign it yet).
|
|
627
|
+
const signatureRequireAuthority = config.signature?.requireAuthority === true;
|
|
628
|
+
|
|
629
|
+
const configuredAgentSecret = config.agentSecret ?? process.env.AGENTSBLOOM_SECRET;
|
|
630
|
+
const agentSecret = typeof configuredAgentSecret === 'string' && configuredAgentSecret.length > 0
|
|
631
|
+
? configuredAgentSecret
|
|
632
|
+
: null;
|
|
633
|
+
// Previous secret kept verify-live during rotation (see verifySignature
|
|
634
|
+
// call sites) so rolling AGENTSBLOOM_SECRET doesn't hard-drop every
|
|
635
|
+
// in-flight agent.
|
|
636
|
+
const configuredPreviousSecret = config.agentSecretPrevious ?? process.env.AGENTSBLOOM_SECRET_PREVIOUS;
|
|
637
|
+
const agentSecretPrevious = typeof configuredPreviousSecret === 'string' && configuredPreviousSecret.length > 0
|
|
638
|
+
? configuredPreviousSecret
|
|
639
|
+
: null;
|
|
640
|
+
const signatureAuthEnabled = config.disableSignatureAuth !== true && config.demoMode !== true;
|
|
641
|
+
const cacheNamespace = crypto.randomUUID();
|
|
642
|
+
|
|
643
|
+
// --- v4 medium pass (V11): per-agent keys and revocation ---
|
|
644
|
+
// The single shared merchant secret meant one compromised agent
|
|
645
|
+
// compromised every agent: any holder could forge requests as ANY
|
|
646
|
+
// identifier, and there was no way to revoke one agent without rotating
|
|
647
|
+
// the global secret for all of them. `config.agentKeys` maps identifier
|
|
648
|
+
// -> per-agent secret (checked first); `config.revokedIdentifiers`
|
|
649
|
+
// rejects an identifier outright. The shared secret remains the
|
|
650
|
+
// fallback so existing deployments keep working.
|
|
651
|
+
const agentKeys = config.agentKeys && typeof config.agentKeys === 'object'
|
|
652
|
+
? Object.fromEntries(
|
|
653
|
+
Object.entries(config.agentKeys).filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
654
|
+
)
|
|
655
|
+
: null;
|
|
656
|
+
const revokedIdentifiers = Array.isArray(config.revokedIdentifiers)
|
|
657
|
+
? new Set(config.revokedIdentifiers)
|
|
658
|
+
: null;
|
|
659
|
+
|
|
660
|
+
// --- v4 medium pass (V12): per-action authorization ---
|
|
661
|
+
// Any verified identity could previously invoke ANY write action,
|
|
662
|
+
// checkout included - the permission matrix existed only in the hosted
|
|
663
|
+
// gateway. Merchants can now require a verified identity per action and
|
|
664
|
+
// restrict which identities may call it.
|
|
665
|
+
const actionAccess = config.actionAccess && typeof config.actionAccess === 'object' ? config.actionAccess : null;
|
|
666
|
+
const actionIdentities = config.actionIdentities && typeof config.actionIdentities === 'object' ? config.actionIdentities : null;
|
|
667
|
+
|
|
668
|
+
// --- v4 medium pass (V20): CORS origin policy ---
|
|
669
|
+
// Array = exact-match allow-list with per-request reflection; string =
|
|
670
|
+
// legacy single-value behavior ('*' by default). The wildcard default is
|
|
671
|
+
// loud about itself so merchants notice and restrict it.
|
|
672
|
+
const allowedOriginList = Array.isArray(config.corsOrigin)
|
|
673
|
+
? config.corsOrigin.filter((o) => typeof o === 'string' && o.length > 0)
|
|
674
|
+
: null;
|
|
675
|
+
const allowedOriginString = allowedOriginList ? null : (config.corsOrigin || '*');
|
|
676
|
+
if (!config.corsOrigin) {
|
|
677
|
+
console.warn("🌸 AgentsBloom Warning: corsOrigin defaults to '*' - any web origin can call this store's API, including write endpoints reachable from a victim browser. Set corsOrigin to an explicit origin or array of origins for production.");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Returns a rejection descriptor when `identity` (verified cache identity
|
|
682
|
+
* string like `rfc:<keyid>` / `legacy:<identifier>`, or null) may not
|
|
683
|
+
* invoke `actionName`; null when allowed.
|
|
684
|
+
*/
|
|
685
|
+
function authorizeAction(actionName, identity) {
|
|
686
|
+
if (actionAccess?.[actionName] === 'authenticated' && !identity) {
|
|
687
|
+
return {
|
|
688
|
+
status: 401,
|
|
689
|
+
body: {
|
|
690
|
+
error: "Authentication Required",
|
|
691
|
+
message: `Action ${actionName} requires a verified agent signature (RFC 9421 or X-Agent-Signature).`
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const allowed = actionIdentities?.[actionName];
|
|
696
|
+
if (Array.isArray(allowed) && allowed.length > 0) {
|
|
697
|
+
const matched = Boolean(identity) && allowed.some((pattern) => (
|
|
698
|
+
pattern === identity || (typeof pattern === 'string' && pattern.endsWith(':') && identity.startsWith(pattern))
|
|
699
|
+
));
|
|
700
|
+
if (!matched) {
|
|
701
|
+
return {
|
|
702
|
+
status: 403,
|
|
703
|
+
body: {
|
|
704
|
+
error: "Forbidden",
|
|
705
|
+
message: `Verified identity ${identity || '(anonymous)'} is not authorized to invoke ${actionName}.`
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!agentSecret && !agentKeys && signatureAuthEnabled) {
|
|
714
|
+
console.warn("🌸 AgentsBloom Warning: Neither AGENTSBLOOM_SECRET nor config.agentKeys is set. Legacy signed write requests will be rejected until a secret is configured.");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// v4 (Host-header poisoning defense): warn when the AP2 audience would be
|
|
718
|
+
// derived from the request's own Host header - that fallback is spoofable.
|
|
719
|
+
if (!config.ap2?.expectedAudience && !baseUrl) {
|
|
720
|
+
console.warn("🌸 AgentsBloom Warning: Neither `baseUrl` nor `ap2.expectedAudience` is configured. The AP2 mandate audience will fall back to each request's Host header, which an attacker can spoof. Set one of them for production.");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return async (req, res, next) => {
|
|
724
|
+
// v4 medium pass (V10): the 1MB gate used to check the Content-Length
|
|
725
|
+
// header only - a chunked transfer with no Content-Length sailed past
|
|
726
|
+
// it. The cap is now also enforced against the ACTUAL received bytes
|
|
727
|
+
// (req.rawBody) once the body parser has run, and is configurable.
|
|
728
|
+
const maxBodyBytes = Number.isFinite(config.maxBodyBytes) && config.maxBodyBytes > 0
|
|
729
|
+
? config.maxBodyBytes
|
|
730
|
+
: 1024 * 1024;
|
|
731
|
+
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
732
|
+
if (contentLength > maxBodyBytes) {
|
|
733
|
+
return res.status(413).json({ error: "Payload Too Large", message: `Request body exceeds ${maxBodyBytes} byte limit.` });
|
|
734
|
+
}
|
|
735
|
+
let receivedBodyBytes;
|
|
736
|
+
if (Buffer.isBuffer(req.rawBody)) {
|
|
737
|
+
receivedBodyBytes = req.rawBody.length;
|
|
738
|
+
} else if (typeof req.rawBody === 'string') {
|
|
739
|
+
receivedBodyBytes = Buffer.byteLength(req.rawBody);
|
|
740
|
+
}
|
|
741
|
+
if (receivedBodyBytes !== undefined && receivedBodyBytes > maxBodyBytes) {
|
|
742
|
+
return res.status(413).json({
|
|
743
|
+
error: "Payload Too Large",
|
|
744
|
+
message: `Request body exceeds ${maxBodyBytes} byte limit.`,
|
|
745
|
+
code: "body_size_exceeded"
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (req.path === '/health' && req.method === 'GET') {
|
|
750
|
+
return res.json({ status: "ok", version: "0.4.0", uptime: process.uptime() });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// v4 (Host-header poisoning defense): when `config.allowedHosts` is
|
|
754
|
+
// configured, a request whose Host header is not on the list gets its
|
|
755
|
+
// discovery URLs built from the merchant's trusted `baseUrl` instead of
|
|
756
|
+
// the attacker-controlled Host. Without an allow-list the previous
|
|
757
|
+
// behavior is preserved (tunnel hosts etc. keep working), so this is
|
|
758
|
+
// opt-in hardening for production deployments behind a fixed domain.
|
|
759
|
+
const allowedHosts = Array.isArray(config.allowedHosts)
|
|
760
|
+
? config.allowedHosts.filter((h) => typeof h === 'string' && h.length > 0).map((h) => h.toLowerCase())
|
|
761
|
+
: null;
|
|
762
|
+
const rawHost = req.get('host') || '';
|
|
763
|
+
const hostIsAllowed = !allowedHosts || !rawHost || allowedHosts.includes(rawHost.toLowerCase());
|
|
764
|
+
const host = hostIsAllowed ? rawHost : '';
|
|
765
|
+
// v4: only well-known protocol values are honored from x-forwarded-proto.
|
|
766
|
+
const forwardedProtoCandidate = String(req.get('x-forwarded-proto') || '').split(',')[0].trim().toLowerCase();
|
|
767
|
+
const forwardedProto = forwardedProtoCandidate === 'http' || forwardedProtoCandidate === 'https'
|
|
768
|
+
? forwardedProtoCandidate
|
|
769
|
+
: '';
|
|
770
|
+
const isTlsTunnel = host.includes('.life') || host.includes('.loca.lt') || host.includes('.trycloudflare.com') || host.includes('.ngrok');
|
|
771
|
+
const protocol = forwardedProto || (isTlsTunnel ? 'https' : (req.protocol || 'http'));
|
|
772
|
+
const requestUrl = host ? `${protocol}://${host}` : (baseUrl || `${protocol}://localhost:3000`);
|
|
773
|
+
const ip = req.ip || req.socket.remoteAddress || '127.0.0.1';
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
|
|
776
|
+
// --- CORS HEADERS ---
|
|
777
|
+
// v4 medium pass (V20): `corsOrigin` now accepts an array of exact
|
|
778
|
+
// origins; requests whose Origin matches get it reflected (with
|
|
779
|
+
// Vary: Origin), everyone else gets NO Access-Control-Allow-Origin at
|
|
780
|
+
// all - a real allow-list instead of `*` for everything including
|
|
781
|
+
// writes. The string form behaves exactly as before. The wildcard
|
|
782
|
+
// default is retained for backward compatibility but warns once.
|
|
783
|
+
if (Array.isArray(allowedOriginList)) {
|
|
784
|
+
const requestOrigin = req.headers.origin;
|
|
785
|
+
if (typeof requestOrigin === 'string' && allowedOriginList.includes(requestOrigin)) {
|
|
786
|
+
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
787
|
+
res.setHeader('Vary', 'Origin');
|
|
788
|
+
}
|
|
789
|
+
} else {
|
|
790
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOriginString);
|
|
791
|
+
}
|
|
792
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
793
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Content-Digest, X-Agent-Signature, X-Agent-Identifier, X-Agent-Timestamp, X-Agent-Nonce, Idempotency-Key, Signature, Signature-Input, Signature-Agent');
|
|
794
|
+
|
|
795
|
+
// --- SaaS QUOTA ENFORCEMENT (Zero Latency Cache) ---
|
|
796
|
+
if (now < quotaExceededUntilMs) {
|
|
797
|
+
res.setHeader('Content-Type', 'application/json');
|
|
798
|
+
return res.status(402).json({
|
|
799
|
+
error: "AgentsBloom Quota Exceeded. Please upgrade your API plan to continue serving AI Agents.",
|
|
800
|
+
code: "api_quota_exceeded"
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// --- 1. DDoS PROTECTION (RATE LIMITING) ---
|
|
805
|
+
// v4: OPTIONS preflights are now counted toward the same bucket (they
|
|
806
|
+
// used to bypass the limiter entirely, making free preflight floods
|
|
807
|
+
// possible), and the client key is normalized so IPv6-mapped IPv4
|
|
808
|
+
// representations cannot mint fresh buckets.
|
|
809
|
+
const rateKey = normalizeRateLimitKey(ip);
|
|
810
|
+
let limit = rateLimitMap.get(rateKey);
|
|
811
|
+
const rateKeyNormalized = rateKey;
|
|
812
|
+
if (!limit || now > limit.resetTime) {
|
|
813
|
+
// v4: bound the tracking map. When at capacity, sweep expired
|
|
814
|
+
// entries first; if still full, drop the oldest-tracked client so
|
|
815
|
+
// attacker-controlled IP rotation cannot grow memory unboundedly.
|
|
816
|
+
if (!rateLimitMap.has(rateKeyNormalized) && rateLimitMap.size >= MAX_RATE_TRACKED_CLIENTS) {
|
|
817
|
+
for (const [key, val] of rateLimitMap.entries()) {
|
|
818
|
+
if (val.resetTime < now) rateLimitMap.delete(key);
|
|
819
|
+
}
|
|
820
|
+
if (rateLimitMap.size >= MAX_RATE_TRACKED_CLIENTS) {
|
|
821
|
+
const oldestKey = rateLimitMap.keys().next().value;
|
|
822
|
+
if (oldestKey !== undefined) rateLimitMap.delete(oldestKey);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
limit = { count: 1, resetTime: now + RATE_LIMIT_WINDOW };
|
|
826
|
+
rateLimitMap.set(rateKeyNormalized, limit);
|
|
827
|
+
} else {
|
|
828
|
+
limit.count++;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const remaining = Math.max(0, MAX_REQUESTS - limit.count);
|
|
832
|
+
res.setHeader('X-RateLimit-Limit', String(MAX_REQUESTS));
|
|
833
|
+
res.setHeader('X-RateLimit-Remaining', String(remaining));
|
|
834
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil((limit.resetTime - now) / 1000)));
|
|
835
|
+
|
|
836
|
+
if (limit.count > MAX_REQUESTS) {
|
|
837
|
+
res.setHeader('Content-Type', 'application/json');
|
|
838
|
+
res.setHeader('Retry-After', String(Math.ceil((limit.resetTime - now) / 1000)));
|
|
839
|
+
return res.status(429).json({
|
|
840
|
+
error: "Too Many Requests",
|
|
841
|
+
message: "Rate limit exceeded. Please slow down.",
|
|
842
|
+
retryAfter: Math.ceil((limit.resetTime - now) / 1000)
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// OPTIONS preflights are answered AFTER being counted against the
|
|
847
|
+
// caller's rate bucket (v4 fix for unthrottled preflight floods).
|
|
848
|
+
if (req.method === 'OPTIONS') {
|
|
849
|
+
return res.status(204).end();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// --- 2. SERVE SPEC ENDPOINTS ---
|
|
853
|
+
|
|
854
|
+
// Serve /.well-known/agent-spec & /v1/agent/spec (API Versioning)
|
|
855
|
+
if (req.path === '/.well-known/agent-spec' || req.path === '/v1/agent/spec') {
|
|
856
|
+
res.setHeader('Content-Type', 'application/json');
|
|
857
|
+
res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
|
|
858
|
+
return res.json({
|
|
859
|
+
name,
|
|
860
|
+
description,
|
|
861
|
+
version: "1.0.0",
|
|
862
|
+
agentsbloomVersion: "0.4.0",
|
|
863
|
+
discoveryUrl: `${requestUrl}/v1/agent/spec`,
|
|
864
|
+
catalogUrl: `${requestUrl}/v1/agent/catalog`,
|
|
865
|
+
llmsUrl: `${requestUrl}/llms.txt`,
|
|
866
|
+
security: {
|
|
867
|
+
rateLimiting: { maxRequestsPerMin: MAX_REQUESTS },
|
|
868
|
+
captchaBypassing: { supported: true, authHeader: "X-Agent-Signature", webBotAuth: true },
|
|
869
|
+
idempotency: { supported: true, header: "Idempotency-Key", ttlSeconds: 300 }
|
|
870
|
+
},
|
|
871
|
+
actions: Object.entries(actions).reduce((acc, [key, val]) => {
|
|
872
|
+
acc[key] = {
|
|
873
|
+
endpoint: `/api/agentsbloom/${key}`,
|
|
874
|
+
method: val.method || 'POST',
|
|
875
|
+
description: val.description,
|
|
876
|
+
params: val.params || {}
|
|
877
|
+
};
|
|
878
|
+
return acc;
|
|
879
|
+
}, {}),
|
|
880
|
+
authentication: { type: "http-message-signatures-or-x-agent-signature", requiredForWrites: true }
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Serve /.well-known/http-message-signatures-directory
|
|
885
|
+
// v4 medium pass (V16): the old placeholder key ("placeholder-merchant-key")
|
|
886
|
+
// was a fake trust anchor - agents could "verify" nothing real against
|
|
887
|
+
// it while believing they had a genuine merchant key set. Fail closed
|
|
888
|
+
// instead until the merchant configures their JWKS.
|
|
889
|
+
if (req.path === '/.well-known/http-message-signatures-directory') {
|
|
890
|
+
if (!config.merchantJwks) {
|
|
891
|
+
return res.status(503).json({
|
|
892
|
+
error: 'JWKS Not Configured',
|
|
893
|
+
message: 'This store has not published a merchant JWKS. Provide `merchantJwks` in the AgentsBloom SDK config to serve /.well-known/http-message-signatures-directory.'
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
res.setHeader('Content-Type', 'application/json');
|
|
897
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
898
|
+
return res.json(config.merchantJwks);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Serve /.well-known/ucp (Universal Commerce Protocol Profile)
|
|
902
|
+
if (req.path === '/.well-known/ucp') {
|
|
903
|
+
res.setHeader('Content-Type', 'application/json');
|
|
904
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
905
|
+
return res.json({
|
|
906
|
+
protocol: "ucp",
|
|
907
|
+
version: "1.0.0",
|
|
908
|
+
store: { name, description, baseUrl: requestUrl },
|
|
909
|
+
capabilities: [
|
|
910
|
+
"dev.ucp.shopping",
|
|
911
|
+
"dev.ucp.shopping.checkout",
|
|
912
|
+
"dev.ucp.common.identity_linking"
|
|
913
|
+
],
|
|
914
|
+
endpoints: {
|
|
915
|
+
catalog: `${requestUrl}/ai-catalog.json`,
|
|
916
|
+
search: `${requestUrl}/api/agentsbloom/search`,
|
|
917
|
+
products: `${requestUrl}/api/agentsbloom/products`,
|
|
918
|
+
cart: `${requestUrl}/api/agentsbloom/cart`,
|
|
919
|
+
checkout: `${requestUrl}/api/agentsbloom/checkout/acp`
|
|
920
|
+
},
|
|
921
|
+
actions: Object.entries(actions).reduce((acc, [key, val]) => {
|
|
922
|
+
acc[key] = {
|
|
923
|
+
endpoint: `/api/agentsbloom/${key}`,
|
|
924
|
+
method: val.method || 'POST',
|
|
925
|
+
description: val.description,
|
|
926
|
+
params: val.params || {}
|
|
927
|
+
};
|
|
928
|
+
return acc;
|
|
929
|
+
}, {}),
|
|
930
|
+
auth: {
|
|
931
|
+
methods: ["http-message-signatures", "x-agent-signature"]
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Serve /ai-catalog.json & /v1/agent/catalog (ARD / UCP Compliant)
|
|
937
|
+
if (req.path === '/ai-catalog.json' || req.path === '/.well-known/ai-catalog.json' || req.path === '/v1/agent/catalog') {
|
|
938
|
+
res.setHeader('Content-Type', 'application/json');
|
|
939
|
+
res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
|
|
940
|
+
return res.json({
|
|
941
|
+
$schema: "https://universalcommerce.org/schemas/catalog.json",
|
|
942
|
+
name,
|
|
943
|
+
description,
|
|
944
|
+
version: "1.0.0",
|
|
945
|
+
auth: {
|
|
946
|
+
supported: ["http-message-signatures", "x-agent-signature"]
|
|
947
|
+
},
|
|
948
|
+
items: Object.entries(actions).map(([key, val]) => ({
|
|
949
|
+
id: key,
|
|
950
|
+
type: "action",
|
|
951
|
+
title: key,
|
|
952
|
+
description: val.description,
|
|
953
|
+
actionUrl: `${requestUrl}/api/agentsbloom/${key}`
|
|
954
|
+
}))
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Serve /openapi.json & /schema.json (OpenAPI 3.1 for custom GPT
|
|
959
|
+
// actions and any OpenAPI-consuming agent). The document is generated
|
|
960
|
+
// from the merchant's declared actions - previously it hardcoded five
|
|
961
|
+
// paths, so any custom action (removeFromCart, clearCart, or anything
|
|
962
|
+
// a merchant added) silently vanished from the OpenAPI surface, and
|
|
963
|
+
// AP2 endpoints were entirely undocumented.
|
|
964
|
+
if (req.path === '/openapi.json' || req.path === '/schema.json') {
|
|
965
|
+
res.setHeader('Content-Type', 'application/json');
|
|
966
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
967
|
+
return res.json(buildOpenApiDocument({ name, description, requestUrl, actions, signatureAuthEnabled }));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Serve MCP SSE Endpoint for Tool Calling
|
|
971
|
+
// v4: concurrent SSE sessions are bounded (config.mcp.maxSessions,
|
|
972
|
+
// default 100). Previously every GET /mcp created an unbounded new
|
|
973
|
+
// transport with no accounting - a trivial socket/memory exhaustion
|
|
974
|
+
// vector.
|
|
975
|
+
if (req.path === '/mcp') {
|
|
976
|
+
const maxMcpSessions = Number.isFinite(config.mcp?.maxSessions) && config.mcp.maxSessions > 0
|
|
977
|
+
? config.mcp.maxSessions
|
|
978
|
+
: 100;
|
|
979
|
+
if (activeMcpSessions >= maxMcpSessions) {
|
|
980
|
+
return res.status(503).json({
|
|
981
|
+
error: "Too Many MCP Sessions",
|
|
982
|
+
message: `Concurrent MCP session limit (${maxMcpSessions}) reached. Retry later.`
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
const transport = new SSEServerTransport('/mcp/messages', res);
|
|
986
|
+
activeMcpSessions += 1;
|
|
987
|
+
res.on('close', () => { activeMcpSessions -= 1; });
|
|
988
|
+
const mcpServer = new Server({ name: name, version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
989
|
+
// Auto-generate MCP tool declarations from actions
|
|
990
|
+
mcpServer.setRequestHandler("tools/list", async () => ({
|
|
991
|
+
tools: Object.entries(actions).map(([key, val]) => ({
|
|
992
|
+
name: key,
|
|
993
|
+
description: val.description,
|
|
994
|
+
inputSchema: {
|
|
995
|
+
type: "object",
|
|
996
|
+
properties: Object.entries(val.params || {}).reduce((acc, [pkey, pval]) => {
|
|
997
|
+
acc[pkey] = { type: pval };
|
|
998
|
+
return acc;
|
|
999
|
+
}, {})
|
|
1000
|
+
}
|
|
1001
|
+
}))
|
|
1002
|
+
}));
|
|
1003
|
+
|
|
1004
|
+
mcpServer.setRequestHandler("tools/call", async (request) => {
|
|
1005
|
+
const action = actions[request.params.name];
|
|
1006
|
+
if (!action) throw new Error(`Tool not found: ${request.params.name}`);
|
|
1007
|
+
// v4 medium pass (V12): the same per-action authorization model
|
|
1008
|
+
// applies to MCP tool invocations.
|
|
1009
|
+
const accessRejection = authorizeAction(request.params.name, authenticatedCacheIdentity);
|
|
1010
|
+
if (accessRejection) throw new Error(accessRejection.body.message);
|
|
1011
|
+
// v4: enforce the declared parameter contract. The REST route has
|
|
1012
|
+
// always validated/coerced params; the MCP path now does too, so
|
|
1013
|
+
// merchants get identical type guarantees on both surfaces.
|
|
1014
|
+
const validation = validateActionParams(request.params.arguments || {}, action);
|
|
1015
|
+
if (!validation.ok) throw new Error(validation.message);
|
|
1016
|
+
const result = await action.handler(validation.params, req, res);
|
|
1017
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Second-pass review (N4): if connect() rejects, release the session
|
|
1021
|
+
// slot immediately instead of waiting for a close event that may not
|
|
1022
|
+
// fire for a transport that never started.
|
|
1023
|
+
try {
|
|
1024
|
+
return await mcpServer.connect(transport);
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
activeMcpSessions -= 1;
|
|
1027
|
+
throw err;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Serve /llms.txt
|
|
1032
|
+
if (req.path === '/llms.txt') {
|
|
1033
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1034
|
+
res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
|
|
1035
|
+
const defaultLlmDoc = `# ${name}\n\n${description}\n\n## Developer API Reference\n\n- GET /.well-known/agent-spec : Spec sheets\n- GET /ai-catalog.json : Catalog schemas\n- GET /openapi.json : OpenAPI 3.1 schema\n`;
|
|
1036
|
+
const docContent = typeof llmsDoc === 'function' ? llmsDoc() : (llmsDoc || defaultLlmDoc);
|
|
1037
|
+
return res.send(docContent);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// --- 3. IDEMPOTENCY METADATA FOR WRITES (POST/PUT/PATCH/DELETE) ---
|
|
1041
|
+
const method = String(req.method || '').toUpperCase();
|
|
1042
|
+
const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
1043
|
+
const rawIdempotencyKey = req.headers['idempotency-key'];
|
|
1044
|
+
const idempotencyHeader = typeof rawIdempotencyKey === 'string' ? rawIdempotencyKey : null;
|
|
1045
|
+
let idempotencyKey = null;
|
|
1046
|
+
let authenticatedCacheIdentity = null;
|
|
1047
|
+
|
|
1048
|
+
// --- 4. CRYPTOGRAPHIC CAPTCHA BYPASSING (Web Bot Auth + Legacy HMAC) ---
|
|
1049
|
+
const isMcpMessage = req.path === '/mcp/messages' && method === 'POST';
|
|
1050
|
+
// Second-pass review (N3): AP2 intent/checkout POSTs are now part of the
|
|
1051
|
+
// signature gate. The agent-side SDK has always signed these posts, but
|
|
1052
|
+
// the merchant gate ignored them - so a mandate alone could check out
|
|
1053
|
+
// anonymously, and per-action authorization policies could never see a
|
|
1054
|
+
// verified identity on the most valuable endpoint. A verified mandate
|
|
1055
|
+
// proves budget INTENT; a signature proves who is spending it.
|
|
1056
|
+
const isAp2WritePath = method === 'POST' && (
|
|
1057
|
+
req.path === '/ap2/intent'
|
|
1058
|
+
|| req.path === '/ap2/checkout'
|
|
1059
|
+
|| req.path === '/v1/ap2/intent'
|
|
1060
|
+
|| req.path === '/v1/ap2/checkout'
|
|
1061
|
+
);
|
|
1062
|
+
const isAgentAction = req.path.startsWith('/api/agentsbloom/')
|
|
1063
|
+
|| req.path.startsWith('/v1/agent/actions/')
|
|
1064
|
+
|| isMcpMessage
|
|
1065
|
+
|| isAp2WritePath;
|
|
1066
|
+
// v4 medium pass (V12): resolve which action (if any) this path targets
|
|
1067
|
+
// so per-action authorization can require verification even on reads.
|
|
1068
|
+
let requestedActionName = null;
|
|
1069
|
+
if (req.path.startsWith('/api/agentsbloom/')) {
|
|
1070
|
+
requestedActionName = req.path.slice('/api/agentsbloom/'.length);
|
|
1071
|
+
} else if (req.path.startsWith('/v1/agent/actions/')) {
|
|
1072
|
+
requestedActionName = req.path.slice('/v1/agent/actions/'.length);
|
|
1073
|
+
}
|
|
1074
|
+
const needsAuthenticatedRead = Boolean(
|
|
1075
|
+
requestedActionName && actionAccess?.[requestedActionName] === 'authenticated'
|
|
1076
|
+
);
|
|
1077
|
+
if (isAgentAction && (isWrite || needsAuthenticatedRead) && signatureAuthEnabled) {
|
|
1078
|
+
const signature = req.headers['x-agent-signature'];
|
|
1079
|
+
const identifier = req.headers['x-agent-identifier'];
|
|
1080
|
+
const timestamp = req.headers['x-agent-timestamp'];
|
|
1081
|
+
const nonce = req.headers['x-agent-nonce'];
|
|
1082
|
+
|
|
1083
|
+
const rfcSignature = req.headers['signature'];
|
|
1084
|
+
const rfcSignatureInput = req.headers['signature-input'];
|
|
1085
|
+
const hasRfcSignature = typeof rfcSignature === 'string' && rfcSignature.length > 0;
|
|
1086
|
+
const hasRfcSignatureInput = typeof rfcSignatureInput === 'string' && rfcSignatureInput.length > 0;
|
|
1087
|
+
|
|
1088
|
+
if (!signature && !hasRfcSignature && !hasRfcSignatureInput) {
|
|
1089
|
+
return res.status(401).json({
|
|
1090
|
+
error: "Verification Required",
|
|
1091
|
+
message: "CAPTCHA check required. Please provide standard RFC 9421 HTTP Message Signatures or the legacy X-Agent-Signature."
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (hasRfcSignature !== hasRfcSignatureInput) {
|
|
1096
|
+
return res.status(403).json({
|
|
1097
|
+
error: "Verification Failed",
|
|
1098
|
+
message: "Signature and Signature-Input headers must be provided together."
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (hasRfcSignature) {
|
|
1103
|
+
try {
|
|
1104
|
+
const keyidMatch = rfcSignatureInput.match(/keyid="([^"]+)"/);
|
|
1105
|
+
if (!keyidMatch) throw new Error("Missing keyid");
|
|
1106
|
+
const keyid = keyidMatch[1];
|
|
1107
|
+
|
|
1108
|
+
// v4 medium pass (V19/V14): only single-label signatures are
|
|
1109
|
+
// supported - the regex-based parameter extraction below would
|
|
1110
|
+
// silently mis-parse a multi-label input, so reject it explicitly
|
|
1111
|
+
// instead of verifying something other than what was sent.
|
|
1112
|
+
if (/,\s*[a-zA-Z0-9_]+\s*=\s*\(/.test(rfcSignatureInput)) {
|
|
1113
|
+
return res.status(403).json({
|
|
1114
|
+
error: "Verification Failed",
|
|
1115
|
+
message: "Multi-label HTTP Message Signatures are not supported; send exactly one signature label."
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
const inputLabelMatch = rfcSignatureInput.match(/^([a-zA-Z0-9_]+)\s*=/);
|
|
1119
|
+
const sigLabelMatch = rfcSignature.match(/^([a-zA-Z0-9_]+)\s*=/);
|
|
1120
|
+
if (!inputLabelMatch || !sigLabelMatch || inputLabelMatch[1] !== sigLabelMatch[1]) {
|
|
1121
|
+
return res.status(403).json({
|
|
1122
|
+
error: "Verification Failed",
|
|
1123
|
+
message: "Signature and Signature-Input labels do not match."
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const componentsMatch = rfcSignatureInput.match(/\(([^)]+)\)/);
|
|
1128
|
+
const components = componentsMatch
|
|
1129
|
+
? componentsMatch[1].split(' ').map(s => s.replace(/"/g, ''))
|
|
1130
|
+
: [];
|
|
1131
|
+
if (!components.includes('@method') || !components.includes('@path') || !components.includes('content-digest')) {
|
|
1132
|
+
throw new Error('HTTP Message Signatures must cover @method, @path, and content-digest');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// v4 medium pass (V14): deployments that need the host bound into
|
|
1136
|
+
// every signature can require the @authority component.
|
|
1137
|
+
if (signatureRequireAuthority && !components.includes('@authority')) {
|
|
1138
|
+
throw new Error('HTTP Message Signatures must cover @authority for this deployment');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const createdMatch = rfcSignatureInput.match(/(?:^|;)created=(\d+)(?:;|$)/);
|
|
1142
|
+
const expiresMatch = rfcSignatureInput.match(/(?:^|;)expires=(\d+)(?:;|$)/);
|
|
1143
|
+
const nonceMatch = rfcSignatureInput.match(/(?:^|;)nonce="([^"]+)"(?:;|$)/);
|
|
1144
|
+
const algMatch = rfcSignatureInput.match(/(?:^|;)alg="([^"]+)"(?:;|$)/);
|
|
1145
|
+
if (!createdMatch || !expiresMatch || !nonceMatch || !algMatch) {
|
|
1146
|
+
throw new Error('HTTP Message Signatures require created, expires, nonce, and alg parameters');
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const createdSeconds = Number(createdMatch[1]);
|
|
1150
|
+
const expiresSeconds = Number(expiresMatch[1]);
|
|
1151
|
+
const createdMs = createdSeconds * 1000;
|
|
1152
|
+
const expiresMs = expiresSeconds * 1000;
|
|
1153
|
+
const nowMs = Date.now();
|
|
1154
|
+
if (
|
|
1155
|
+
!Number.isSafeInteger(createdSeconds) ||
|
|
1156
|
+
!Number.isSafeInteger(expiresSeconds) ||
|
|
1157
|
+
expiresSeconds <= createdSeconds ||
|
|
1158
|
+
createdMs > nowMs + RFC_SIGNATURE_CLOCK_SKEW_MS ||
|
|
1159
|
+
createdMs < nowMs - signatureMaxAgeMs ||
|
|
1160
|
+
expiresMs < nowMs - RFC_SIGNATURE_CLOCK_SKEW_MS ||
|
|
1161
|
+
expiresMs - createdMs > signatureMaxAgeMs
|
|
1162
|
+
) {
|
|
1163
|
+
throw new Error('HTTP Message Signature is expired or outside the allowed lifetime');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const rfcNonce = nonceMatch[1];
|
|
1167
|
+
if (!/^[\x21-\x7e]{16,256}$/.test(rfcNonce)) {
|
|
1168
|
+
throw new Error('HTTP Message Signature nonce must be a printable value of 16 to 256 characters');
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const contentDigest = req.headers['content-digest'];
|
|
1172
|
+
if (typeof contentDigest !== 'string' || contentDigest !== buildRequestContentDigest(req)) {
|
|
1173
|
+
throw new Error('HTTP Message Signature content-digest does not match the request body');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const alg = algMatch[1];
|
|
1177
|
+
const rfcReplayKey = `${cacheNamespace}:rfc:${keyid}:${rfcNonce}`;
|
|
1178
|
+
|
|
1179
|
+
if (/^https?:\/\//i.test(keyid)) {
|
|
1180
|
+
throw new Error("Remote keyid URLs are not accepted; configure a trusted JWKS and use its exact key id");
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// v4 (fail closed): RFC 9421 verification requires a merchant-
|
|
1184
|
+
// configured trust root. The old third-party default JWKS meant
|
|
1185
|
+
// unconfigured merchants accepted an external provider's signing
|
|
1186
|
+
// keys for their own write actions.
|
|
1187
|
+
const trustedJwks = config.agentJwks;
|
|
1188
|
+
const trustedJwksUrl = config.agentJwksUrl;
|
|
1189
|
+
if (!trustedJwks && !trustedJwksUrl) {
|
|
1190
|
+
return res.status(503).json({
|
|
1191
|
+
error: "Forbidden",
|
|
1192
|
+
message: "RFC 9421 verification is not configured: set config.agentJwks or config.agentJwksUrl to your agent population's JWKS."
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
const cacheKey = `${cacheNamespace}:${trustedJwks ? 'inline' : trustedJwksUrl}:${keyid}`;
|
|
1196
|
+
let publicKey = null;
|
|
1197
|
+
const cachedEntry = jwksCacheGet(cacheKey);
|
|
1198
|
+
if (cachedEntry && cachedEntry.key) {
|
|
1199
|
+
publicKey = cachedEntry.key;
|
|
1200
|
+
} else if (cachedEntry && cachedEntry.negative) {
|
|
1201
|
+
// Second-pass review: negative caching - an unknown keyid must
|
|
1202
|
+
// not re-trigger a JWKS fetch on every request.
|
|
1203
|
+
throw new Error("Configured JWKS does not contain the requested key id");
|
|
1204
|
+
} else {
|
|
1205
|
+
let jwks;
|
|
1206
|
+
if (trustedJwks) {
|
|
1207
|
+
jwks = trustedJwks;
|
|
1208
|
+
} else {
|
|
1209
|
+
const parsedJwksUrl = new URL(trustedJwksUrl);
|
|
1210
|
+
if (parsedJwksUrl.protocol !== 'https:') {
|
|
1211
|
+
throw new Error("Configured agentJwksUrl must use HTTPS");
|
|
1212
|
+
}
|
|
1213
|
+
const jwksRes = await fetch(parsedJwksUrl.href, {
|
|
1214
|
+
redirect: 'error',
|
|
1215
|
+
signal: AbortSignal.timeout(5000),
|
|
1216
|
+
});
|
|
1217
|
+
if (!jwksRes.ok) throw new Error(`JWKS request failed with HTTP ${jwksRes.status}`);
|
|
1218
|
+
jwks = await jwksRes.json();
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (!Array.isArray(jwks?.keys)) throw new Error("Configured JWKS is invalid");
|
|
1222
|
+
const jwk = jwks.keys.find((candidate) => candidate && candidate.kid === keyid);
|
|
1223
|
+
if (!jwk) {
|
|
1224
|
+
jwksCachePut(cacheKey, { negative: true, expires: Date.now() + JWKS_NEGATIVE_TTL_MS });
|
|
1225
|
+
throw new Error("Configured JWKS does not contain the requested key id");
|
|
1226
|
+
}
|
|
1227
|
+
publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
|
1228
|
+
jwksCachePut(cacheKey, { key: publicKey, expires: Date.now() + 3600 * 1000 });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// v4 medium pass (V14): pin the declared algorithm family to the
|
|
1232
|
+
// resolved key type so an attacker cannot shop algorithms against
|
|
1233
|
+
// an unrelated key, and derive the digest correctly - the old code
|
|
1234
|
+
// mapped EVERY non-512 alg to SHA-256, silently breaking 384 and
|
|
1235
|
+
// rejecting Ed25519 keys outright.
|
|
1236
|
+
const keyTypeName = String(publicKey.asymmetricKeyType || '').toLowerCase();
|
|
1237
|
+
const algLower = alg.toLowerCase();
|
|
1238
|
+
const isEdKey = keyTypeName === 'ed25519' || keyTypeName === 'ed448';
|
|
1239
|
+
const algFamilyCompatible =
|
|
1240
|
+
(keyTypeName === 'rsa' && algLower.startsWith('rs')) ||
|
|
1241
|
+
(keyTypeName === 'ec' && algLower.startsWith('es')) ||
|
|
1242
|
+
(isEdKey && algLower.startsWith('ed'));
|
|
1243
|
+
if (!algFamilyCompatible) {
|
|
1244
|
+
throw new Error(`Signature algorithm ${alg} is not compatible with the configured key type`);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
let signatureBase = '';
|
|
1248
|
+
for (const comp of components) {
|
|
1249
|
+
if (comp === '@method') signatureBase += `"@method": ${req.method.toLowerCase()}\n`;
|
|
1250
|
+
else if (comp === '@path') signatureBase += `"@path": ${req.originalUrl || req.path}\n`;
|
|
1251
|
+
else if (comp === '@authority') signatureBase += `"@authority": ${req.headers.host}\n`;
|
|
1252
|
+
else signatureBase += `"${comp}": ${req.headers[comp] || ''}\n`;
|
|
1253
|
+
}
|
|
1254
|
+
const sigParams = rfcSignatureInput.replace(/^[a-zA-Z0-9_]+=\s*/, '');
|
|
1255
|
+
signatureBase += `"@signature-params": ${sigParams}`;
|
|
1256
|
+
|
|
1257
|
+
const sigMatch = rfcSignature.match(/=:([a-zA-Z0-9+/=]+):/);
|
|
1258
|
+
const rawSig = sigMatch ? sigMatch[1] : rfcSignature.replace(/^[a-zA-Z0-9_]+=\s*/, '');
|
|
1259
|
+
const signatureBuffer = Buffer.from(rawSig, 'base64');
|
|
1260
|
+
|
|
1261
|
+
const hashAlg = isEdKey
|
|
1262
|
+
? null // Ed25519/Ed448 sign the message itself, no digest
|
|
1263
|
+
: algLower.includes('512') ? 'SHA512' : algLower.includes('384') ? 'SHA384' : 'SHA256';
|
|
1264
|
+
|
|
1265
|
+
const isValid = crypto.verify(hashAlg, Buffer.from(signatureBase), publicKey, signatureBuffer);
|
|
1266
|
+
|
|
1267
|
+
if (!isValid) return res.status(403).json({ error: "Forbidden", message: "Invalid HTTP Message Signature." });
|
|
1268
|
+
// v4: atomic claim - the previous has()-then-set() pair had a
|
|
1269
|
+
// race window where two concurrent identical requests could both
|
|
1270
|
+
// pass before either recorded the nonce. Exactly one of N
|
|
1271
|
+
// concurrent claims now wins.
|
|
1272
|
+
const claimed = await signatureNonceMap.claim(rfcReplayKey, expiresMs);
|
|
1273
|
+
if (!claimed) {
|
|
1274
|
+
return res.status(403).json({
|
|
1275
|
+
error: "Forbidden",
|
|
1276
|
+
message: "HTTP Message Signature nonce has already been used."
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
authenticatedCacheIdentity = `rfc:${keyid}`;
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
// v4 medium pass (V13): verification internals (err.message) were
|
|
1282
|
+
// previously reflected to the caller. Log server-side, respond
|
|
1283
|
+
// with a generic message.
|
|
1284
|
+
console.error("🌸 AgentsBloom: RFC 9421 signature verification failed:", err.message);
|
|
1285
|
+
return res.status(403).json({ error: "Forbidden", message: "Invalid HTTP Message Signature." });
|
|
1286
|
+
}
|
|
1287
|
+
} else {
|
|
1288
|
+
// v4 medium pass (V11): resolve a PER-AGENT secret when one is
|
|
1289
|
+
// configured, and reject revoked identifiers outright.
|
|
1290
|
+
if (revokedIdentifiers && typeof identifier === 'string' && revokedIdentifiers.has(identifier)) {
|
|
1291
|
+
return res.status(403).json({
|
|
1292
|
+
error: "Verification Failed",
|
|
1293
|
+
message: "X-Agent-Identifier has been revoked."
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
const perAgentSecret = agentKeys && typeof identifier === 'string' ? agentKeys[identifier] : undefined;
|
|
1297
|
+
const effectiveSecret = (typeof perAgentSecret === 'string' && perAgentSecret.length > 0)
|
|
1298
|
+
? perAgentSecret
|
|
1299
|
+
: agentSecret;
|
|
1300
|
+
|
|
1301
|
+
const headerValuePattern = /^[\x21-\x7e]+$/;
|
|
1302
|
+
if (
|
|
1303
|
+
!effectiveSecret ||
|
|
1304
|
+
typeof signature !== 'string' ||
|
|
1305
|
+
typeof identifier !== 'string' ||
|
|
1306
|
+
typeof timestamp !== 'string' ||
|
|
1307
|
+
typeof nonce !== 'string' ||
|
|
1308
|
+
identifier.length > 256 ||
|
|
1309
|
+
nonce.length < 16 ||
|
|
1310
|
+
nonce.length > 256 ||
|
|
1311
|
+
!headerValuePattern.test(identifier) ||
|
|
1312
|
+
!headerValuePattern.test(timestamp) ||
|
|
1313
|
+
!headerValuePattern.test(nonce)
|
|
1314
|
+
) {
|
|
1315
|
+
return res.status(403).json({
|
|
1316
|
+
error: "Verification Failed",
|
|
1317
|
+
message: "X-Agent-Signature requires a configured secret, identifier, timestamp, and nonce."
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const timestampSeconds = Number(timestamp);
|
|
1322
|
+
const timestampMs = timestampSeconds * 1000;
|
|
1323
|
+
const nowMs = Date.now();
|
|
1324
|
+
if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowMs - timestampMs) > signatureMaxAgeMs) {
|
|
1325
|
+
return res.status(403).json({
|
|
1326
|
+
error: "Verification Failed",
|
|
1327
|
+
message: "X-Agent-Signature is expired or has an invalid timestamp."
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const replayKey = `${cacheNamespace}:legacy:${identifier}:${nonce}`;
|
|
1332
|
+
|
|
1333
|
+
let signaturePayload;
|
|
1334
|
+
try {
|
|
1335
|
+
signaturePayload = buildLegacySignaturePayload(req, identifier, timestamp, nonce);
|
|
1336
|
+
} catch {
|
|
1337
|
+
return res.status(400).json({
|
|
1338
|
+
error: "Invalid Request",
|
|
1339
|
+
message: "Request body cannot be serialized for signature verification."
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
// Rotation window: a signature minted under the PREVIOUS secret
|
|
1343
|
+
// still verifies while merchants roll AGENTSBLOOM_SECRET. Per-agent
|
|
1344
|
+
// keys (V11) are checked first; the shared secret(s) remain the
|
|
1345
|
+
// fallback so existing agents keep working during migration.
|
|
1346
|
+
if (!verifySignature(signature, signaturePayload, effectiveSecret)
|
|
1347
|
+
&& !(agentSecret && agentSecret !== effectiveSecret && verifySignature(signature, signaturePayload, agentSecret))
|
|
1348
|
+
&& !(agentSecretPrevious && verifySignature(signature, signaturePayload, agentSecretPrevious))) {
|
|
1349
|
+
return res.status(403).json({
|
|
1350
|
+
error: "Verification Failed",
|
|
1351
|
+
message: "X-Agent-Signature is invalid. Access denied."
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// v4: atomic claim AFTER successful verification - failed attempts
|
|
1356
|
+
// never burn the nonce, and concurrent replays cannot slip through
|
|
1357
|
+
// the old has()/set() race window.
|
|
1358
|
+
const claimed = await signatureNonceMap.claim(replayKey, timestampMs + signatureMaxAgeMs);
|
|
1359
|
+
if (!claimed) {
|
|
1360
|
+
return res.status(403).json({
|
|
1361
|
+
error: "Verification Failed",
|
|
1362
|
+
message: "X-Agent-Signature nonce has already been used."
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
authenticatedCacheIdentity = `legacy:${identifier}`;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Expose the verified signature identity (rfc:<keyid> or legacy:<id>)
|
|
1370
|
+
// to merchant handlers: stores persist it on orders to correlate
|
|
1371
|
+
// purchases with the agent-reputation system. Null for unsigned
|
|
1372
|
+
// traffic - never trust the raw self-asserted header alone.
|
|
1373
|
+
req.agentIdentity = authenticatedCacheIdentity;
|
|
1374
|
+
|
|
1375
|
+
// --- 3b. IDEMPOTENCY CHECKS FOR WRITES (POST/PUT/PATCH/DELETE) ---
|
|
1376
|
+
// Perform this lookup only after the protected-route authentication gate.
|
|
1377
|
+
// Cache keys are scoped to this middleware instance and the verified agent
|
|
1378
|
+
// identity so one caller cannot replay another caller's cached response.
|
|
1379
|
+
if (isWrite && idempotencyHeader) {
|
|
1380
|
+
const requestedIdentity = typeof req.headers['x-agent-identifier'] === 'string'
|
|
1381
|
+
? req.headers['x-agent-identifier']
|
|
1382
|
+
: 'anonymous';
|
|
1383
|
+
const cacheIdentity = authenticatedCacheIdentity || `anonymous:${requestedIdentity}`;
|
|
1384
|
+
let serializedRequestBody;
|
|
1385
|
+
try {
|
|
1386
|
+
serializedRequestBody = JSON.stringify(req.body ?? null);
|
|
1387
|
+
} catch {
|
|
1388
|
+
return res.status(400).json({
|
|
1389
|
+
error: "Invalid Request",
|
|
1390
|
+
message: "Request body cannot be serialized for idempotency verification."
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
const cacheKeyMaterial = [
|
|
1394
|
+
cacheNamespace,
|
|
1395
|
+
cacheIdentity,
|
|
1396
|
+
method,
|
|
1397
|
+
req.originalUrl || req.path,
|
|
1398
|
+
serializedRequestBody,
|
|
1399
|
+
idempotencyHeader,
|
|
1400
|
+
].join('\u0000');
|
|
1401
|
+
idempotencyKey = `${cacheNamespace}:${crypto.createHash('sha256').update(cacheKeyMaterial).digest('hex')}`;
|
|
1402
|
+
|
|
1403
|
+
for (const [key, val] of idempotencyMap.entries()) {
|
|
1404
|
+
if (val.expiry < now) {
|
|
1405
|
+
idempotencyMap.delete(key);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const cachedResponse = idempotencyMap.get(idempotencyKey);
|
|
1410
|
+
if (cachedResponse) {
|
|
1411
|
+
console.log('🌸 AgentsBloom: Found cached response for an Idempotency Key');
|
|
1412
|
+
res.setHeader('X-Cache', 'Idempotent-Hit');
|
|
1413
|
+
res.setHeader('Content-Type', cachedResponse.headers['content-type'] || 'application/json');
|
|
1414
|
+
return res.status(cachedResponse.status).send(cachedResponse.responseBody);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// --- 4b. AP2 MANDATE VERIFICATION (Wired into middleware) ---
|
|
1419
|
+
// v4: the legacy `authorization` header fallback is now only honored on
|
|
1420
|
+
// requests that are ALREADY AP2-shaped (an /ap2 path or x-protocol AP2).
|
|
1421
|
+
// Previously ANY request carrying a dotted Authorization value - which
|
|
1422
|
+
// includes every ordinary OAuth/JWT bearer token - was parsed as an AP2
|
|
1423
|
+
// SD-JWT, failed verification, and had the whole request rejected 403,
|
|
1424
|
+
// breaking unrelated authenticated routes. The explicit
|
|
1425
|
+
// `x-ap2-mandate` header works everywhere as before.
|
|
1426
|
+
const detectedProtocol = resolveProtocol(req);
|
|
1427
|
+
const rawAuthorizationHeader = req.headers['authorization'];
|
|
1428
|
+
const ap2MandateHeader = req.headers['x-ap2-mandate']
|
|
1429
|
+
|| (detectedProtocol === 'AP2' && typeof rawAuthorizationHeader === 'string'
|
|
1430
|
+
? rawAuthorizationHeader
|
|
1431
|
+
: null);
|
|
1432
|
+
let ap2MandateResult = null;
|
|
1433
|
+
|
|
1434
|
+
if (detectedProtocol === 'AP2' || (ap2MandateHeader && ap2MandateHeader.includes('.'))) {
|
|
1435
|
+
// Second-pass review (N2): the mandate's single-use jti must only be
|
|
1436
|
+
// consumed where the mandate is actually USED. Verification still
|
|
1437
|
+
// runs wherever a mandate is presented (so merchant handlers keep
|
|
1438
|
+
// receiving req.ap2Mandate), but a mandate riding along on an
|
|
1439
|
+
// unrelated cart-add no longer burns itself.
|
|
1440
|
+
const isAp2Endpoint = req.path.startsWith('/ap2/') || req.path.startsWith('/v1/ap2/');
|
|
1441
|
+
// Verify the SD-JWT mandate before allowing any write action. Uses
|
|
1442
|
+
// either a merchant-configured trusted public key (config.ap2PublicKey)
|
|
1443
|
+
// or, when absent, derives a key from the mandate's own did:key issuer
|
|
1444
|
+
// (self-certifying - see lib/ap2.js for why an unverifiable mandate is
|
|
1445
|
+
// now rejected outright rather than passed through as "valid").
|
|
1446
|
+
ap2MandateResult = await verifyAP2Mandates(req.headers, req.body || {}, {
|
|
1447
|
+
trustedPublicKey: config.ap2PublicKey || null,
|
|
1448
|
+
// v4: production-grade trust policy. When ap2.trustedIssuersOnly is
|
|
1449
|
+
// set, self-certifying did:key mandates (which anyone can mint for
|
|
1450
|
+
// any budget) are rejected in favor of the merchant-trusted key.
|
|
1451
|
+
allowSelfCertifying: config.ap2?.trustedIssuersOnly !== true,
|
|
1452
|
+
// Audience binding prefers explicit configuration, then the
|
|
1453
|
+
// merchant-configured baseUrl (a trusted deployment constant), and
|
|
1454
|
+
// only falls back to the Host-derived requestUrl when neither is
|
|
1455
|
+
// set - deriving the expected audience purely from the request's
|
|
1456
|
+
// own Host header would let an attacker align a stolen mandate's
|
|
1457
|
+
// audience with a spoofed Host.
|
|
1458
|
+
expectedAudience: config.ap2?.expectedAudience || (baseUrl ? baseUrl : requestUrl),
|
|
1459
|
+
maxMandateLifetimeSec: config.ap2?.maxMandateLifetimeSec,
|
|
1460
|
+
requireJti: config.ap2?.requireJti,
|
|
1461
|
+
requestedCategories: req.body?.requestedCategories || config.ap2?.requestedCategories,
|
|
1462
|
+
expectedCurrency: config.ap2?.expectedCurrency,
|
|
1463
|
+
consumeJti: isAp2Endpoint,
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
if (!ap2MandateResult.valid) {
|
|
1467
|
+
return res.status(403).json({
|
|
1468
|
+
error: "AP2 Mandate Rejected",
|
|
1469
|
+
protocol: "AP2",
|
|
1470
|
+
reason: ap2MandateResult.reason,
|
|
1471
|
+
message: "The Verifiable Intent mandate failed validation. The agent's payment authorization is invalid."
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Attach mandate info to request for downstream handlers
|
|
1476
|
+
req.ap2Mandate = ap2MandateResult;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// --- AP2 Discovery Endpoint ---
|
|
1480
|
+
if (req.path === '/ap2/capabilities' || req.path === '/v1/ap2/capabilities') {
|
|
1481
|
+
return res.json({
|
|
1482
|
+
protocol: "AP2",
|
|
1483
|
+
version: "1.0.0",
|
|
1484
|
+
store: { name, description, baseUrl: requestUrl },
|
|
1485
|
+
mandateTypes: ["intentMandate", "cartMandate", "paymentMandate"],
|
|
1486
|
+
verificationMethods: ["sd-jwt", "jwt"],
|
|
1487
|
+
endpoints: {
|
|
1488
|
+
capabilities: `${requestUrl}/ap2/capabilities`,
|
|
1489
|
+
intent: `${requestUrl}/ap2/intent`,
|
|
1490
|
+
checkout: `${requestUrl}/ap2/checkout`,
|
|
1491
|
+
actions: Object.keys(actions).map(k => `${requestUrl}/v1/agent/actions/${k}`)
|
|
1492
|
+
},
|
|
1493
|
+
budgetEnforcement: true,
|
|
1494
|
+
signatureAlgorithms: ["ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA"]
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// --- AP2 Intent Endpoint (agent announces what it wants to do) ---
|
|
1499
|
+
if ((req.path === '/ap2/intent' || req.path === '/v1/ap2/intent') && req.method === 'POST') {
|
|
1500
|
+
// Must check `.verified`, not mere truthiness: ap2MandateResult is a
|
|
1501
|
+
// truthy object even when no mandate header was ever presented
|
|
1502
|
+
// (`{ valid: true, verified: false, note: '...' }`), so a bare
|
|
1503
|
+
// `if (!ap2MandateResult)` check would let unauthenticated requests
|
|
1504
|
+
// reach this mandate-gated endpoint.
|
|
1505
|
+
if (!ap2MandateResult?.verified) {
|
|
1506
|
+
return res.status(401).json({
|
|
1507
|
+
error: "AP2 Mandate Required",
|
|
1508
|
+
message: "Send x-ap2-mandate: Bearer <sd-jwt> header with a valid Intent Mandate."
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
return res.json({
|
|
1512
|
+
protocol: "AP2",
|
|
1513
|
+
intentAccepted: true,
|
|
1514
|
+
mandateVerified: ap2MandateResult.verified,
|
|
1515
|
+
mandates: ap2MandateResult.mandates || {},
|
|
1516
|
+
availableActions: Object.entries(actions).map(([key, val]) => ({
|
|
1517
|
+
action: key,
|
|
1518
|
+
endpoint: `/v1/agent/actions/${key}`,
|
|
1519
|
+
method: val.method || 'POST',
|
|
1520
|
+
description: val.description
|
|
1521
|
+
})),
|
|
1522
|
+
budgetRemaining: ap2MandateResult.mandates?.intentMandate?.maxBudget || "unlimited"
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// --- AP2 Checkout Endpoint (mandate-gated checkout) ---
|
|
1527
|
+
if ((req.path === '/ap2/checkout' || req.path === '/v1/ap2/checkout') && req.method === 'POST') {
|
|
1528
|
+
// Same fix as /ap2/intent above: this MUST require a genuinely
|
|
1529
|
+
// verified mandate, not just a truthy result object. Previously,
|
|
1530
|
+
// hitting /ap2/checkout with no x-ap2-mandate header at all still
|
|
1531
|
+
// produced a truthy `ap2MandateResult` (valid:true, verified:false),
|
|
1532
|
+
// which passed this check, then found no `.mandates.intentMandate`
|
|
1533
|
+
// to read a maxBudget from - so checkout proceeded with ZERO budget
|
|
1534
|
+
// enforcement. Requiring `.verified` closes that bypass.
|
|
1535
|
+
if (!ap2MandateResult?.verified) {
|
|
1536
|
+
return res.status(401).json({
|
|
1537
|
+
error: "AP2 Payment Mandate Required",
|
|
1538
|
+
message: "AP2 checkout requires x-ap2-mandate header with a valid Payment Mandate."
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const checkoutAction = actions['checkout'];
|
|
1543
|
+
if (!checkoutAction || typeof checkoutAction.handler !== 'function') {
|
|
1544
|
+
return res.status(404).json({ error: 'Checkout action not configured for this store.' });
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// v4 medium pass (V12): mandate verification proves budget intent,
|
|
1548
|
+
// not identity authorization - the checkout action still respects
|
|
1549
|
+
// the merchant's per-action policy.
|
|
1550
|
+
const checkoutAccessRejection = authorizeAction('checkout', authenticatedCacheIdentity);
|
|
1551
|
+
if (checkoutAccessRejection) {
|
|
1552
|
+
return res.status(checkoutAccessRejection.status).json(checkoutAccessRejection.body);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Validate budget before executing checkout. A checkout-time mandate
|
|
1556
|
+
// MUST declare a positive maxBudget: a capless mandate would
|
|
1557
|
+
// authorize unlimited spend (previously it produced
|
|
1558
|
+
// budgetEnforced:false and the checkout proceeded at any total).
|
|
1559
|
+
const orderTotal = req.body?.orderTotal || req.body?.total || 0;
|
|
1560
|
+
const maxBudget = ap2MandateResult.mandates?.intentMandate?.maxBudget
|
|
1561
|
+
|| ap2MandateResult.mandates?.paymentMandate?.maxBudget;
|
|
1562
|
+
|
|
1563
|
+
if (!Number.isFinite(Number(maxBudget)) || Number(maxBudget) <= 0) {
|
|
1564
|
+
return res.status(403).json({
|
|
1565
|
+
error: "AP2 Mandate Rejected",
|
|
1566
|
+
protocol: "AP2",
|
|
1567
|
+
reason: "Mandate must declare a positive intentMandate.maxBudget for checkout",
|
|
1568
|
+
message: "A checkout mandate without a spending cap authorizes unlimited spend and is rejected. Reissue the mandate with maxBudget."
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// v4 medium pass (V17): a budget number without a matching currency
|
|
1573
|
+
// is unitless - reject unit-mismatch attempts before any handler run.
|
|
1574
|
+
const mandateCurrency = ap2MandateResult.mandates?.intentMandate?.currency
|
|
1575
|
+
|| ap2MandateResult.mandates?.paymentMandate?.currency;
|
|
1576
|
+
const normalizeCurrency = (value) => String(value || '').trim().toUpperCase();
|
|
1577
|
+
const expectedCheckoutCurrency = config.ap2?.expectedCurrency;
|
|
1578
|
+
if (
|
|
1579
|
+
mandateCurrency && expectedCheckoutCurrency &&
|
|
1580
|
+
normalizeCurrency(mandateCurrency) !== normalizeCurrency(expectedCheckoutCurrency)
|
|
1581
|
+
) {
|
|
1582
|
+
return res.status(403).json({
|
|
1583
|
+
error: "AP2 Mandate Rejected",
|
|
1584
|
+
protocol: "AP2",
|
|
1585
|
+
reason: `Mandate currency "${mandateCurrency}" does not match this store's expected currency "${expectedCheckoutCurrency}"`,
|
|
1586
|
+
message: "The mandate was issued for a different currency than this store accepts."
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
if (
|
|
1590
|
+
mandateCurrency && req.body?.currency &&
|
|
1591
|
+
normalizeCurrency(mandateCurrency) !== normalizeCurrency(req.body.currency)
|
|
1592
|
+
) {
|
|
1593
|
+
return res.status(403).json({
|
|
1594
|
+
error: "AP2 Budget Exceeded",
|
|
1595
|
+
protocol: "AP2",
|
|
1596
|
+
reason: `Order currency "${req.body.currency}" does not match the mandate's currency "${mandateCurrency}"`,
|
|
1597
|
+
message: "Order currency does not match the mandate's declared currency."
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (Number(orderTotal) > Number(maxBudget)) {
|
|
1602
|
+
return res.status(403).json({
|
|
1603
|
+
error: "AP2 Budget Exceeded",
|
|
1604
|
+
protocol: "AP2",
|
|
1605
|
+
orderTotal: Number(orderTotal),
|
|
1606
|
+
maxBudget: Number(maxBudget),
|
|
1607
|
+
message: `Order total $${orderTotal} exceeds mandate budget limit of $${maxBudget}.`
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// v4 (budget bypass fix): the client-declared orderTotal above is a
|
|
1612
|
+
// fast-fail convenience only - it is attacker-controlled and can
|
|
1613
|
+
// never be the basis of authorization. The AUTHORITATIVE check runs
|
|
1614
|
+
// after the merchant handler returns, against the handler-computed
|
|
1615
|
+
// total (result.total / result.totalPrice). If the real cart total
|
|
1616
|
+
// exceeds the mandate budget, no payment URL is returned.
|
|
1617
|
+
const issuerTrust = ap2MandateResult.selfCertifying ? 'self-certifying' : 'merchant-trusted';
|
|
1618
|
+
|
|
1619
|
+
return Promise.resolve(checkoutAction.handler(req.body, req, res))
|
|
1620
|
+
.then(result => {
|
|
1621
|
+
const serverTotal = result && (result.total ?? result.totalPrice);
|
|
1622
|
+
const effectiveTotal = serverTotal !== undefined ? Number(serverTotal) : Number(orderTotal);
|
|
1623
|
+
if (Number.isFinite(effectiveTotal) && effectiveTotal > Number(maxBudget)) {
|
|
1624
|
+
return res.status(403).json({
|
|
1625
|
+
error: "AP2 Budget Exceeded",
|
|
1626
|
+
protocol: "AP2",
|
|
1627
|
+
reason: "Server-computed order total exceeds the mandate's maxBudget; the client-declared orderTotal is never authoritative.",
|
|
1628
|
+
orderTotal: effectiveTotal,
|
|
1629
|
+
totalSource: serverTotal !== undefined ? 'handler' : 'client-declared',
|
|
1630
|
+
maxBudget: Number(maxBudget),
|
|
1631
|
+
message: `Order total $${effectiveTotal} computed by the store exceeds mandate budget limit of $${maxBudget}. No checkout link was issued.`
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
if (!res.headersSent) {
|
|
1635
|
+
res.json({
|
|
1636
|
+
protocol: "AP2",
|
|
1637
|
+
verifiableIntent: {
|
|
1638
|
+
mandateVerified: ap2MandateResult.verified,
|
|
1639
|
+
budgetEnforced: !!maxBudget,
|
|
1640
|
+
issuerTrust,
|
|
1641
|
+
maxBudget: maxBudget || null,
|
|
1642
|
+
orderTotal: result.total || orderTotal,
|
|
1643
|
+
...(serverTotal !== undefined ? { totalSource: 'handler' } : {})
|
|
1644
|
+
},
|
|
1645
|
+
session_id: result.sessionId || `ap2_sess_${Date.now()}`,
|
|
1646
|
+
payment_url: result.paymentUrl,
|
|
1647
|
+
status: "authorized",
|
|
1648
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
1649
|
+
checkout: result
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
})
|
|
1653
|
+
.catch(err => {
|
|
1654
|
+
console.error("AP2 Checkout error:", err);
|
|
1655
|
+
if (!res.headersSent) {
|
|
1656
|
+
res.status(500).json({ error: 'Internal AP2 checkout error', protocol: 'AP2' });
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// --- 5. HANDLE ACTION ROUTING ---
|
|
1662
|
+
// Agentic Commerce Protocol (ACP) Checkout Wrapper
|
|
1663
|
+
if (req.path === '/api/agentsbloom/checkout/acp' && req.method === 'POST') {
|
|
1664
|
+
const checkoutAction = actions['checkout'];
|
|
1665
|
+
if (checkoutAction && typeof checkoutAction.handler === 'function') {
|
|
1666
|
+
return Promise.resolve(checkoutAction.handler(req.body, req, res))
|
|
1667
|
+
.then(result => {
|
|
1668
|
+
if (!res.headersSent) {
|
|
1669
|
+
res.json({
|
|
1670
|
+
session_id: result.sessionId || `acp_sess_${Date.now()}`,
|
|
1671
|
+
payment_url: result.paymentUrl,
|
|
1672
|
+
status: "open",
|
|
1673
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
})
|
|
1677
|
+
.catch(err => {
|
|
1678
|
+
console.error(`ACP Checkout execution error:`, err);
|
|
1679
|
+
if (!res.headersSent) {
|
|
1680
|
+
res.status(500).json({ error: 'Internal ACP checkout error' });
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
} else {
|
|
1684
|
+
return res.status(404).json({ error: 'ACP Checkout not configured for this store.' });
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (req.path.startsWith('/api/agentsbloom/') || req.path.startsWith('/v1/agent/actions/')) {
|
|
1689
|
+
const actionName = requestedActionName;
|
|
1690
|
+
const action = actions[actionName];
|
|
1691
|
+
|
|
1692
|
+
if (action && typeof action.handler === 'function') {
|
|
1693
|
+
const configuredMethod = String(action.method || 'POST').toUpperCase();
|
|
1694
|
+
if (method !== configuredMethod) {
|
|
1695
|
+
res.setHeader('Allow', configuredMethod);
|
|
1696
|
+
return res.status(405).json({
|
|
1697
|
+
error: "Method Not Allowed",
|
|
1698
|
+
message: `Action ${actionName} only accepts ${configuredMethod} requests.`,
|
|
1699
|
+
allowedMethod: configuredMethod,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// v4 medium pass (V12): per-action authorization - the SDK had no
|
|
1704
|
+
// authorization model at all; any verified identity could invoke
|
|
1705
|
+
// any write action including checkout.
|
|
1706
|
+
const accessRejection = authorizeAction(actionName, authenticatedCacheIdentity);
|
|
1707
|
+
if (accessRejection) {
|
|
1708
|
+
return res.status(accessRejection.status).json(accessRejection.body);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const params = method === 'GET' ? { ...req.query } : { ...req.body };
|
|
1712
|
+
|
|
1713
|
+
// v4: shared with the MCP tools/call path so both surfaces enforce
|
|
1714
|
+
// the same declared parameter contract.
|
|
1715
|
+
const validation = validateActionParams(params, action);
|
|
1716
|
+
if (!validation.ok) {
|
|
1717
|
+
return res.status(400).json({ error: "Invalid Parameter", message: validation.message });
|
|
1718
|
+
}
|
|
1719
|
+
const validatedParams = validation.params;
|
|
1720
|
+
|
|
1721
|
+
// Cache original res.send to support idempotency key caching
|
|
1722
|
+
const originalSend = res.send;
|
|
1723
|
+
res.send = function (body) {
|
|
1724
|
+
if (isWrite && idempotencyKey && res.statusCode >= 200 && res.statusCode < 300) {
|
|
1725
|
+
// v4 (V18): evict the oldest entry at capacity instead of
|
|
1726
|
+
// growing without bound between TTL sweeps.
|
|
1727
|
+
if (!idempotencyMap.has(idempotencyKey) && idempotencyMap.size >= IDEMPOTENCY_MAX_ENTRIES) {
|
|
1728
|
+
const oldestKey = idempotencyMap.keys().next().value;
|
|
1729
|
+
if (oldestKey !== undefined) idempotencyMap.delete(oldestKey);
|
|
1730
|
+
}
|
|
1731
|
+
idempotencyMap.set(idempotencyKey, {
|
|
1732
|
+
responseBody: body,
|
|
1733
|
+
status: res.statusCode,
|
|
1734
|
+
headers: { 'content-type': res.getHeader('content-type') },
|
|
1735
|
+
timestamp: Date.now(),
|
|
1736
|
+
expiry: Date.now() + IDEMPOTENCY_TTL
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
return originalSend.apply(this, arguments);
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
const startTime = Date.now();
|
|
1743
|
+
const agentName = req.headers['x-agent-identifier'] || req.headers['signature-agent'] || 'Unknown Agent';
|
|
1744
|
+
|
|
1745
|
+
// Extract W3C Trace Context from incoming request (HIGH-16)
|
|
1746
|
+
const parentContext = propagation.extract(context.active(), req.headers);
|
|
1747
|
+
const span = tracer.startSpan(`agent_request:${actionName}`, {}, parentContext);
|
|
1748
|
+
|
|
1749
|
+
return Promise.resolve(action.handler(validatedParams, req, res))
|
|
1750
|
+
.then(result => {
|
|
1751
|
+
if (!res.headersSent) {
|
|
1752
|
+
res.json(result);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const latencyMs = Date.now() - startTime;
|
|
1756
|
+
const revenue = result && result.totalPrice ? result.totalPrice : 0;
|
|
1757
|
+
|
|
1758
|
+
// OpenTelemetry Native Instrumentation
|
|
1759
|
+
span.setAttribute('agent.name', agentName);
|
|
1760
|
+
span.setAttribute('agent.route', `/api/agentsbloom/${actionName}`);
|
|
1761
|
+
span.setAttribute('http.status_code', res.statusCode);
|
|
1762
|
+
span.setAttribute('http.latency_ms', latencyMs);
|
|
1763
|
+
|
|
1764
|
+
agentRequestsCounter.add(1, { agent: agentName });
|
|
1765
|
+
if (revenue) agentRevenueCounter.add(revenue, { agent: agentName });
|
|
1766
|
+
span.end();
|
|
1767
|
+
|
|
1768
|
+
// Telemetry is handled by OTLP exporters configured via setupTelemetry().
|
|
1769
|
+
// The span.end() call above will auto-export to the OTLP collector.
|
|
1770
|
+
})
|
|
1771
|
+
.catch(err => {
|
|
1772
|
+
span.setAttribute('error', true);
|
|
1773
|
+
span.end();
|
|
1774
|
+
console.error(`AgentsBloom action execution error (${actionName}):`, err);
|
|
1775
|
+
if (!res.headersSent) {
|
|
1776
|
+
res.status(500).json({ error: 'Internal agent endpoint error' });
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
} else {
|
|
1780
|
+
return res.status(404).json({ error: `Unknown AgentsBloom action.` });
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// --- 6. HTML INJECTION WITH COMPRESSION SUPPORT ---
|
|
1785
|
+
// Opt-out via config.disableHtmlInjection (MED-12)
|
|
1786
|
+
if (config.disableHtmlInjection) {
|
|
1787
|
+
return next();
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const originalWrite = res.write;
|
|
1791
|
+
const originalEnd = res.end;
|
|
1792
|
+
let chunks = [];
|
|
1793
|
+
let isHtml = false;
|
|
1794
|
+
|
|
1795
|
+
const originalWriteHead = res.writeHead;
|
|
1796
|
+
res.writeHead = function (statusCode, headers) {
|
|
1797
|
+
const contentType = res.getHeader('Content-Type') || (headers && headers['content-type']) || '';
|
|
1798
|
+
if (typeof contentType === 'string' && contentType.includes('text/html')) {
|
|
1799
|
+
isHtml = true;
|
|
1800
|
+
res.removeHeader('Content-Length');
|
|
1801
|
+
if (headers) delete headers['content-length'];
|
|
1802
|
+
}
|
|
1803
|
+
return originalWriteHead.apply(this, arguments);
|
|
1804
|
+
};
|
|
1805
|
+
|
|
1806
|
+
res.write = function (chunk) {
|
|
1807
|
+
const contentType = res.getHeader('Content-Type') || '';
|
|
1808
|
+
if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
|
|
1809
|
+
isHtml = true;
|
|
1810
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
1811
|
+
return true;
|
|
1812
|
+
}
|
|
1813
|
+
return originalWrite.apply(res, arguments);
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
res.end = function (chunk) {
|
|
1817
|
+
const contentType = res.getHeader('Content-Type') || '';
|
|
1818
|
+
if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
|
|
1819
|
+
isHtml = true;
|
|
1820
|
+
if (chunk) {
|
|
1821
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
let bodyBuffer = Buffer.concat(chunks);
|
|
1825
|
+
const encoding = res.getHeader('Content-Encoding');
|
|
1826
|
+
const isGzipped = typeof encoding === 'string' && encoding.includes('gzip');
|
|
1827
|
+
|
|
1828
|
+
// Decompress if gzipped
|
|
1829
|
+
if (isGzipped) {
|
|
1830
|
+
try {
|
|
1831
|
+
bodyBuffer = zlib.gunzipSync(bodyBuffer);
|
|
1832
|
+
} catch (err) {
|
|
1833
|
+
console.error("AgentsBloom decompression error:", err);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
let body = bodyBuffer.toString('utf8');
|
|
1838
|
+
|
|
1839
|
+
if (body.toLowerCase().includes('</body>')) {
|
|
1840
|
+
// v4 (stored-XSS fix): merchant-controlled strings (store name,
|
|
1841
|
+
// description, action names/descriptions) are no longer spliced
|
|
1842
|
+
// into inline JavaScript or raw JSON.stringify output. JSON.stringify
|
|
1843
|
+
// does NOT escape `</script>`, so a crafted description used to be
|
|
1844
|
+
// able to break out of the script tag and execute on every page.
|
|
1845
|
+
// All dynamic data is now serialized through toSafeJson() (hex-
|
|
1846
|
+
// escaped `<`, `>`, `&`, U+2028/2029) and consumed as DATA by a
|
|
1847
|
+
// static loader - the same approach as the hardened next-sdk.
|
|
1848
|
+
const jsonLdData = {
|
|
1849
|
+
"@context": "https://schema.org",
|
|
1850
|
+
"@type": "WebPage",
|
|
1851
|
+
"name": name,
|
|
1852
|
+
"description": description,
|
|
1853
|
+
"potentialAction": Object.entries(actions).map(([key]) => ({
|
|
1854
|
+
"@type": "SearchAction",
|
|
1855
|
+
"name": key,
|
|
1856
|
+
"target": `${requestUrl}/api/agentsbloom/${key}`
|
|
1857
|
+
}))
|
|
1858
|
+
};
|
|
1859
|
+
|
|
1860
|
+
const jsonLdScript = `\n<script type="application/ld+json">\n${toSafeJson(jsonLdData)}\n</script>`;
|
|
1861
|
+
|
|
1862
|
+
const webMcpTools = Object.entries(actions).map(([key, val]) => ({
|
|
1863
|
+
name: key,
|
|
1864
|
+
description: val.description || '',
|
|
1865
|
+
method: String(val.method || 'POST').toUpperCase(),
|
|
1866
|
+
inputSchema: {
|
|
1867
|
+
type: 'object',
|
|
1868
|
+
properties: Object.fromEntries(
|
|
1869
|
+
Object.entries(val.params || {}).map(([pkey, pval]) => [pkey, { type: pval }])
|
|
1870
|
+
),
|
|
1871
|
+
},
|
|
1872
|
+
}));
|
|
1873
|
+
const webMcpScript = `
|
|
1874
|
+
<meta name="webmcp" content="active">
|
|
1875
|
+
<script>
|
|
1876
|
+
// Auto-generated WebMCP Declarative Actions by AgentsBloom
|
|
1877
|
+
(function() {
|
|
1878
|
+
if (typeof navigator === 'undefined' || !navigator.ai || typeof navigator.ai.registerTool !== 'function') return;
|
|
1879
|
+
var tools = ${toSafeJson(webMcpTools)};
|
|
1880
|
+
for (var i = 0; i < tools.length; i++) {
|
|
1881
|
+
(function(tool) {
|
|
1882
|
+
navigator.ai.registerTool({
|
|
1883
|
+
name: tool.name,
|
|
1884
|
+
description: tool.description,
|
|
1885
|
+
inputSchema: tool.inputSchema,
|
|
1886
|
+
handler: async function(args) {
|
|
1887
|
+
try {
|
|
1888
|
+
var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1889
|
+
var options = { method: tool.method, headers: headers };
|
|
1890
|
+
var url = '/api/agentsbloom/' + encodeURIComponent(tool.name);
|
|
1891
|
+
if (tool.method === 'GET') {
|
|
1892
|
+
var queryParams = new URLSearchParams(args).toString();
|
|
1893
|
+
if (queryParams) url += '?' + queryParams;
|
|
1894
|
+
} else {
|
|
1895
|
+
options.body = JSON.stringify(args);
|
|
1896
|
+
}
|
|
1897
|
+
var response = await fetch(url, options);
|
|
1898
|
+
return await response.json();
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
return { error: e.message };
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
})(tools[i]);
|
|
1905
|
+
}
|
|
1906
|
+
})();
|
|
1907
|
+
</script>
|
|
1908
|
+
`;
|
|
1909
|
+
|
|
1910
|
+
if (body.toLowerCase().includes('</head>')) {
|
|
1911
|
+
body = body.replace(/<\/head>/i, `${jsonLdScript}\n</head>`);
|
|
1912
|
+
} else {
|
|
1913
|
+
body = body + jsonLdScript;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
body = body.replace(/<\/body>/i, `${webMcpScript}\n</body>`);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
let outputBuffer = Buffer.from(body);
|
|
1920
|
+
|
|
1921
|
+
// Re-compress if gzipped
|
|
1922
|
+
if (isGzipped) {
|
|
1923
|
+
try {
|
|
1924
|
+
outputBuffer = zlib.gzipSync(outputBuffer);
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
console.error("AgentsBloom compression error:", err);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
res.setHeader('Content-Length', outputBuffer.length);
|
|
1931
|
+
originalWrite.call(res, outputBuffer);
|
|
1932
|
+
return originalEnd.call(res);
|
|
1933
|
+
}
|
|
1934
|
+
return originalEnd.apply(res, arguments);
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
next();
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// --- UNIFIED PROTOCOL ROUTER ---
|
|
1942
|
+
export function resolveProtocol(req) {
|
|
1943
|
+
const accept = (req.headers && req.headers['accept']) || '';
|
|
1944
|
+
const xProtocol = (req.headers && req.headers['x-protocol']) || '';
|
|
1945
|
+
const path = req.path || req.url || '';
|
|
1946
|
+
|
|
1947
|
+
if (accept.includes('application/mcp+json') || path.startsWith('/mcp')) {
|
|
1948
|
+
return 'WEBMCP';
|
|
1949
|
+
}
|
|
1950
|
+
if (xProtocol.toLowerCase() === 'ucp' || path.startsWith('/.well-known/ucp') || path.startsWith('/ucp')) {
|
|
1951
|
+
return 'UCP';
|
|
1952
|
+
}
|
|
1953
|
+
if (xProtocol.toLowerCase() === 'acp' || path.startsWith('/acp')) {
|
|
1954
|
+
return 'ACP';
|
|
1955
|
+
}
|
|
1956
|
+
if (req.headers && (req.headers['x-ap2-mandate'] || path.startsWith('/ap2'))) {
|
|
1957
|
+
return 'AP2';
|
|
1958
|
+
}
|
|
1959
|
+
return 'AGENTSBLOOM_REST';
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// --- AP2 (AGENT PAYMENTS PROTOCOL) SD-JWT MANDATE VERIFIER ---
|
|
1963
|
+
//
|
|
1964
|
+
// Thin, backward-compatible wrapper over the hardened implementation in
|
|
1965
|
+
// lib/ap2.js. The original signature `verifyAP2Mandates(headers, body,
|
|
1966
|
+
// publicKey)` treated an unsigned/unverifiable mandate as "valid but
|
|
1967
|
+
// unverified" and let downstream code (and, worse, the /ap2/checkout gate
|
|
1968
|
+
// itself - see the truthiness-check fix above) treat that as authorization
|
|
1969
|
+
// to check out. lib/ap2.js's verifyAp2Mandate instead REJECTS any mandate
|
|
1970
|
+
// it cannot cryptographically verify.
|
|
1971
|
+
//
|
|
1972
|
+
// Both call shapes are supported for backward compatibility:
|
|
1973
|
+
// verifyAP2Mandates(headers, body, publicKey) // legacy
|
|
1974
|
+
// verifyAP2Mandates(headers, body, { trustedPublicKey, expectedAudience, ... }) // current
|
|
1975
|
+
export function verifyAP2Mandates(headers = {}, body = {}, publicKeyOrOptions = null) {
|
|
1976
|
+
let options;
|
|
1977
|
+
if (publicKeyOrOptions === null || publicKeyOrOptions === undefined) {
|
|
1978
|
+
options = {};
|
|
1979
|
+
} else if (
|
|
1980
|
+
publicKeyOrOptions instanceof crypto.KeyObject ||
|
|
1981
|
+
Buffer.isBuffer(publicKeyOrOptions) ||
|
|
1982
|
+
typeof publicKeyOrOptions === 'string'
|
|
1983
|
+
) {
|
|
1984
|
+
// Legacy call shape: third argument is a raw public key.
|
|
1985
|
+
options = { trustedPublicKey: publicKeyOrOptions };
|
|
1986
|
+
} else {
|
|
1987
|
+
// Current call shape: third argument is an options object.
|
|
1988
|
+
options = publicKeyOrOptions;
|
|
1989
|
+
}
|
|
1990
|
+
return verifyAp2Mandate(headers, body, options);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1111
1993
|
export { createAp2Mandate, didKeyFromEd25519PublicKey, ed25519PublicKeyFromDidKey, resetAp2ReplayCache };
|
|
1112
|
-
|
|
1994
|
+
export { createOutcomeReporter, stripeEventToOutcome };
|
|
1995
|
+
|