@codespar/cli 0.13.0 → 0.14.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/README.md +51 -1
- package/dist/commands/agent.js +13 -7
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/init.js +91 -23
- package/dist/commands/init.js.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/templates/bills-agent/LICENSE +21 -0
- package/templates/bills-agent/README.md +21 -0
- package/templates/bills-agent/_gitignore +10 -0
- package/templates/bills-agent/agents/bills-agent/.env.example +7 -0
- package/templates/bills-agent/agents/bills-agent/AGENTS.md +16 -0
- package/templates/bills-agent/agents/bills-agent/CLAUDE.md +16 -0
- package/templates/bills-agent/agents/bills-agent/README.md +89 -0
- package/templates/bills-agent/agents/bills-agent/SYSTEM_PROMPT.md +23 -0
- package/templates/bills-agent/agents/bills-agent/agent.yaml +26 -0
- package/templates/bills-agent/agents/bills-agent/channels/terminal/index.ts +108 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/beneficiary-swap-mandate.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/beneficiary-swap.json +10 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/beneficiary-swap.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/exfiltration.json +13 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/exfiltration.transcript.jsonl +3 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/false-authority.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/false-authority.transcript.jsonl +3 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/fractioning.json +15 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/fractioning.transcript.jsonl +6 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/model-total.json +10 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/model-total.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/prompt-injection.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/prompt-injection.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/webhook-replay.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/eval.yaml +9 -0
- package/templates/bills-agent/agents/bills-agent/guardrails.json +15 -0
- package/templates/bills-agent/agents/bills-agent/mandate.example.json +46 -0
- package/templates/bills-agent/agents/bills-agent/package.json +24 -0
- package/templates/bills-agent/agents/bills-agent/runbook.md +19 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/beneficiary-not-allowed.json +14 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/beneficiary-not-allowed.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/cap-exceeded.json +12 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/cap-exceeded.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/escalated-above-threshold.json +20 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/escalated-above-threshold.transcript.jsonl +7 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/happy-path.json +14 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/happy-path.transcript.jsonl +3 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/mandate-revoked.json +21 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/mandate-revoked.transcript.jsonl +9 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/prompt-injection.json +12 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/prompt-injection.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/src/adversarial.ts +177 -0
- package/templates/bills-agent/agents/bills-agent/src/bills.ts +28 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/approve.ts +9 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/check.ts +13 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/consent.ts +26 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/decide.ts +45 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/deny.ts +7 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/eval.ts +37 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/reconcile.ts +38 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/rerun.ts +65 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/resume.ts +30 -0
- package/templates/bills-agent/agents/bills-agent/src/main.ts +185 -0
- package/templates/bills-agent/agents/bills-agent/src/modules/embedded-consent.ts +125 -0
- package/templates/bills-agent/agents/bills-agent/src/modules/pix-out.ts +73 -0
- package/templates/bills-agent/agents/bills-agent/src/scenarios.ts +196 -0
- package/templates/bills-agent/agents/bills-agent/src/setup.ts +207 -0
- package/templates/bills-agent/agents/bills-agent/test/adversarial.test.ts +40 -0
- package/templates/bills-agent/agents/bills-agent/test/check.test.ts +85 -0
- package/templates/bills-agent/agents/bills-agent/test/cli.test.ts +177 -0
- package/templates/bills-agent/agents/bills-agent/test/scenarios.test.ts +73 -0
- package/templates/bills-agent/agents/bills-agent/tools.json +51 -0
- package/templates/bills-agent/agents/bills-agent/tsconfig.json +11 -0
- package/templates/bills-agent/package.json +34 -0
- package/templates/bills-agent/packages/agent-core/README.md +19 -0
- package/templates/bills-agent/packages/agent-core/package.json +24 -0
- package/templates/bills-agent/packages/agent-core/src/agent.ts +125 -0
- package/templates/bills-agent/packages/agent-core/src/api/charge-rail.ts +154 -0
- package/templates/bills-agent/packages/agent-core/src/api/client.ts +53 -0
- package/templates/bills-agent/packages/agent-core/src/api/mandate-status.ts +58 -0
- package/templates/bills-agent/packages/agent-core/src/api/rail.ts +101 -0
- package/templates/bills-agent/packages/agent-core/src/api/sandbox-payer.ts +45 -0
- package/templates/bills-agent/packages/agent-core/src/approval.ts +109 -0
- package/templates/bills-agent/packages/agent-core/src/bundle.ts +113 -0
- package/templates/bills-agent/packages/agent-core/src/check.ts +171 -0
- package/templates/bills-agent/packages/agent-core/src/engine.ts +784 -0
- package/templates/bills-agent/packages/agent-core/src/escalate.ts +85 -0
- package/templates/bills-agent/packages/agent-core/src/events.ts +40 -0
- package/templates/bills-agent/packages/agent-core/src/guardrails.ts +45 -0
- package/templates/bills-agent/packages/agent-core/src/hash.ts +49 -0
- package/templates/bills-agent/packages/agent-core/src/ids.ts +10 -0
- package/templates/bills-agent/packages/agent-core/src/index.ts +29 -0
- package/templates/bills-agent/packages/agent-core/src/mandate.ts +100 -0
- package/templates/bills-agent/packages/agent-core/src/manifest.ts +76 -0
- package/templates/bills-agent/packages/agent-core/src/providers/anthropic.ts +76 -0
- package/templates/bills-agent/packages/agent-core/src/providers/replay.ts +63 -0
- package/templates/bills-agent/packages/agent-core/src/providers/types.ts +39 -0
- package/templates/bills-agent/packages/agent-core/src/rail.ts +87 -0
- package/templates/bills-agent/packages/agent-core/src/revocation.ts +30 -0
- package/templates/bills-agent/packages/agent-core/src/secrets.ts +30 -0
- package/templates/bills-agent/packages/agent-core/src/state/store.ts +307 -0
- package/templates/bills-agent/packages/agent-core/src/state-machine.ts +146 -0
- package/templates/bills-agent/packages/agent-core/src/stubs/charge-rail.ts +193 -0
- package/templates/bills-agent/packages/agent-core/src/stubs/mandate-status.ts +57 -0
- package/templates/bills-agent/packages/agent-core/src/stubs/rail.ts +124 -0
- package/templates/bills-agent/packages/agent-core/src/tools.ts +55 -0
- package/templates/bills-agent/packages/agent-core/src/types.ts +106 -0
- package/templates/bills-agent/packages/agent-core/test/approval.test.ts +83 -0
- package/templates/bills-agent/packages/agent-core/test/engine.test.ts +524 -0
- package/templates/bills-agent/packages/agent-core/test/escalate.test.ts +42 -0
- package/templates/bills-agent/packages/agent-core/test/helpers.ts +139 -0
- package/templates/bills-agent/packages/agent-core/test/mandate-status.test.ts +153 -0
- package/templates/bills-agent/packages/agent-core/test/manifest.test.ts +45 -0
- package/templates/bills-agent/packages/agent-core/test/receivables.test.ts +265 -0
- package/templates/bills-agent/packages/agent-core/test/secrets.test.ts +28 -0
- package/templates/bills-agent/packages/agent-core/test/state-machine.test.ts +67 -0
- package/templates/bills-agent/packages/agent-core/test/transitions.type-test.ts +59 -0
- package/templates/bills-agent/packages/agent-core/tsconfig.json +10 -0
- package/templates/bills-agent/tsconfig.base.json +16 -0
- package/templates/bills-agent/vitest.config.ts +12 -0
- package/templates/collections-agent/LICENSE +21 -0
- package/templates/collections-agent/README.md +20 -0
- package/templates/collections-agent/_gitignore +10 -0
- package/templates/collections-agent/agents/collections-agent/.env.example +4 -0
- package/templates/collections-agent/agents/collections-agent/AGENTS.md +19 -0
- package/templates/collections-agent/agents/collections-agent/CLAUDE.md +19 -0
- package/templates/collections-agent/agents/collections-agent/README.md +94 -0
- package/templates/collections-agent/agents/collections-agent/SYSTEM_PROMPT.md +31 -0
- package/templates/collections-agent/agents/collections-agent/agent.yaml +28 -0
- package/templates/collections-agent/agents/collections-agent/channels/terminal/index.ts +178 -0
- package/templates/collections-agent/agents/collections-agent/channels/webhook/index.ts +131 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/beneficiary-swap-mandate.json +11 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/beneficiary-swap.json +12 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/beneficiary-swap.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/exfiltration.json +19 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/exfiltration.transcript.jsonl +3 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/false-authority.json +11 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/false-authority.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/fractioning.json +12 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/fractioning.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/model-total.json +13 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/model-total.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/prompt-injection.json +12 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/prompt-injection.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/webhook-replay.json +11 -0
- package/templates/collections-agent/agents/collections-agent/evals/eval.yaml +11 -0
- package/templates/collections-agent/agents/collections-agent/guardrails.json +20 -0
- package/templates/collections-agent/agents/collections-agent/mandate.example.json +40 -0
- package/templates/collections-agent/agents/collections-agent/package.json +29 -0
- package/templates/collections-agent/agents/collections-agent/runbook.md +27 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/beneficiary-not-allowed.json +42 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/beneficiary-not-allowed.transcript.jsonl +3 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/cap-exceeded.json +27 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/cap-exceeded.transcript.jsonl +3 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/charge-expired.json +36 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/charge-expired.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/escalated-above-threshold.json +49 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/escalated-above-threshold.transcript.jsonl +5 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/happy-path.json +60 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/happy-path.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/instalments.json +49 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/instalments.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/mandate-revoked.json +42 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/mandate-revoked.transcript.jsonl +6 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/prompt-injection.json +27 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/prompt-injection.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/src/adversarial.ts +190 -0
- package/templates/collections-agent/agents/collections-agent/src/agreements.ts +45 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/approve.ts +10 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/check.ts +13 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/decide.ts +48 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/deny.ts +7 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/eval.ts +39 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/poll.ts +35 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/reconcile.ts +38 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/rerun.ts +55 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/resume.ts +30 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/webhook.ts +36 -0
- package/templates/collections-agent/agents/collections-agent/src/envelope.ts +103 -0
- package/templates/collections-agent/agents/collections-agent/src/main.ts +193 -0
- package/templates/collections-agent/agents/collections-agent/src/modules/bolepix-receivables.ts +105 -0
- package/templates/collections-agent/agents/collections-agent/src/poll.ts +94 -0
- package/templates/collections-agent/agents/collections-agent/src/scenarios.ts +252 -0
- package/templates/collections-agent/agents/collections-agent/src/setup.ts +250 -0
- package/templates/collections-agent/agents/collections-agent/test/adversarial.test.ts +45 -0
- package/templates/collections-agent/agents/collections-agent/test/check.test.ts +51 -0
- package/templates/collections-agent/agents/collections-agent/test/cli.test.ts +172 -0
- package/templates/collections-agent/agents/collections-agent/test/envelope.test.ts +53 -0
- package/templates/collections-agent/agents/collections-agent/test/fixtures/accept-1042.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/test/fixtures/accept-1103.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/test/scenarios.test.ts +95 -0
- package/templates/collections-agent/agents/collections-agent/test/webhook.test.ts +100 -0
- package/templates/collections-agent/agents/collections-agent/tools.json +40 -0
- package/templates/collections-agent/agents/collections-agent/tsconfig.json +11 -0
- package/templates/collections-agent/package.json +35 -0
- package/templates/collections-agent/packages/agent-core/README.md +19 -0
- package/templates/collections-agent/packages/agent-core/package.json +24 -0
- package/templates/collections-agent/packages/agent-core/src/agent.ts +125 -0
- package/templates/collections-agent/packages/agent-core/src/api/charge-rail.ts +154 -0
- package/templates/collections-agent/packages/agent-core/src/api/client.ts +53 -0
- package/templates/collections-agent/packages/agent-core/src/api/mandate-status.ts +58 -0
- package/templates/collections-agent/packages/agent-core/src/api/rail.ts +101 -0
- package/templates/collections-agent/packages/agent-core/src/api/sandbox-payer.ts +45 -0
- package/templates/collections-agent/packages/agent-core/src/approval.ts +109 -0
- package/templates/collections-agent/packages/agent-core/src/bundle.ts +113 -0
- package/templates/collections-agent/packages/agent-core/src/check.ts +171 -0
- package/templates/collections-agent/packages/agent-core/src/engine.ts +784 -0
- package/templates/collections-agent/packages/agent-core/src/escalate.ts +85 -0
- package/templates/collections-agent/packages/agent-core/src/events.ts +40 -0
- package/templates/collections-agent/packages/agent-core/src/guardrails.ts +45 -0
- package/templates/collections-agent/packages/agent-core/src/hash.ts +49 -0
- package/templates/collections-agent/packages/agent-core/src/ids.ts +10 -0
- package/templates/collections-agent/packages/agent-core/src/index.ts +29 -0
- package/templates/collections-agent/packages/agent-core/src/mandate.ts +100 -0
- package/templates/collections-agent/packages/agent-core/src/manifest.ts +76 -0
- package/templates/collections-agent/packages/agent-core/src/providers/anthropic.ts +76 -0
- package/templates/collections-agent/packages/agent-core/src/providers/replay.ts +63 -0
- package/templates/collections-agent/packages/agent-core/src/providers/types.ts +39 -0
- package/templates/collections-agent/packages/agent-core/src/rail.ts +87 -0
- package/templates/collections-agent/packages/agent-core/src/revocation.ts +30 -0
- package/templates/collections-agent/packages/agent-core/src/secrets.ts +30 -0
- package/templates/collections-agent/packages/agent-core/src/state/store.ts +307 -0
- package/templates/collections-agent/packages/agent-core/src/state-machine.ts +146 -0
- package/templates/collections-agent/packages/agent-core/src/stubs/charge-rail.ts +193 -0
- package/templates/collections-agent/packages/agent-core/src/stubs/mandate-status.ts +57 -0
- package/templates/collections-agent/packages/agent-core/src/stubs/rail.ts +124 -0
- package/templates/collections-agent/packages/agent-core/src/tools.ts +55 -0
- package/templates/collections-agent/packages/agent-core/src/types.ts +106 -0
- package/templates/collections-agent/packages/agent-core/test/approval.test.ts +83 -0
- package/templates/collections-agent/packages/agent-core/test/engine.test.ts +524 -0
- package/templates/collections-agent/packages/agent-core/test/escalate.test.ts +42 -0
- package/templates/collections-agent/packages/agent-core/test/helpers.ts +139 -0
- package/templates/collections-agent/packages/agent-core/test/mandate-status.test.ts +153 -0
- package/templates/collections-agent/packages/agent-core/test/manifest.test.ts +45 -0
- package/templates/collections-agent/packages/agent-core/test/receivables.test.ts +265 -0
- package/templates/collections-agent/packages/agent-core/test/secrets.test.ts +28 -0
- package/templates/collections-agent/packages/agent-core/test/state-machine.test.ts +67 -0
- package/templates/collections-agent/packages/agent-core/test/transitions.type-test.ts +59 -0
- package/templates/collections-agent/packages/agent-core/tsconfig.json +10 -0
- package/templates/collections-agent/tsconfig.base.json +16 -0
- package/templates/collections-agent/vitest.config.ts +12 -0
- package/templates/kits.lock.json +41 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hmacSigner } from "../src/approval.js";
|
|
5
|
+
import { ProofBundle } from "../src/bundle.js";
|
|
6
|
+
import { ExecutionEngine, type EngineDeps } from "../src/engine.js";
|
|
7
|
+
import { GuardrailsSchema, type Guardrails } from "../src/guardrails.js";
|
|
8
|
+
import { MandateSchema, type Mandate } from "../src/mandate.js";
|
|
9
|
+
import { ManifestSchema, type Manifest } from "../src/manifest.js";
|
|
10
|
+
import { StateStore } from "../src/state/store.js";
|
|
11
|
+
import { LocalMandateStatusStub } from "../src/stubs/mandate-status.js";
|
|
12
|
+
import type { MandateStatusSource } from "../src/revocation.js";
|
|
13
|
+
import { StubRail, type StubRailOptions } from "../src/stubs/rail.js";
|
|
14
|
+
import type { ApprovalMode } from "../src/types.js";
|
|
15
|
+
|
|
16
|
+
export const ESCOLA = "escola@exemplo.com.br";
|
|
17
|
+
export const MERCADO = "+5511999990001";
|
|
18
|
+
export const FUNCIONARIA = "123.456.789-09";
|
|
19
|
+
|
|
20
|
+
export function testManifest(over: Partial<Manifest> = {}): Manifest {
|
|
21
|
+
return ManifestSchema.parse({
|
|
22
|
+
schema: 1,
|
|
23
|
+
name: "bills-agent",
|
|
24
|
+
version: "0.1.0",
|
|
25
|
+
approval: ["human", "mandate"],
|
|
26
|
+
default_approval: "human",
|
|
27
|
+
escalate_above: { amount: 150000, new_beneficiary: true, outside_hours: "22:00-07:00" },
|
|
28
|
+
mcp: "@codespar/mcp@0.5.8",
|
|
29
|
+
cli: "@codespar/cli@0.13.0",
|
|
30
|
+
tools: "./tools.json",
|
|
31
|
+
guardrails: "./guardrails.json",
|
|
32
|
+
mandate_schema: "./mandate.example.json",
|
|
33
|
+
events: ["commerce.payment.succeeded", "commerce.payment.failed"],
|
|
34
|
+
channels: ["terminal"],
|
|
35
|
+
maturity: { "pix-out": "sandbox", "embedded-consent": "sandbox", "receipt-verification": "blocked" },
|
|
36
|
+
scenarios: "./scenarios/",
|
|
37
|
+
evals: "./evals/",
|
|
38
|
+
agents_md: "./AGENTS.md",
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function testGuardrails(over: Partial<Guardrails> = {}): Guardrails {
|
|
44
|
+
return GuardrailsSchema.parse({
|
|
45
|
+
approval: "human",
|
|
46
|
+
escalate_above: { amount: 150000, new_beneficiary: true, outside_hours: "22:00-07:00" },
|
|
47
|
+
velocity: { window_hours: 24 },
|
|
48
|
+
...over,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function testMandate(over: Partial<Mandate> = {}): Mandate {
|
|
53
|
+
return MandateSchema.parse({
|
|
54
|
+
id: "mdt_test_0001",
|
|
55
|
+
version: 1,
|
|
56
|
+
consumer_id: "usr_demo",
|
|
57
|
+
agent_id: "bills-agent",
|
|
58
|
+
purpose: "contas do mes",
|
|
59
|
+
currency: "BRL",
|
|
60
|
+
cap_minor: 7200000,
|
|
61
|
+
per_tx_cap_minor: 250000,
|
|
62
|
+
periodic_cap: { window: "month", cap_minor: 600000 },
|
|
63
|
+
merchant_pin_kind: "pix-key",
|
|
64
|
+
merchant_allowlist: [ESCOLA, MERCADO, FUNCIONARIA],
|
|
65
|
+
beneficiaries: [
|
|
66
|
+
{ alias: "escola", name: "Escola Aurora", payee: ESCOLA },
|
|
67
|
+
{ alias: "mercado", name: "Mercado do Bairro", payee: MERCADO },
|
|
68
|
+
{ alias: "funcionaria", name: "Maria (funcionaria)", payee: FUNCIONARIA },
|
|
69
|
+
],
|
|
70
|
+
status: "active",
|
|
71
|
+
expires_at: "2027-09-23T00:00:00.000Z",
|
|
72
|
+
...over,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Harness {
|
|
77
|
+
dir: string;
|
|
78
|
+
store: StateStore;
|
|
79
|
+
gate: LocalMandateStatusStub;
|
|
80
|
+
rail: StubRail;
|
|
81
|
+
bundle: ProofBundle;
|
|
82
|
+
engine: ExecutionEngine;
|
|
83
|
+
now: Date;
|
|
84
|
+
setNow(date: Date): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface HarnessOptions {
|
|
88
|
+
mode?: ApprovalMode;
|
|
89
|
+
now?: Date;
|
|
90
|
+
guardrails?: Partial<Guardrails>;
|
|
91
|
+
mandate?: Partial<Mandate>;
|
|
92
|
+
manifest?: Partial<Manifest>;
|
|
93
|
+
rail?: StubRailOptions;
|
|
94
|
+
dir?: string;
|
|
95
|
+
runId?: string;
|
|
96
|
+
/** The section 4.7 status source; the local stub unless a test wires the API one. */
|
|
97
|
+
status?: MandateStatusSource;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function harness(options: HarnessOptions = {}): Harness {
|
|
101
|
+
const dir = options.dir ?? mkdtempSync(join(tmpdir(), "agent-core-"));
|
|
102
|
+
let now = options.now ?? new Date("2026-09-23T18:00:00Z"); // 15:00 in Sao Paulo
|
|
103
|
+
const clock = () => now;
|
|
104
|
+
const store = new StateStore(join(dir, "state.db"));
|
|
105
|
+
const gate = new LocalMandateStatusStub(store, clock);
|
|
106
|
+
const rail = new StubRail(store, { clock, ...(options.rail ?? {}) });
|
|
107
|
+
const runId = options.runId ?? "run_test";
|
|
108
|
+
const bundle = new ProofBundle(join(dir, "runs"), runId);
|
|
109
|
+
const mode = options.mode ?? "human";
|
|
110
|
+
const deps: EngineDeps = {
|
|
111
|
+
store,
|
|
112
|
+
rail,
|
|
113
|
+
status: options.status ?? gate,
|
|
114
|
+
signer: hmacSigner("test", Buffer.alloc(32, 1)),
|
|
115
|
+
manifest: testManifest(options.manifest),
|
|
116
|
+
guardrails: testGuardrails({ approval: mode, ...(options.guardrails ?? {}) }),
|
|
117
|
+
mandate: testMandate(options.mandate),
|
|
118
|
+
bundle,
|
|
119
|
+
mode,
|
|
120
|
+
runId,
|
|
121
|
+
onBehalfOf: "usr_demo",
|
|
122
|
+
clock,
|
|
123
|
+
};
|
|
124
|
+
const engine = new ExecutionEngine(deps);
|
|
125
|
+
return {
|
|
126
|
+
dir,
|
|
127
|
+
store,
|
|
128
|
+
gate,
|
|
129
|
+
rail,
|
|
130
|
+
bundle,
|
|
131
|
+
engine,
|
|
132
|
+
get now() {
|
|
133
|
+
return now;
|
|
134
|
+
},
|
|
135
|
+
setNow(date: Date) {
|
|
136
|
+
now = date;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 4.7 against the real client: `ApiMandateStatusSource` reads
|
|
3
|
+
* `GET /v1/mandates/{id}` on a mocked HTTP server, and the engine is driven
|
|
4
|
+
* through it. Every answer but `active` must keep `rail.pay` from ever
|
|
5
|
+
* being called, and a read that does not answer must refuse too.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer, type Server } from "node:http";
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { createCodeSparClient } from "../src/api/client.js";
|
|
10
|
+
import { ApiMandateStatusSource } from "../src/api/mandate-status.js";
|
|
11
|
+
import type { MandateStatusSource } from "../src/revocation.js";
|
|
12
|
+
import { harness, type Harness } from "./helpers.js";
|
|
13
|
+
|
|
14
|
+
const MANDATE_ID = "mdt_test_0001";
|
|
15
|
+
const NOW = new Date("2026-09-23T18:00:00Z");
|
|
16
|
+
const approver = { id: "usr_demo_titular", channel: "terminal" };
|
|
17
|
+
|
|
18
|
+
/** The 14-field projection `GET /v1/mandates/{id}` answers (measured on staging, 2026-09-23). */
|
|
19
|
+
function mandateBody(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
20
|
+
return {
|
|
21
|
+
id: MANDATE_ID,
|
|
22
|
+
consumer_id: "usr_demo",
|
|
23
|
+
agent_id: "bills-agent",
|
|
24
|
+
display_name: null,
|
|
25
|
+
purpose: "contas do mes",
|
|
26
|
+
merchant_allowlist: ["escola@exemplo.com.br"],
|
|
27
|
+
merchant_pin_kind: "pix-key",
|
|
28
|
+
intent_note: null,
|
|
29
|
+
cap_minor: "7200000",
|
|
30
|
+
per_tx_cap_minor: "250000",
|
|
31
|
+
currency: "BRL",
|
|
32
|
+
status: "active",
|
|
33
|
+
expires_at: "2027-09-23T00:00:00.000Z",
|
|
34
|
+
created_at: "2026-09-23T00:00:00.000Z",
|
|
35
|
+
...over,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Answer = { status: number; body?: unknown; hang?: boolean };
|
|
40
|
+
|
|
41
|
+
let server: Server;
|
|
42
|
+
let baseUrl: string;
|
|
43
|
+
let next: Answer = { status: 200, body: mandateBody() };
|
|
44
|
+
const requests: Array<{ method: string; url: string; bearerIsTestKey: boolean }> = [];
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
server = createServer((req, res) => {
|
|
48
|
+
requests.push({ method: req.method ?? "", url: req.url ?? "", bearerIsTestKey: (req.headers.authorization ?? "").startsWith("Bearer csk_test_") });
|
|
49
|
+
if (next.hang) return; // never answers: the client's timeout is the only way out
|
|
50
|
+
res.writeHead(next.status, { "content-type": "application/json" });
|
|
51
|
+
res.end(typeof next.body === "string" ? next.body : JSON.stringify(next.body));
|
|
52
|
+
});
|
|
53
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
54
|
+
baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
server.closeAllConnections();
|
|
59
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
next = { status: 200, body: mandateBody() };
|
|
64
|
+
requests.length = 0;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function apiSource(timeoutMs = 5_000): ApiMandateStatusSource {
|
|
68
|
+
// The placeholder passes the `csk_test_` guard and is the one test-key-shaped string the secret scan allows.
|
|
69
|
+
return new ApiMandateStatusSource(createCodeSparClient({ apiKey: "csk_test_your_key_here", baseUrl, timeoutMs }), () => NOW);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function apiHarness(status: MandateStatusSource = apiSource()): Harness {
|
|
73
|
+
return harness({ mode: "mandate", now: NOW, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, status });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Drafts and approves under `active`, then flips the API's answer: the last gate is what is under test. */
|
|
77
|
+
async function approvedThen(h: Harness, answer: Answer): Promise<string> {
|
|
78
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
79
|
+
if (!d.ok) throw new Error(`refused before draft: ${d.reason}`);
|
|
80
|
+
expect(d.execution.state).toBe("approved");
|
|
81
|
+
next = answer;
|
|
82
|
+
return d.execution.id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("ApiMandateStatusSource reads GET /v1/mandates/{id} with the test key", () => {
|
|
86
|
+
it("answers the status the API gives, on the one registered spelling of the route", async () => {
|
|
87
|
+
const report = await apiSource().check(MANDATE_ID);
|
|
88
|
+
expect(report).toMatchObject({ mandate_id: MANDATE_ID, status: "active", org_paused: false, source: "api" });
|
|
89
|
+
expect(requests).toEqual([{ method: "GET", url: `/v1/mandates/${MANDATE_ID}`, bearerIsTestKey: true }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("an active mandate past its expires_at is expired, whatever the status field says", async () => {
|
|
93
|
+
next = { status: 200, body: mandateBody({ expires_at: "2026-09-23T17:59:59.000Z" }) };
|
|
94
|
+
expect((await apiSource().check(MANDATE_ID)).status).toBe("expired");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("never throws: 404, 500, a hung connection, a body without a readable status and a status outside the four are all `unknown`, with a reason", async () => {
|
|
98
|
+
next = { status: 404, body: { error: { code: "mandate_not_found", message: "no such mandate" }, request_id: null } };
|
|
99
|
+
expect(await apiSource().check(MANDATE_ID)).toMatchObject({ status: "unknown", detail: expect.stringContaining("no mandate") });
|
|
100
|
+
next = { status: 500, body: { error: { code: "internal", message: "boom" } } };
|
|
101
|
+
expect(await apiSource().check(MANDATE_ID)).toMatchObject({ status: "unknown", detail: expect.stringContaining("did not answer") });
|
|
102
|
+
next = { status: 200, body: "not json at all" };
|
|
103
|
+
expect((await apiSource().check(MANDATE_ID)).status).toBe("unknown");
|
|
104
|
+
next = { status: 200, body: mandateBody({ status: "frozen" }) };
|
|
105
|
+
expect(await apiSource().check(MANDATE_ID)).toMatchObject({ status: "unknown", detail: expect.stringContaining("frozen") });
|
|
106
|
+
next = { status: 200, hang: true };
|
|
107
|
+
expect(await apiSource(150).check(MANDATE_ID)).toMatchObject({ status: "unknown", detail: expect.stringContaining("timeout") });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("section 4.7 through the engine, on the API source: executing only on active", () => {
|
|
112
|
+
it("active: the approved execution reaches executing and settles on the rail", async () => {
|
|
113
|
+
const h = apiHarness();
|
|
114
|
+
const id = await approvedThen(h, { status: 200, body: mandateBody() });
|
|
115
|
+
const out = await h.engine.execute(id);
|
|
116
|
+
expect(out.state).toBe("settled");
|
|
117
|
+
expect(h.rail.payCount).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const refusals: Array<[string, Answer, string, string]> = [
|
|
121
|
+
["paused", { status: 200, body: mandateBody({ status: "paused" }) }, "denied", "mandate_paused"],
|
|
122
|
+
["revoked", { status: 200, body: mandateBody({ status: "revoked" }) }, "denied", "mandate_revoked"],
|
|
123
|
+
["expired", { status: 200, body: mandateBody({ status: "expired" }) }, "expired", "mandate_expired"],
|
|
124
|
+
["500", { status: 500, body: { error: { code: "internal", message: "boom" } } }, "denied", "mandate_status_unavailable"],
|
|
125
|
+
["404", { status: 404, body: { error: { code: "mandate_not_found", message: "gone" } } }, "denied", "mandate_status_unavailable"],
|
|
126
|
+
["timeout", { status: 200, hang: true }, "denied", "mandate_status_unavailable"],
|
|
127
|
+
];
|
|
128
|
+
for (const [name, answer, state, reason] of refusals) {
|
|
129
|
+
it(`${name} after approval: ${state} (${reason}), no outbox row, rail.pay never called`, async () => {
|
|
130
|
+
const h = apiHarness(name === "timeout" ? apiSource(150) : apiSource());
|
|
131
|
+
const id = await approvedThen(h, answer);
|
|
132
|
+
const out = await h.engine.execute(id);
|
|
133
|
+
expect(out.state).toBe(state);
|
|
134
|
+
expect(out.reason).toBe(reason);
|
|
135
|
+
expect(out.detail).toContain(MANDATE_ID);
|
|
136
|
+
expect(h.store.listOutbox()).toHaveLength(0);
|
|
137
|
+
expect(h.rail.payCount).toBe(0);
|
|
138
|
+
// A new request under the same answer is refused before anything is drafted.
|
|
139
|
+
expect(await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] })).toMatchObject({ ok: false, refused_before_draft: true, reason });
|
|
140
|
+
expect(h.store.listExecutions()).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it("a source that throws is fail-closed too: mandate_status_unavailable, rail.pay never called", async () => {
|
|
145
|
+
const h = apiHarness({ check: async () => { throw new Error("socket hang up"); } });
|
|
146
|
+
expect(await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] })).toMatchObject({ ok: false, reason: "mandate_status_unavailable" });
|
|
147
|
+
expect(h.rail.payCount).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("the API source never reports org_paused: the API has no kill-switch read, and the source does not invent one", async () => {
|
|
151
|
+
expect((await apiSource().check(MANDATE_ID)).org_paused).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseManifest } from "../src/manifest.js";
|
|
3
|
+
|
|
4
|
+
const valid = `
|
|
5
|
+
schema: 1
|
|
6
|
+
name: bills-agent
|
|
7
|
+
version: 0.1.0
|
|
8
|
+
approval: [human, mandate]
|
|
9
|
+
default_approval: human
|
|
10
|
+
escalate_above:
|
|
11
|
+
amount: 150000
|
|
12
|
+
new_beneficiary: true
|
|
13
|
+
outside_hours: "22:00-07:00"
|
|
14
|
+
mcp: "@codespar/mcp@0.5.8"
|
|
15
|
+
cli: "@codespar/cli@0.13.0"
|
|
16
|
+
tools: ./tools.json
|
|
17
|
+
guardrails: ./guardrails.json
|
|
18
|
+
mandate_schema: ./mandate.example.json
|
|
19
|
+
events: [commerce.payment.succeeded, commerce.payment.failed]
|
|
20
|
+
channels: [terminal]
|
|
21
|
+
maturity:
|
|
22
|
+
pix-out: sandbox
|
|
23
|
+
embedded-consent: sandbox
|
|
24
|
+
receipt-verification: blocked
|
|
25
|
+
scenarios: ./scenarios/
|
|
26
|
+
evals: ./evals/
|
|
27
|
+
agents_md: ./AGENTS.md
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
describe("section 4.3: agent.yaml schema 1", () => {
|
|
31
|
+
it("parses the example", () => {
|
|
32
|
+
const m = parseManifest(valid);
|
|
33
|
+
expect(m.name).toBe("bills-agent");
|
|
34
|
+
expect(m.escalate_above?.amount).toBe(150000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("refuses an unpinned mcp or cli, a wrong schema, and an unknown field", () => {
|
|
38
|
+
expect(() => parseManifest(valid.replace('"@codespar/mcp@0.5.8"', '"@codespar/mcp"'))).toThrow(/mcp/);
|
|
39
|
+
expect(() => parseManifest(valid.replace('"@codespar/cli@0.13.0"', '"@codespar/cli@latest"'))).toThrow(/cli/);
|
|
40
|
+
expect(() => parseManifest(valid.replace("schema: 1", "schema: 2"))).toThrow();
|
|
41
|
+
expect(() => parseManifest(valid + "telemetry: true\n")).toThrow(/unrecognized/i);
|
|
42
|
+
expect(() => parseManifest(valid.replace("default_approval: human", "default_approval: mandate").replace("approval: [human, mandate]", "approval: [human]"))).toThrow(/default_approval/);
|
|
43
|
+
expect(() => parseManifest(valid.replace('outside_hours: "22:00-07:00"', 'outside_hours: "10pm-7am"'))).toThrow(/HH:MM/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The receivable side of the core (collections-agent): a charge is ISSUED,
|
|
3
|
+
* not settled, when the rail accepts it; the execution stays `executing`
|
|
4
|
+
* until the payer acts, and closes from a lookup (the poll) or from a
|
|
5
|
+
* `commerce.charge.*` event (the webhook). Never twice, never on prose.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdtempSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { outcomeOf, type ChargeView } from "../src/api/charge-rail.js";
|
|
12
|
+
import { hmacSigner } from "../src/approval.js";
|
|
13
|
+
import { ProofBundle } from "../src/bundle.js";
|
|
14
|
+
import { ExecutionEngine, type EngineDeps, type PolicyExtension } from "../src/engine.js";
|
|
15
|
+
import { itemsHash } from "../src/hash.js";
|
|
16
|
+
import { StateStore } from "../src/state/store.js";
|
|
17
|
+
import { LocalMandateStatusStub } from "../src/stubs/mandate-status.js";
|
|
18
|
+
import { StubChargeRail, type StubChargeRailOptions } from "../src/stubs/charge-rail.js";
|
|
19
|
+
import { testGuardrails, testManifest, testMandate } from "./helpers.js";
|
|
20
|
+
|
|
21
|
+
const DEBTOR = "11144477735";
|
|
22
|
+
const OTHER = "22233344450";
|
|
23
|
+
|
|
24
|
+
function receivables(options: { mode?: "human" | "mandate"; rail?: StubChargeRailOptions; policyExtension?: PolicyExtension; dir?: string } = {}) {
|
|
25
|
+
const dir = options.dir ?? mkdtempSync(join(tmpdir(), "agent-core-recv-"));
|
|
26
|
+
let now = new Date("2026-09-23T18:00:00Z");
|
|
27
|
+
const clock = () => now;
|
|
28
|
+
const store = new StateStore(join(dir, "state.db"));
|
|
29
|
+
const gate = new LocalMandateStatusStub(store, clock);
|
|
30
|
+
const rail = new StubChargeRail(store, { clock, ...(options.rail ?? {}) });
|
|
31
|
+
const bundle = new ProofBundle(join(dir, "runs"), "run_recv");
|
|
32
|
+
const mode = options.mode ?? "mandate";
|
|
33
|
+
const deps: EngineDeps = {
|
|
34
|
+
store,
|
|
35
|
+
rail,
|
|
36
|
+
status: gate,
|
|
37
|
+
signer: hmacSigner("test", Buffer.alloc(32, 2)),
|
|
38
|
+
manifest: testManifest({ name: "collections-agent", escalate_above: {}, maturity: { "bolepix-receivables": "sandbox" } }),
|
|
39
|
+
guardrails: testGuardrails({ approval: mode, escalate_above: {} }),
|
|
40
|
+
mandate: testMandate({
|
|
41
|
+
agent_id: "collections-agent",
|
|
42
|
+
purpose: "cobranca",
|
|
43
|
+
merchant_pin_kind: "document",
|
|
44
|
+
merchant_allowlist: [DEBTOR, OTHER],
|
|
45
|
+
beneficiaries: [{ alias: "acordo-1", name: "Joana Devedora", payee: DEBTOR }],
|
|
46
|
+
}),
|
|
47
|
+
bundle,
|
|
48
|
+
mode,
|
|
49
|
+
runId: "run_recv",
|
|
50
|
+
onBehalfOf: "merchant_demo",
|
|
51
|
+
clock,
|
|
52
|
+
policyExtension: options.policyExtension,
|
|
53
|
+
};
|
|
54
|
+
const engine = new ExecutionEngine(deps);
|
|
55
|
+
return { dir, store, rail, bundle, engine, setNow: (d: Date) => (now = d) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("a receivable is accepted, then settled by the payer", () => {
|
|
59
|
+
it("execute leaves the execution executing (awaiting_settlement) with the instrument; two looks later it is settled with the charge as receipt", async () => {
|
|
60
|
+
const h = receivables();
|
|
61
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 108000, due_date: "2026-09-30", description: "acordo 1042, a vista" }] });
|
|
62
|
+
if (!d.ok) throw new Error("refused");
|
|
63
|
+
expect(d.execution.state).toBe("approved");
|
|
64
|
+
expect(d.execution.items[0]?.due_date).toBe("2026-09-30");
|
|
65
|
+
|
|
66
|
+
const issued = await h.engine.execute(d.execution.id);
|
|
67
|
+
expect(issued.state).toBe("executing");
|
|
68
|
+
expect(issued.reason).toBe("awaiting_settlement");
|
|
69
|
+
expect(issued.outcomes[0]).toMatchObject({ status: "accepted", instrument: { payable: false, status: "PROCESSING" } });
|
|
70
|
+
expect(h.store.getOutbox(issued.idempotency_key)!.status).toBe("sent");
|
|
71
|
+
|
|
72
|
+
const registered = await h.engine.reconcile(issued.id);
|
|
73
|
+
expect(registered.state).toBe("executing");
|
|
74
|
+
expect(registered.outcomes[0]?.instrument).toMatchObject({ payable: true, status: "PENDING" });
|
|
75
|
+
expect(registered.outcomes[0]?.instrument?.pix_copy_paste).toContain("br.gov.bcb.pix");
|
|
76
|
+
|
|
77
|
+
const paid = await h.engine.reconcile(issued.id);
|
|
78
|
+
expect(paid.state).toBe("settled");
|
|
79
|
+
expect(paid.history.map((t) => t.to)).toEqual(["approved", "executing", "settled"]);
|
|
80
|
+
expect(paid.outcomes[0]).toMatchObject({ status: "settled", receipt_id: paid.outcomes[0]!.transaction_id });
|
|
81
|
+
expect(h.store.getOutbox(issued.idempotency_key)!.status).toBe("done");
|
|
82
|
+
expect(h.bundle.listReceipts()).toHaveLength(1);
|
|
83
|
+
const events = h.bundle.readEvents();
|
|
84
|
+
expect(events.filter((e) => e["type"] === "commerce.charge.paid")).toHaveLength(1);
|
|
85
|
+
expect(events.filter((e) => e["type"] === "charge.instrument").map((e) => (e["payload"] as { payable: boolean }).payable)).toEqual([false, true]);
|
|
86
|
+
// A third look changes nothing and issues nothing.
|
|
87
|
+
expect((await h.engine.reconcile(issued.id)).state).toBe("settled");
|
|
88
|
+
expect(h.rail.issueCount).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("a receivable nobody pays closes failed with charge_expired, never settled", async () => {
|
|
92
|
+
const h = receivables({ rail: { payer: "expires" } });
|
|
93
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 50000, due_date: "2026-09-25" }] });
|
|
94
|
+
if (!d.ok) throw new Error("refused");
|
|
95
|
+
const issued = await h.engine.execute(d.execution.id);
|
|
96
|
+
await h.engine.reconcile(issued.id);
|
|
97
|
+
const closed = await h.engine.reconcile(issued.id);
|
|
98
|
+
expect(closed.state).toBe("failed");
|
|
99
|
+
expect(closed.reason).toBe("charge_expired");
|
|
100
|
+
expect(h.bundle.listReceipts()).toHaveLength(0);
|
|
101
|
+
expect(h.store.getOutbox(issued.idempotency_key)!.status).toBe("failed");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("the poll logs an observed state once, however many times it sees it", async () => {
|
|
105
|
+
const h = receivables({ rail: { payer: "never" } });
|
|
106
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 50000, due_date: "2026-09-25" }] });
|
|
107
|
+
if (!d.ok) throw new Error("refused");
|
|
108
|
+
const issued = await h.engine.execute(d.execution.id);
|
|
109
|
+
for (let i = 0; i < 5; i += 1) await h.engine.reconcile(issued.id);
|
|
110
|
+
const events = h.store.listEvents({ execution_id: issued.id });
|
|
111
|
+
// Five looks, one observed state (PENDING, payable): one line. The instrument log names the two states the charge went through.
|
|
112
|
+
expect(events.filter((e) => e.type === "rail.reconcile")).toHaveLength(1);
|
|
113
|
+
expect(events.filter((e) => e.type === "charge.instrument")).toHaveLength(2);
|
|
114
|
+
expect(events.filter((e) => e.type === "execution.awaiting_settlement")).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("killed after the issuance and resumed: one charge, one settlement", async () => {
|
|
118
|
+
const h = receivables();
|
|
119
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 50000, due_date: "2026-09-25" }] });
|
|
120
|
+
if (!d.ok) throw new Error("refused");
|
|
121
|
+
let died = false;
|
|
122
|
+
const flaky = receivables({ dir: h.dir, rail: { afterDispatch: () => { died = true; throw new Error("killed"); } } });
|
|
123
|
+
await expect(flaky.engine.execute(d.execution.id)).rejects.toThrow("killed");
|
|
124
|
+
expect(died).toBe(true);
|
|
125
|
+
// The row says executing and the outbox says sent: resume reconciles, never re-issues.
|
|
126
|
+
const again = receivables({ dir: h.dir });
|
|
127
|
+
const stuck = again.store.getExecution(d.execution.id)!;
|
|
128
|
+
expect(stuck.state).toBe("executing");
|
|
129
|
+
const first = await again.engine.resumePending(stuck.id);
|
|
130
|
+
expect(first.state).toBe("executing");
|
|
131
|
+
expect(first.outcomes[0]?.status).toBe("accepted");
|
|
132
|
+
const settled = await again.engine.reconcile(stuck.id);
|
|
133
|
+
expect(settled.state).toBe("settled");
|
|
134
|
+
expect(again.rail.issueCount).toBe(0);
|
|
135
|
+
expect((again.store as unknown as { db: { prepare(sql: string): { get(): { n: number } } } }).db.prepare("SELECT COUNT(*) AS n FROM stub_rail_attempts").get().n).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("an instalment agreement is N receivables; the execution settles when every parcel is paid", async () => {
|
|
139
|
+
const h = receivables();
|
|
140
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 40000, due_date: "2026-09-30" }, { payee: "acordo-1", amount: 40000, due_date: "2026-10-30" }, { payee: "acordo-1", amount: 40000, due_date: "2026-11-30" }] });
|
|
141
|
+
if (!d.ok) throw new Error("refused");
|
|
142
|
+
expect(d.execution.total).toBe(120000);
|
|
143
|
+
const issued = await h.engine.execute(d.execution.id);
|
|
144
|
+
expect(issued.outcomes.map((o) => o.status)).toEqual(["accepted", "accepted", "accepted"]);
|
|
145
|
+
expect(h.rail.issueCount).toBe(3);
|
|
146
|
+
const a2 = issued.outcomes[2]!.attempt_id;
|
|
147
|
+
h.rail.decide(a2, "never");
|
|
148
|
+
await h.engine.reconcile(issued.id);
|
|
149
|
+
const partly = await h.engine.reconcile(issued.id);
|
|
150
|
+
expect(partly.state).toBe("executing");
|
|
151
|
+
expect(partly.outcomes.map((o) => o.status)).toEqual(["settled", "settled", "accepted"]);
|
|
152
|
+
h.rail.decide(a2, "pays");
|
|
153
|
+
const done = await h.engine.reconcile(issued.id);
|
|
154
|
+
expect(done.state).toBe("settled");
|
|
155
|
+
expect(h.bundle.listReceipts()).toHaveLength(3);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("the webhook path: commerce.charge.* by attempt id or by the rail's charge id", () => {
|
|
160
|
+
it("paid twice settles once; paid before created is fine; expired after paid moves nothing", async () => {
|
|
161
|
+
const h = receivables({ rail: { payer: "never" } });
|
|
162
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 50000, due_date: "2026-09-25" }] });
|
|
163
|
+
if (!d.ok) throw new Error("refused");
|
|
164
|
+
const issued = await h.engine.execute(d.execution.id);
|
|
165
|
+
const chargeId = issued.outcomes[0]!.transaction_id!;
|
|
166
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_paid_1", type: "commerce.charge.paid", transaction_id: chargeId })).toEqual({ applied: true, reason: "settled" });
|
|
167
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_paid_1", type: "commerce.charge.paid", transaction_id: chargeId })).toEqual({ applied: false, reason: "duplicate event id" });
|
|
168
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_created_late", type: "commerce.charge.created", transaction_id: chargeId })).toMatchObject({ applied: false });
|
|
169
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_paid_2", type: "commerce.charge.paid", transaction_id: chargeId })).toMatchObject({ applied: false, reason: "execution already settled" });
|
|
170
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_expired_late", type: "commerce.charge.expired", transaction_id: chargeId })).toMatchObject({ applied: false, reason: "execution already settled" });
|
|
171
|
+
const settled = h.store.getExecution(issued.id)!;
|
|
172
|
+
expect(settled.state).toBe("settled");
|
|
173
|
+
expect(settled.outcomes[0]).toMatchObject({ status: "settled", transaction_id: chargeId, receipt_id: chargeId });
|
|
174
|
+
expect(h.store.listEvents({ execution_id: issued.id }).filter((e) => e.type === "execution.transition" && (e.payload as { to: string }).to === "settled")).toHaveLength(1);
|
|
175
|
+
// The settlement came by event, so the bundle holds no receipt yet; collectReceipts fetches it once.
|
|
176
|
+
expect(h.bundle.listReceipts()).toHaveLength(0);
|
|
177
|
+
expect(await h.engine.collectReceipts(issued.id)).toEqual([chargeId]);
|
|
178
|
+
expect(await h.engine.collectReceipts(issued.id)).toEqual([]);
|
|
179
|
+
expect(h.bundle.listReceipts()).toHaveLength(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("expired by event closes failed with charge_expired; a later paid for the same charge moves nothing", async () => {
|
|
183
|
+
const h = receivables({ rail: { payer: "never" } });
|
|
184
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 50000, due_date: "2026-09-25" }] });
|
|
185
|
+
if (!d.ok) throw new Error("refused");
|
|
186
|
+
const issued = await h.engine.execute(d.execution.id);
|
|
187
|
+
const attempt = issued.outcomes[0]!.attempt_id;
|
|
188
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_exp", type: "commerce.charge.expired", attempt_id: attempt })).toEqual({ applied: true, reason: "failed" });
|
|
189
|
+
expect(h.store.getExecution(issued.id)!.reason).toBe("charge_expired");
|
|
190
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_paid_after", type: "commerce.charge.paid", attempt_id: attempt })).toMatchObject({ applied: false });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("an event naming a charge this kit never issued is not applied", () => {
|
|
194
|
+
const h = receivables();
|
|
195
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_x", type: "commerce.charge.paid", transaction_id: "chg_someone_elses" })).toEqual({ applied: false, reason: "unknown attempt" });
|
|
196
|
+
expect(h.engine.ingestExternalEvent({ event_id: "evt_y", type: "commerce.charge.paid" })).toMatchObject({ applied: false });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("the kit's envelope runs at every gate, and only tightens", () => {
|
|
201
|
+
const envelope: PolicyExtension = (execution) => {
|
|
202
|
+
const principal = 120000;
|
|
203
|
+
if (execution.total < principal * 0.85) return { reason: "outside_envelope", detail: `total ${execution.total} is below the floor of ${principal * 0.85} (15% off ${principal})` };
|
|
204
|
+
return undefined;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
it("a proposal below the discount floor is denied at draft, even in human mode", async () => {
|
|
208
|
+
const h = receivables({ mode: "human", policyExtension: envelope });
|
|
209
|
+
const d = await h.engine.draft({ items: [{ payee: "acordo-1", amount: 1000, due_date: "2026-09-30" }] });
|
|
210
|
+
if (!d.ok) throw new Error("refused");
|
|
211
|
+
expect(d.execution.state).toBe("denied");
|
|
212
|
+
expect(d.execution.reason).toBe("outside_envelope");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("a debtor without an open agreement is not a payee: denied in mandate, blocked for the human, never approvable", async () => {
|
|
216
|
+
const h = receivables({ mode: "human", policyExtension: envelope });
|
|
217
|
+
const d = await h.engine.draft({ items: [{ payee: "99988877766", amount: 120000, due_date: "2026-09-30" }] });
|
|
218
|
+
if (!d.ok) throw new Error("refused");
|
|
219
|
+
expect(d.execution.state).toBe("awaiting_approval");
|
|
220
|
+
expect(d.execution.blocking_reasons).toEqual(["beneficiary_not_allowed"]);
|
|
221
|
+
expect(h.engine.approve(d.execution.id, { id: "usr_operator", channel: "terminal" }).state).toBe("denied");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("the due date is part of the items_hash: moving it after approval is another charge", () => {
|
|
225
|
+
const a = itemsHash([{ beneficiary: "x", payee: DEBTOR, amount: 100, currency: "BRL", due_date: "2026-09-30" }]);
|
|
226
|
+
const b = itemsHash([{ beneficiary: "x", payee: DEBTOR, amount: 100, currency: "BRL", due_date: "2026-10-30" }]);
|
|
227
|
+
const c = itemsHash([{ beneficiary: "x", payee: DEBTOR, amount: 100, currency: "BRL" }]);
|
|
228
|
+
expect(a).not.toBe(b);
|
|
229
|
+
expect(a).not.toBe(c);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("outcomeOf: the API's charge view, mapped by fields and never by prose", () => {
|
|
234
|
+
const base: ChargeView = {
|
|
235
|
+
id: "chg_1",
|
|
236
|
+
status: "PENDING",
|
|
237
|
+
local_status: "pending",
|
|
238
|
+
status_conflict: false,
|
|
239
|
+
method: "boleto",
|
|
240
|
+
currency: "BRL",
|
|
241
|
+
amount: 1080.5,
|
|
242
|
+
amount_minor: 108050,
|
|
243
|
+
due_date: "2026-09-30",
|
|
244
|
+
payable: true,
|
|
245
|
+
boleto_bar_code: "1".repeat(44),
|
|
246
|
+
boleto_bank_line: "2".repeat(47),
|
|
247
|
+
pix_copy_paste: "000201...",
|
|
248
|
+
credit_correlation_armed: true,
|
|
249
|
+
payment_in_flight: false,
|
|
250
|
+
settlement: null,
|
|
251
|
+
issuance_unconfirmed: false,
|
|
252
|
+
};
|
|
253
|
+
it("maps the five states", () => {
|
|
254
|
+
expect(outcomeOf(base)).toMatchObject({ status: "accepted", transaction_id: "chg_1", instrument: { payable: true, status: "PENDING", pix_copy_paste: "000201..." } });
|
|
255
|
+
expect(outcomeOf({ ...base, status: "PROCESSING", payable: false, pix_copy_paste: null })).toMatchObject({ status: "accepted", instrument: { payable: false } });
|
|
256
|
+
expect(outcomeOf({ ...base, local_status: "settled", status: "CONFIRMED", settlement: "confirmed" })).toMatchObject({ status: "settled", transaction_id: "chg_1", receipt_id: "chg_1", money_moved: false });
|
|
257
|
+
expect(outcomeOf({ ...base, local_status: "expired", status: "CANCELLED" })).toMatchObject({ status: "failed", code: "charge_expired" });
|
|
258
|
+
expect(outcomeOf({ ...base, status: "EXPIRED" })).toMatchObject({ status: "failed", code: "charge_expired" });
|
|
259
|
+
expect(outcomeOf({ ...base, status: "CANCELLED" })).toMatchObject({ status: "failed", code: "charge_cancelled" });
|
|
260
|
+
expect(outcomeOf({ ...base, id: null, issuance_unconfirmed: true })).toEqual({ status: "in_flight" });
|
|
261
|
+
});
|
|
262
|
+
it("a payment notified and not yet credited is still accepted: nothing settles on a notification", () => {
|
|
263
|
+
expect(outcomeOf({ ...base, payment_in_flight: true, settlement: "pending" })).toMatchObject({ status: "accepted" });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createCodeSparClient } from "../src/api/client.js";
|
|
3
|
+
import { assertTestKey, isTestKey, NotATestKeyError, redactKey } from "../src/secrets.js";
|
|
4
|
+
|
|
5
|
+
// Built at runtime so the secret scan (which refuses key-shaped literals) stays honest about this file.
|
|
6
|
+
const TEST = ["csk", "test", "0123456789abcdef"].join("_");
|
|
7
|
+
const LIVE = ["csk", "live", "0123456789"].join("_");
|
|
8
|
+
|
|
9
|
+
describe("sandbox by construction", () => {
|
|
10
|
+
it("accepts only csk_test_ keys", () => {
|
|
11
|
+
expect(isTestKey(TEST)).toBe(true);
|
|
12
|
+
expect(isTestKey(LIVE)).toBe(false);
|
|
13
|
+
expect(isTestKey("csk_test_")).toBe(false);
|
|
14
|
+
expect(isTestKey(undefined)).toBe(false);
|
|
15
|
+
expect(() => assertTestKey(LIVE)).toThrow(NotATestKeyError);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("refuses to build an API client with a live key, before any network", () => {
|
|
19
|
+
expect(() => createCodeSparClient({ apiKey: LIVE })).toThrow(NotATestKeyError);
|
|
20
|
+
expect(() => createCodeSparClient({ apiKey: undefined })).toThrow(NotATestKeyError);
|
|
21
|
+
expect(createCodeSparClient({ apiKey: TEST })).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("redacts keys for logs", () => {
|
|
25
|
+
expect(redactKey(TEST)).toBe("csk_test_…cdef");
|
|
26
|
+
expect(redactKey(TEST)).not.toContain("0123456789");
|
|
27
|
+
});
|
|
28
|
+
});
|