@ar-agents/mercadopago 0.17.2 → 0.18.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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Recipe 26 — Certify any sociedad-IA by fetching its public endpoints.
3
+ *
4
+ * # Pattern
5
+ *
6
+ * Reusable TypeScript function that takes a target base URL, fetches the
7
+ * sociedad-IA's public endpoints (well-known + audit + verify + CSV +
8
+ * OpenAPI), runs ~9 checks against them, and returns a deterministic
9
+ * Certification object with a 0-100 score + per-check breakdown.
10
+ *
11
+ * This is the function backing the /certifier web flow + the /api/certifier
12
+ * HTTP endpoint at ar-agents.ar. It's also useful as:
13
+ *
14
+ * - A CI guard. Run every commit; fail the build if score drops below a
15
+ * threshold.
16
+ * - A monitoring check. Run hourly; alert if a known-good sociedad-IA
17
+ * starts failing checks.
18
+ * - A reverse-due-diligence tool. Before transacting with a counterpart,
19
+ * run certify(counterpartUrl) to confirm they advertise + serve the
20
+ * RFC endpoints.
21
+ *
22
+ * # When to use
23
+ *
24
+ * - Pre-merge gate in a sociedad-IA's own GitHub Actions workflow.
25
+ * - Cron job that re-certifies every sociedad in the public registry
26
+ * (regulator's "show me who's actually live" dashboard).
27
+ * - Integration test in CI for the @ar-agents/* libs themselves —
28
+ * prove that the demo deployments stay conformant.
29
+ *
30
+ * # Edge Runtime
31
+ *
32
+ * Pure fetch + JSON shaping. Runs in Edge, Node 18+, browser, deno.
33
+ * No filesystem. No state.
34
+ */
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Types — match the /api/certifier output shape
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ export interface Check {
41
+ id: string;
42
+ label: string;
43
+ weight: number;
44
+ status: "pass" | "fail" | "skip" | "warn";
45
+ detail: string;
46
+ source?: string;
47
+ httpStatus?: number;
48
+ }
49
+
50
+ export interface Certification {
51
+ $schema: string;
52
+ generatedAt: string;
53
+ target: { baseUrl: string; sessionId: string | null };
54
+ score: number;
55
+ rating: "A" | "B" | "C" | "D" | "F" | "N/A";
56
+ rfcConformance: {
57
+ "rfc-002-v1": "pass" | "partial" | "fail" | "skip";
58
+ "rfc-004-draft": "pass" | "partial" | "fail" | "skip";
59
+ };
60
+ checks: Check[];
61
+ notes: string[];
62
+ }
63
+
64
+ export interface CertifyOptions {
65
+ /** SessionId to use for the audit-read + verify checks. */
66
+ sessionId?: string;
67
+ /** Override fetch impl (for testing). */
68
+ fetchImpl?: typeof fetch;
69
+ /** Per-fetch timeout in ms (default 8000). */
70
+ timeoutMs?: number;
71
+ }
72
+
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ // Default constants
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ const DEFAULT_TIMEOUT_MS = 8000;
78
+ const DEFAULT_SESSION_ID = "demo-public-ar-001";
79
+
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ // Fetch helper with timeout
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+
84
+ async function fetchWithTimeout(
85
+ url: string,
86
+ init: RequestInit | undefined,
87
+ timeoutMs: number,
88
+ fetchImpl: typeof fetch,
89
+ ): Promise<Response> {
90
+ const controller = new AbortController();
91
+ const t = setTimeout(() => controller.abort(), timeoutMs);
92
+ try {
93
+ return await fetchImpl(url, {
94
+ ...init,
95
+ signal: controller.signal,
96
+ headers: {
97
+ "user-agent": "ar-agents-recipe-26-certify (https://ar-agents.ar/certifier)",
98
+ ...(init?.headers ?? {}),
99
+ },
100
+ });
101
+ } finally {
102
+ clearTimeout(t);
103
+ }
104
+ }
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // Scoring helpers
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ function ratingFromScore(score: number): Certification["rating"] {
111
+ if (score >= 90) return "A";
112
+ if (score >= 75) return "B";
113
+ if (score >= 60) return "C";
114
+ if (score >= 40) return "D";
115
+ return "F";
116
+ }
117
+
118
+ function summarizeRfcConformance(checks: Check[]): Certification["rfcConformance"] {
119
+ function status(arr: Check[]): "pass" | "partial" | "fail" | "skip" {
120
+ if (arr.length === 0) return "skip";
121
+ const counts = { pass: 0, fail: 0, warn: 0, skip: 0 };
122
+ for (const c of arr) counts[c.status]++;
123
+ if (counts.fail === 0 && counts.warn === 0 && counts.pass > 0) return "pass";
124
+ if (counts.pass > 0) return "partial";
125
+ if (counts.fail > 0) return "fail";
126
+ return "skip";
127
+ }
128
+ return {
129
+ "rfc-002-v1": status(checks.filter((c) => c.id.startsWith("rfc-002"))),
130
+ "rfc-004-draft": status(checks.filter((c) => c.id.startsWith("rfc-004"))),
131
+ };
132
+ }
133
+
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+ // Public API
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+
138
+ export async function certifySociedad(
139
+ baseUrl: string,
140
+ options: CertifyOptions = {},
141
+ ): Promise<Certification> {
142
+ const parsed = new URL(baseUrl);
143
+ const base = parsed.origin;
144
+ const sessionId = options.sessionId ?? null;
145
+ const targetSession = sessionId ?? DEFAULT_SESSION_ID;
146
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
147
+ const fetchImpl = options.fetchImpl ?? fetch;
148
+
149
+ const checks: Check[] = [];
150
+ const notes: string[] = [];
151
+
152
+ // ── 1. Well-known manifest exists + parses ─────────────────────────────────
153
+ const wellKnownUrl = `${base}/.well-known/agents.json`;
154
+ let manifest: Record<string, unknown> | null = null;
155
+ try {
156
+ const r = await fetchWithTimeout(wellKnownUrl, undefined, timeoutMs, fetchImpl);
157
+ if (r.ok) {
158
+ try {
159
+ manifest = await r.json();
160
+ checks.push({
161
+ id: "rfc-002-well-known-exists",
162
+ label: "RFC-002 · /.well-known/agents.json returns 200 + valid JSON",
163
+ weight: 15,
164
+ status: "pass",
165
+ detail: "Manifest fetched + parsed.",
166
+ source: wellKnownUrl,
167
+ httpStatus: r.status,
168
+ });
169
+ } catch {
170
+ checks.push({
171
+ id: "rfc-002-well-known-exists",
172
+ label: "RFC-002 · /.well-known/agents.json returns 200 + valid JSON",
173
+ weight: 15,
174
+ status: "fail",
175
+ detail: "Body is not valid JSON.",
176
+ source: wellKnownUrl,
177
+ httpStatus: r.status,
178
+ });
179
+ }
180
+ } else {
181
+ checks.push({
182
+ id: "rfc-002-well-known-exists",
183
+ label: "RFC-002 · /.well-known/agents.json returns 200 + valid JSON",
184
+ weight: 15,
185
+ status: "fail",
186
+ detail: `HTTP ${r.status}.`,
187
+ source: wellKnownUrl,
188
+ httpStatus: r.status,
189
+ });
190
+ }
191
+ } catch (e) {
192
+ checks.push({
193
+ id: "rfc-002-well-known-exists",
194
+ label: "RFC-002 · /.well-known/agents.json returns 200 + valid JSON",
195
+ weight: 15,
196
+ status: "fail",
197
+ detail: `Network error: ${(e as Error).message}`,
198
+ source: wellKnownUrl,
199
+ });
200
+ }
201
+
202
+ // ── 2. Manifest required fields ───────────────────────────────────────────
203
+ if (manifest) {
204
+ const issuer = manifest.issuer as Record<string, unknown> | undefined;
205
+ const endpoints = manifest.endpoints as Record<string, unknown> | unknown[] | undefined;
206
+ const auditEndpoints = manifest.auditEndpoints as Record<string, unknown> | undefined;
207
+ const hasIssuerJurisdiction = issuer && typeof issuer.jurisdiction === "string";
208
+ const hasAuditRead =
209
+ (endpoints && !Array.isArray(endpoints) && typeof (endpoints as Record<string, unknown>).auditRead === "string") ||
210
+ (auditEndpoints && typeof auditEndpoints.auditRead === "string");
211
+ const ok = hasIssuerJurisdiction && hasAuditRead;
212
+ checks.push({
213
+ id: "rfc-002-manifest-required-fields",
214
+ label: "RFC-002 · Manifest has issuer.jurisdiction + auditRead endpoint",
215
+ weight: 10,
216
+ status: ok ? "pass" : "fail",
217
+ detail: ok
218
+ ? `jurisdiction=${issuer!.jurisdiction}; auditRead present.`
219
+ : "Missing issuer.jurisdiction or auditRead endpoint.",
220
+ });
221
+
222
+ const rfcConformance = manifest.rfcConformance;
223
+ checks.push({
224
+ id: "rfc-002-manifest-rfc-conformance",
225
+ label: "RFC-002 · Manifest advertises rfcConformance",
226
+ weight: 5,
227
+ status: Array.isArray(rfcConformance) && rfcConformance.length > 0 ? "pass" : "warn",
228
+ detail: Array.isArray(rfcConformance) && rfcConformance.length > 0
229
+ ? `Claims: ${(rfcConformance as string[]).join(", ")}.`
230
+ : "No rfcConformance array (recommended).",
231
+ });
232
+ } else {
233
+ checks.push({
234
+ id: "rfc-002-manifest-required-fields",
235
+ label: "RFC-002 · Manifest has issuer.jurisdiction + endpoints.auditRead",
236
+ weight: 10,
237
+ status: "skip",
238
+ detail: "Skipped (manifest fetch failed).",
239
+ });
240
+ checks.push({
241
+ id: "rfc-002-manifest-rfc-conformance",
242
+ label: "RFC-002 · Manifest advertises rfcConformance",
243
+ weight: 5,
244
+ status: "skip",
245
+ detail: "Skipped (manifest fetch failed).",
246
+ });
247
+ }
248
+
249
+ // ── 3. Audit-read endpoint ────────────────────────────────────────────────
250
+ let auditUrl: string;
251
+ const endpointsForRead = manifest?.endpoints as Record<string, unknown> | unknown[] | undefined;
252
+ const auditEndpointsForRead = manifest?.auditEndpoints as Record<string, unknown> | undefined;
253
+ const readTemplate =
254
+ (endpointsForRead && !Array.isArray(endpointsForRead)
255
+ ? (endpointsForRead as Record<string, unknown>).auditRead
256
+ : undefined) ??
257
+ auditEndpointsForRead?.auditRead;
258
+ if (typeof readTemplate === "string") {
259
+ auditUrl = readTemplate.replace(
260
+ "{sessionId}",
261
+ encodeURIComponent(targetSession),
262
+ );
263
+ } else {
264
+ auditUrl = `${base}/api/play/audit/${encodeURIComponent(targetSession)}`;
265
+ }
266
+ try {
267
+ const r = await fetchWithTimeout(auditUrl, undefined, timeoutMs, fetchImpl);
268
+ if (r.ok) {
269
+ const payload = (await r.json()) as Record<string, unknown>;
270
+ checks.push({
271
+ id: "rfc-004-audit-read",
272
+ label: "RFC-004 · Audit-read endpoint returns 200 + valid AuditPayload",
273
+ weight: 15,
274
+ status: "pass",
275
+ detail: `entries: ${Array.isArray(payload.entries) ? (payload.entries as unknown[]).length : "n/a"}.`,
276
+ source: auditUrl,
277
+ httpStatus: r.status,
278
+ });
279
+ } else {
280
+ checks.push({
281
+ id: "rfc-004-audit-read",
282
+ label: "RFC-004 · Audit-read endpoint returns 200 + valid AuditPayload",
283
+ weight: 15,
284
+ status: "fail",
285
+ detail: `HTTP ${r.status}.`,
286
+ source: auditUrl,
287
+ httpStatus: r.status,
288
+ });
289
+ }
290
+ } catch (e) {
291
+ checks.push({
292
+ id: "rfc-004-audit-read",
293
+ label: "RFC-004 · Audit-read endpoint returns 200 + valid AuditPayload",
294
+ weight: 15,
295
+ status: "fail",
296
+ detail: `Network error: ${(e as Error).message}`,
297
+ source: auditUrl,
298
+ });
299
+ }
300
+
301
+ // ── 4. Audit verify=1 endpoint ────────────────────────────────────────────
302
+ const verifyUrl = `${auditUrl}${auditUrl.includes("?") ? "&" : "?"}verify=1`;
303
+ try {
304
+ const r = await fetchWithTimeout(verifyUrl, undefined, timeoutMs, fetchImpl);
305
+ if (r.ok) {
306
+ const data = (await r.json()) as Record<string, unknown>;
307
+ const verificationBlock = (data.verification ?? data) as Record<string, unknown>;
308
+ const hasCounts =
309
+ typeof verificationBlock.verified === "number" &&
310
+ typeof verificationBlock.tampered === "number" &&
311
+ typeof verificationBlock.hmacWired === "boolean";
312
+ if (hasCounts) {
313
+ const tampered = verificationBlock.tampered as number;
314
+ const verified = verificationBlock.verified as number;
315
+ const hmacWired = verificationBlock.hmacWired as boolean;
316
+ const total = verified + tampered;
317
+ checks.push({
318
+ id: "rfc-004-audit-verify",
319
+ label: "RFC-004 · Audit-verify endpoint returns verification counts",
320
+ weight: 20,
321
+ status: tampered === 0 && hmacWired ? "pass" : "warn",
322
+ detail: hmacWired
323
+ ? `verified=${verified}/${total}, tampered=${tampered}.`
324
+ : "hmacWired=false (dev mode).",
325
+ source: verifyUrl,
326
+ httpStatus: r.status,
327
+ });
328
+ if (tampered > 0) notes.push(`⚠ ${tampered} tampered entries on session ${targetSession}.`);
329
+ if (!hmacWired) notes.push(`⚠ HMAC secret not wired (production must wire AUDIT_HMAC_SECRET).`);
330
+ } else {
331
+ checks.push({
332
+ id: "rfc-004-audit-verify",
333
+ label: "RFC-004 · Audit-verify endpoint returns verification counts",
334
+ weight: 20,
335
+ status: "warn",
336
+ detail: "Response missing verified/tampered/hmacWired counts.",
337
+ source: verifyUrl,
338
+ httpStatus: r.status,
339
+ });
340
+ }
341
+ } else {
342
+ checks.push({
343
+ id: "rfc-004-audit-verify",
344
+ label: "RFC-004 · Audit-verify endpoint returns verification counts",
345
+ weight: 20,
346
+ status: "fail",
347
+ detail: `HTTP ${r.status}.`,
348
+ source: verifyUrl,
349
+ httpStatus: r.status,
350
+ });
351
+ }
352
+ } catch (e) {
353
+ checks.push({
354
+ id: "rfc-004-audit-verify",
355
+ label: "RFC-004 · Audit-verify endpoint returns verification counts",
356
+ weight: 20,
357
+ status: "fail",
358
+ detail: `Network error: ${(e as Error).message}`,
359
+ source: verifyUrl,
360
+ });
361
+ }
362
+
363
+ // ── 5. CSV export ─────────────────────────────────────────────────────────
364
+ const csvUrl = `${auditUrl}/csv`;
365
+ try {
366
+ const r = await fetchWithTimeout(csvUrl, undefined, timeoutMs, fetchImpl);
367
+ if (r.ok) {
368
+ const ct = r.headers.get("content-type") || "";
369
+ checks.push({
370
+ id: "rfc-004-audit-csv",
371
+ label: "RFC-004 · CSV export returns text/csv",
372
+ weight: 10,
373
+ status: ct.includes("text/csv") ? "pass" : "warn",
374
+ detail: `Content-Type: ${ct}.`,
375
+ source: csvUrl,
376
+ httpStatus: r.status,
377
+ });
378
+ } else {
379
+ checks.push({
380
+ id: "rfc-004-audit-csv",
381
+ label: "RFC-004 · CSV export returns text/csv",
382
+ weight: 10,
383
+ status: "fail",
384
+ detail: `HTTP ${r.status}.`,
385
+ source: csvUrl,
386
+ httpStatus: r.status,
387
+ });
388
+ }
389
+ } catch (e) {
390
+ checks.push({
391
+ id: "rfc-004-audit-csv",
392
+ label: "RFC-004 · CSV export returns text/csv",
393
+ weight: 10,
394
+ status: "fail",
395
+ detail: `Network error: ${(e as Error).message}`,
396
+ source: csvUrl,
397
+ });
398
+ }
399
+
400
+ // ── 6. OpenAPI ────────────────────────────────────────────────────────────
401
+ const openApiUrl = `${base}/api/openapi`;
402
+ try {
403
+ const r = await fetchWithTimeout(openApiUrl, undefined, timeoutMs, fetchImpl);
404
+ if (r.ok) {
405
+ const data = (await r.json()) as Record<string, unknown>;
406
+ const isOpenApi =
407
+ typeof data.openapi === "string" && (data.openapi as string).startsWith("3.");
408
+ checks.push({
409
+ id: "tooling-openapi",
410
+ label: "Tooling · /api/openapi returns OpenAPI 3.x",
411
+ weight: 10,
412
+ status: isOpenApi ? "pass" : "warn",
413
+ detail: isOpenApi ? `OpenAPI ${data.openapi}.` : "Not an OpenAPI 3.x doc.",
414
+ source: openApiUrl,
415
+ httpStatus: r.status,
416
+ });
417
+ } else {
418
+ checks.push({
419
+ id: "tooling-openapi",
420
+ label: "Tooling · /api/openapi returns OpenAPI 3.x",
421
+ weight: 10,
422
+ status: "skip",
423
+ detail: `Not advertised (HTTP ${r.status}).`,
424
+ source: openApiUrl,
425
+ httpStatus: r.status,
426
+ });
427
+ }
428
+ } catch {
429
+ checks.push({
430
+ id: "tooling-openapi",
431
+ label: "Tooling · /api/openapi returns OpenAPI 3.x",
432
+ weight: 10,
433
+ status: "skip",
434
+ detail: "Not advertised.",
435
+ source: openApiUrl,
436
+ });
437
+ }
438
+
439
+ // ── 7. RFC-005 keys endpoint (asymmetric upgrade path) ──────────────────
440
+ const keysUrl = `${base}/.well-known/sociedad-ia/keys`;
441
+ try {
442
+ const r = await fetchWithTimeout(keysUrl, undefined, timeoutMs, fetchImpl);
443
+ if (r.ok) {
444
+ const data = (await r.json()) as Record<string, unknown>;
445
+ const keys = data.keys as unknown[] | undefined;
446
+ const hasKeys = Array.isArray(keys) && keys.length > 0;
447
+ checks.push({
448
+ id: "rfc-005-keys-endpoint",
449
+ label: "RFC-005 · /.well-known/sociedad-ia/keys advertises Ed25519 public keys",
450
+ weight: 5,
451
+ status: hasKeys ? "pass" : "warn",
452
+ detail: hasKeys
453
+ ? `${keys.length} key(s) advertised.`
454
+ : "Endpoint responds but no keys advertised.",
455
+ source: keysUrl,
456
+ httpStatus: r.status,
457
+ });
458
+ } else {
459
+ checks.push({
460
+ id: "rfc-005-keys-endpoint",
461
+ label: "RFC-005 · /.well-known/sociedad-ia/keys advertises Ed25519 public keys",
462
+ weight: 5,
463
+ status: "skip",
464
+ detail: `Not advertised (HTTP ${r.status}).`,
465
+ source: keysUrl,
466
+ httpStatus: r.status,
467
+ });
468
+ }
469
+ } catch {
470
+ checks.push({
471
+ id: "rfc-005-keys-endpoint",
472
+ label: "RFC-005 · /.well-known/sociedad-ia/keys advertises Ed25519 public keys",
473
+ weight: 5,
474
+ status: "skip",
475
+ detail: "Not advertised.",
476
+ source: keysUrl,
477
+ });
478
+ }
479
+
480
+ // Score + finalize.
481
+ let earned = 0;
482
+ let possible = 0;
483
+ for (const c of checks) {
484
+ if (c.status === "skip") continue;
485
+ possible += c.weight;
486
+ if (c.status === "pass") earned += c.weight;
487
+ else if (c.status === "warn") earned += c.weight * 0.5;
488
+ }
489
+ const score = possible > 0 ? Math.round((earned / possible) * 100) : 0;
490
+ const rating: Certification["rating"] = possible === 0 ? "N/A" : ratingFromScore(score);
491
+
492
+ return {
493
+ $schema: "https://ar-agents.ar/schemas/certification.v1.json",
494
+ generatedAt: new Date().toISOString(),
495
+ target: { baseUrl: base, sessionId },
496
+ score,
497
+ rating,
498
+ rfcConformance: summarizeRfcConformance(checks),
499
+ checks,
500
+ notes,
501
+ };
502
+ }
503
+
504
+ // ─────────────────────────────────────────────────────────────────────────────
505
+ // CLI: `tsx 26-certify-by-fetch.ts <baseUrl> [sessionId]`
506
+ // ─────────────────────────────────────────────────────────────────────────────
507
+
508
+ declare const process: { argv: string[] } | undefined;
509
+
510
+ async function main() {
511
+ if (typeof process === "undefined") return;
512
+ const baseUrl = process.argv[2];
513
+ const sessionId = process.argv[3];
514
+ if (!baseUrl) {
515
+ console.error("usage: tsx 26-certify-by-fetch.ts <baseUrl> [sessionId]");
516
+ return;
517
+ }
518
+ const cert = await certifySociedad(baseUrl, { sessionId });
519
+ console.log(JSON.stringify(cert, null, 2));
520
+ // Exit non-zero if score < 60 — useful as a CI gate.
521
+ if (typeof process !== "undefined" && "exit" in process) {
522
+ (process as unknown as { exit: (code: number) => void }).exit(
523
+ cert.score >= 60 ? 0 : 1,
524
+ );
525
+ }
526
+ }
527
+
528
+ const isMain = typeof require !== "undefined" && require.main === module;
529
+ if (isMain) {
530
+ main().catch((e) => {
531
+ console.error(e);
532
+ if (typeof process !== "undefined" && "exit" in process) {
533
+ (process as unknown as { exit: (code: number) => void }).exit(1);
534
+ }
535
+ });
536
+ }