@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,350 @@
1
+ /**
2
+ * Recipe 24 — Sociedad-IA disaster recovery (export + restore via
3
+ * /api/auto-incorporate).
4
+ *
5
+ * # Pattern
6
+ *
7
+ * A sociedad-IA is code + secrets + audit log. If any layer fails — the
8
+ * Vercel project gets deleted, the GitHub repo is locked, the operator's
9
+ * laptop dies — the operator needs a documented restore path.
10
+ *
11
+ * Recipe 24 is the export-restore cycle:
12
+ *
13
+ * 1. Nightly export: serialize the sociedad's configuration (env-var
14
+ * names list, deployed Vercel project metadata, audit-log session
15
+ * ids, AFIP cert fingerprint) to a portable JSON.
16
+ *
17
+ * 2. Disaster: anywhere in the stack fails.
18
+ *
19
+ * 3. Restore: feed the exported JSON to a fresh /api/auto-incorporate
20
+ * call (with the same denominacion + tipo + capital + objeto +
21
+ * sessionId), get the generated source files + new Vercel deploy
22
+ * URL, re-paste env vars, redeploy.
23
+ *
24
+ * The sessionId continuity is the load-bearing piece: by passing the
25
+ * same audit-log session id to the re-incorporation, the forensic
26
+ * timeline of the original sociedad continues unbroken across the
27
+ * disaster. Regulators see one chain of events, not two.
28
+ *
29
+ * # When to use
30
+ *
31
+ * - Multi-tenant marketplace running many sociedades (recipe 20). Each
32
+ * tenant gets nightly export; restore is per-tenant.
33
+ * - Long-running sociedad with regulatory exposure where breaking the
34
+ * audit log chain would create compliance trouble.
35
+ * - Any production deployment where the operator wants offline copies
36
+ * of the configuration outside Vercel's control plane.
37
+ *
38
+ * # Edge Runtime
39
+ *
40
+ * The export side is Node.js (fs + filesystem export). The restore
41
+ * side is fetch-based, runs anywhere. The audit-log session-id
42
+ * continuity is the bridge.
43
+ *
44
+ * # What's NOT in the export
45
+ *
46
+ * - Secrets. AFIP_CERT_PEM, MERCADOPAGO_ACCESS_TOKEN, etc. live in your
47
+ * own secrets manager (1Password, Vault, AWS Secrets Manager) and
48
+ * stay there. The export references which env vars are needed (by
49
+ * name) but never their values.
50
+ * - PII / customer data. The export is sociedad-config-only.
51
+ * - The KV-stored audit log itself. Vercel KV has its own backup story;
52
+ * if you need cross-region audit-log durability, mirror to S3 with
53
+ * object lock per the open-question in /architecture/audit-log § 11.
54
+ */
55
+
56
+ import { promises as fs } from "node:fs";
57
+ import path from "node:path";
58
+ import { incorporate, fetchAudit, type IncorporateInput } from "@ar-agents/incorporate";
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // Export shape
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ interface SociedadExport {
65
+ $schema: string;
66
+ exportedAt: string;
67
+ schemaVersion: "1.0";
68
+
69
+ /** The original sociedad parameters — recoverable in full. */
70
+ sociedad: {
71
+ denominacion: string;
72
+ tipo: "SAS" | "SRL" | "SA" | "SOCIEDAD-IA";
73
+ capitalSocial: number;
74
+ objeto: string;
75
+ representante?: { nombre: string; cuit: string };
76
+ emailContacto?: string;
77
+ piezas?: string[];
78
+ };
79
+
80
+ /**
81
+ * The original audit-log session id. RESTORE feeds this back to
82
+ * /api/auto-incorporate so the forensic timeline continues without
83
+ * a break.
84
+ */
85
+ sessionId: string;
86
+
87
+ /** Env-var names the sociedad needs to operate. Values come from the operator's secrets manager. */
88
+ envVarsRequired: string[];
89
+
90
+ /** Deployment metadata for the restore destination. */
91
+ deployment: {
92
+ framework: "nextjs";
93
+ runtime: "vercel-edge" | "vercel-node" | "cloudflare-workers" | "deno-deploy";
94
+ /** Where the source was last deployed. Pre-disaster reference only. */
95
+ lastKnownProductionUrl?: string;
96
+ /** For verification: how the sociedad's identity used to be proven. */
97
+ afipCertFingerprintSha256?: string;
98
+ };
99
+
100
+ /** Audit-log snapshot at export time. Not authoritative — the live log is. */
101
+ auditSnapshot: {
102
+ totalEntries: number;
103
+ lastEntryId: string | null;
104
+ lastEntryTs: string | null;
105
+ /** HMAC of the snapshot itself, signed by the exporter, for tamper detection on the export. */
106
+ snapshotHmac?: string;
107
+ };
108
+
109
+ /** Free-form notes from the operator (e.g., "rotated MP token 2026-04-30"). */
110
+ notes?: string;
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Export
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ export async function exportSociedad(args: {
118
+ outFile: string;
119
+ sociedad: SociedadExport["sociedad"];
120
+ sessionId: string;
121
+ envVarsRequired: string[];
122
+ deployment: SociedadExport["deployment"];
123
+ notes?: string;
124
+ }): Promise<SociedadExport> {
125
+ // Pull the live audit log to snapshot its state at export time.
126
+ const audit = (await fetchAudit(args.sessionId, { verify: false })) as {
127
+ count: number;
128
+ entries: Array<{ id: string; ts: string }>;
129
+ };
130
+ const lastEntry = audit.entries[audit.entries.length - 1];
131
+
132
+ const exportObj: SociedadExport = {
133
+ $schema:
134
+ "https://ar-agents.ar/schemas/sociedad-export.v1.json",
135
+ exportedAt: new Date().toISOString(),
136
+ schemaVersion: "1.0",
137
+ sociedad: args.sociedad,
138
+ sessionId: args.sessionId,
139
+ envVarsRequired: args.envVarsRequired,
140
+ deployment: args.deployment,
141
+ auditSnapshot: {
142
+ totalEntries: audit.count,
143
+ lastEntryId: lastEntry?.id ?? null,
144
+ lastEntryTs: lastEntry?.ts ?? null,
145
+ // Optional: sign the snapshot itself with the operator's separate
146
+ // export-signing key, distinct from AUDIT_HMAC_SECRET. Adds tamper
147
+ // detection to the export file in transit.
148
+ snapshotHmac: undefined,
149
+ },
150
+ notes: args.notes,
151
+ };
152
+
153
+ // Write to disk.
154
+ await fs.mkdir(path.dirname(args.outFile), { recursive: true });
155
+ await fs.writeFile(args.outFile, JSON.stringify(exportObj, null, 2), "utf8");
156
+
157
+ return exportObj;
158
+ }
159
+
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+ // Restore
162
+ // ─────────────────────────────────────────────────────────────────────────────
163
+
164
+ interface RestoreResult {
165
+ /** The fresh incorporation result. */
166
+ incorporationOk: boolean;
167
+ newDeployUrl: string;
168
+ newAuditDashboardUrl: string;
169
+ /** Reconciliation: does the new audit entry count match the export snapshot + 1 (for the restore event itself)? */
170
+ reconciliation: {
171
+ expectedMinTotal: number;
172
+ actualTotal: number;
173
+ sessionContinuity: "preserved" | "drift" | "broken";
174
+ };
175
+ /** Manual steps the operator still has to do (re-paste env vars, re-upload AFIP cert, etc). */
176
+ manualSteps: string[];
177
+ }
178
+
179
+ export async function restoreSociedad(args: {
180
+ exportFile: string;
181
+ }): Promise<RestoreResult> {
182
+ // 1. Load the export.
183
+ const raw = await fs.readFile(args.exportFile, "utf8");
184
+ const exp = JSON.parse(raw) as SociedadExport;
185
+
186
+ if (exp.schemaVersion !== "1.0") {
187
+ throw new Error(
188
+ `Unsupported export schema version: ${exp.schemaVersion}. This recipe handles 1.0.`,
189
+ );
190
+ }
191
+
192
+ // 2. Call /api/auto-incorporate with the original sessionId so the
193
+ // audit log continues under the same forensic timeline.
194
+ const incorporateInput: IncorporateInput = {
195
+ denominacion: exp.sociedad.denominacion,
196
+ tipo: exp.sociedad.tipo,
197
+ capitalSocial: exp.sociedad.capitalSocial,
198
+ objeto: exp.sociedad.objeto,
199
+ sessionId: exp.sessionId, // ← continuity!
200
+ };
201
+ if (exp.sociedad.representante) {
202
+ incorporateInput.representante = exp.sociedad.representante;
203
+ }
204
+ if (exp.sociedad.emailContacto) {
205
+ incorporateInput.emailContacto = exp.sociedad.emailContacto;
206
+ }
207
+ if (exp.sociedad.piezas) {
208
+ // Type-cast: incorporate() validates piezas server-side
209
+ incorporateInput.piezas = exp.sociedad.piezas as IncorporateInput["piezas"];
210
+ }
211
+
212
+ const result = await incorporate(incorporateInput);
213
+
214
+ if (!result.ok) {
215
+ // Pre-flight failure — the original config no longer passes IGJ
216
+ // pre-flight (regulations changed, denomination conflict, etc).
217
+ // Surface the findings + abort.
218
+ const errors = result.validation.findings
219
+ .filter((f) => f.severity === "error")
220
+ .map((f) => `${f.field}: ${f.message}`);
221
+ return {
222
+ incorporationOk: false,
223
+ newDeployUrl: "",
224
+ newAuditDashboardUrl: "",
225
+ reconciliation: {
226
+ expectedMinTotal: exp.auditSnapshot.totalEntries,
227
+ actualTotal: -1,
228
+ sessionContinuity: "broken",
229
+ },
230
+ manualSteps: [
231
+ `Original sociedad config no longer passes IGJ pre-flight: ${errors.join("; ")}.`,
232
+ "Review the export, adjust the failing fields, re-export, retry restore.",
233
+ ],
234
+ };
235
+ }
236
+
237
+ // 3. Re-fetch the audit log to verify the new restore event landed
238
+ // + the count is at least the snapshot count + 1 (the restore
239
+ // itself counts as a new entry).
240
+ const audit = (await fetchAudit(exp.sessionId, { verify: false })) as {
241
+ count: number;
242
+ entries: Array<{ id: string; tool: string; ts: string }>;
243
+ };
244
+ const expectedMin = exp.auditSnapshot.totalEntries + 1;
245
+ const continuity =
246
+ audit.count >= expectedMin
247
+ ? "preserved"
248
+ : audit.count === exp.auditSnapshot.totalEntries
249
+ ? "drift"
250
+ : "broken";
251
+
252
+ return {
253
+ incorporationOk: true,
254
+ newDeployUrl: result.deploy.oneClickUrl,
255
+ newAuditDashboardUrl: result.audit.dashboardUrl,
256
+ reconciliation: {
257
+ expectedMinTotal: expectedMin,
258
+ actualTotal: audit.count,
259
+ sessionContinuity: continuity,
260
+ },
261
+ manualSteps: [
262
+ "Click the new deploy URL → review the generated project → confirm import.",
263
+ `Paste env vars from your secrets manager. Required: ${exp.envVarsRequired.join(", ")}.`,
264
+ "Re-upload AFIP cert PEM + key PEM to Vercel env (AFIP_CERT_PEM, AFIP_KEY_PEM, AFIP_CUIT).",
265
+ "Verify the new deploy serves the agent endpoint by running a smoke-test scenario from /play.",
266
+ "Verify the audit log still shows pre-disaster entries at the dashboard URL.",
267
+ ],
268
+ };
269
+ }
270
+
271
+ // ─────────────────────────────────────────────────────────────────────────────
272
+ // Example: nightly export + simulated restore
273
+ // ─────────────────────────────────────────────────────────────────────────────
274
+
275
+ async function main() {
276
+ const cmd = process.argv[2];
277
+
278
+ if (cmd === "export") {
279
+ const sessionId = process.argv[3];
280
+ if (!sessionId) {
281
+ console.error(
282
+ "usage: pnpm tsx 24-sociedad-ia-disaster-recovery.ts export <sessionId>",
283
+ );
284
+ process.exit(1);
285
+ }
286
+ const result = await exportSociedad({
287
+ outFile: `./backups/${sessionId}-${new Date().toISOString().slice(0, 10)}.json`,
288
+ sociedad: {
289
+ denominacion: "ACME-AI SAS",
290
+ tipo: "SAS",
291
+ capitalSocial: 200_000,
292
+ objeto:
293
+ "Operación de servicios digitales y desarrollo de software propio para clientes argentinos.",
294
+ },
295
+ sessionId,
296
+ envVarsRequired: [
297
+ "ANTHROPIC_API_KEY",
298
+ "AFIP_CERT_PEM",
299
+ "AFIP_KEY_PEM",
300
+ "AFIP_CUIT",
301
+ "MERCADOPAGO_ACCESS_TOKEN",
302
+ "WHATSAPP_ACCESS_TOKEN",
303
+ "WHATSAPP_PHONE_NUMBER_ID",
304
+ "AUDIT_HMAC_SECRET",
305
+ ],
306
+ deployment: {
307
+ framework: "nextjs",
308
+ runtime: "vercel-edge",
309
+ lastKnownProductionUrl: "https://acme-ai-sas.vercel.app",
310
+ },
311
+ notes: "Nightly export. Operator: ACME-AI SAS. Contador signs off monthly.",
312
+ });
313
+ console.log("Exported snapshot:");
314
+ console.log(JSON.stringify(result, null, 2));
315
+ return;
316
+ }
317
+
318
+ if (cmd === "restore") {
319
+ const file = process.argv[3];
320
+ if (!file) {
321
+ console.error(
322
+ "usage: pnpm tsx 24-sociedad-ia-disaster-recovery.ts restore <exportFile>",
323
+ );
324
+ process.exit(1);
325
+ }
326
+ const result = await restoreSociedad({ exportFile: file });
327
+ console.log("Restore result:");
328
+ console.log(JSON.stringify(result, null, 2));
329
+ if (!result.incorporationOk) {
330
+ process.exit(1);
331
+ }
332
+ console.log("\nManual steps:");
333
+ for (const s of result.manualSteps) console.log(` - ${s}`);
334
+ return;
335
+ }
336
+
337
+ console.error(
338
+ "usage:\n export <sessionId>\n restore <exportFile>",
339
+ );
340
+ process.exit(1);
341
+ }
342
+
343
+ if (typeof require !== "undefined" && require.main === module) {
344
+ main().catch((err) => {
345
+ console.error("Recipe 24 failed:", err);
346
+ process.exit(1);
347
+ });
348
+ }
349
+
350
+ export { main };