diff --git a/integrations/aura/README.md b/integrations/aura/README.md new file mode 100644 index 0000000..bc60deb --- /dev/null +++ b/integrations/aura/README.md @@ -0,0 +1,129 @@ +# AURA trust-check adapter (TypeScript) + +Opt-in, **read-only** counterparty reputation for PayBot. One HTTPS GET +answers *"can I trust this agent before I settle a payment to it?"* — a +natural `beforeSettle` gate in front of `client.pay()`. + +- **Zero dependencies** — global `fetch` (Node 18+), no new packages. +- **Read-only** — the only network call is `GET /check?did=...`. No auth, no key. +- **No coupling** — does not sign, hold keys, move USDC, or touch the wallet. + PayBot's `pay()` flow is untouched; this sits *in front* of it. +- **Off by default** — nothing runs until you call it. + +## Enable (opt-in) + +Gate the settlement at the call site. No global hooks, no monkey-patching: + +```ts +import { PayBotClient, type PaymentRequest } from 'paybot-sdk'; +import { beforeSettle, AuraUntrusted } from './integrations/aura'; + +const client = new PayBotClient(config); // your existing PayBot config + +async function payChecked(counterpartyDid: string, req: PaymentRequest) { + try { + await beforeSettle(counterpartyDid); // rejects high_risk + unknown + } catch (e) { + if (e instanceof AuraUntrusted) { + console.warn('blocked:', e.message); // your policy decides + return; + } + throw e; + } + return client.pay(req); // existing flow, unchanged +} +``` + +`payTo` in a `PaymentRequest` is a wallet address; the AURA DID is the +counterparty's portable identity, supplied by your own mapping. The gate keys +on that DID — it composes cleanly with PayBot's existing `TRUST_VIOLATION` +error model as a *pre-flight* reputation axis. + +Prefer to read the verdict instead of throwing? + +```ts +import { auraVerdict } from './integrations/aura'; + +const v = await auraVerdict(counterpartyDid); +console.log(v.verdict); // trusted | caution | high_risk | new | unknown +console.log(v.reason, v.score, v.ok); + +// v.dimensions tells you *which* axis is weak, not just the aggregate: +if ((v.dimensions?.financial_integrity ?? 1) < 0.4) requireManualReview(); // placeholder for your own policy +``` + +> `v.ok` reflects the *verdict class* (true for `trusted`/`caution`), not the +> outcome of `beforeSettle` — the gate's default `allow` also lets `new` +> through. Use the gate's return/throw for the decision, `v.ok` for display. + +## Verdicts + +| verdict | meaning | `ok` | +|---|---|---| +| `trusted` | strong on-chain track record (composite ≥ 0.70) | ✅ | +| `caution` | mixed history (0.40–0.70) | ✅ | +| `high_risk` | poor track record (< 0.40) | ❌ | +| `new` | registered identity, no interactions yet | ❌ | +| `unknown` | no track record — or AURA was unreachable | ❌ | + +## Policy knobs + +```ts +await beforeSettle(did, { allow: ['trusted', 'caution'] }); // reject new too +await beforeSettle(did, { failOpen: true }); // unreachable => pass +await beforeSettle(did, { baseUrl: 'https://my-mirror', timeoutMs: 5000 }); +``` + +`requireTrust` is an alias of `beforeSettle` for non-payment call sites. + +## Failure behavior + +`auraVerdict()` **never rejects on a network error** — it resolves to an +`unknown` verdict with the reason set. The gate then decides: + +- **default (`failOpen: false`)** — `unknown` rejected → unreachable AURA + blocks the settlement. *Fail-closed.* +- **`failOpen: true`** — `unknown` from an unreachable endpoint passes, so AURA + can never take your payment flow down. *Fail-open.* + +The signal is **purely additive**: remove the adapter or take AURA down, and +PayBot behaves exactly as before. + +## Tests + +Offline — every call replays a recorded `/check` body via the `fetchImpl` +injection seam: + +```bash +npx vitest run --config integrations/aura/vitest.config.ts +``` + +17 tests: all five verdict classes, the gate's allow-list + `failOpen`, the +unreachable path, and input validation. + +## Boundary & threats + +See [THREAT_MODEL.md](./THREAT_MODEL.md) — what the verdict does and does not +prove, and the failure modes a verifier should account for. + +## Carry the AURA badge + +Show your live trust verdict in your own README — it updates automatically and +links back to your AURA profile: + +```markdown +[![AURA Verified](https://agent.auraopenprotocol.org/badge?did=YOUR_DID)](https://agent.auraopenprotocol.org/check?did=YOUR_DID) +``` + +A shields-style badge colored by verdict (`trusted` green, `caution` amber, +`high_risk` red, `new` blue, `unknown` grey). Add `&score=1` to show the +composite score. No DID yet? The bare badge is a generic mark: + +```markdown +[![Powered by AURA](https://agent.auraopenprotocol.org/badge)](https://auraopenprotocol.org) +``` + +## What's behind the verdict + +[AURA Open Protocol](https://auraopenprotocol.org) — W3C DID identity plus 8 +on-chain reputation dimensions on Base L2. Docs: https://dev.auraopenprotocol.org diff --git a/integrations/aura/THREAT_MODEL.md b/integrations/aura/THREAT_MODEL.md new file mode 100644 index 0000000..286a3b1 --- /dev/null +++ b/integrations/aura/THREAT_MODEL.md @@ -0,0 +1,52 @@ +# Threat model — AURA trust-check adapter + +A short, honest boundary statement. The verdict is **one backward-looking +signal**, not a security guarantee. Read this before treating `trusted` as a +green light for an irreversible settlement. + +## What the verdict proves + +- The DID has (or lacks) an on-chain interaction history on AURA, summarized + into a composite score and per-dimension breakdown. +- It is **backward-looking**: a statement about past recorded behavior, not a + prediction or an authorization for the *current* payment. + +## What it explicitly does NOT prove + +- **Not payment-safety.** A `trusted` agent can still be the wrong recipient or + request a bad amount. Keep this separate from PayBot's own checks + (`TRUST_VIOLATION`, limits) so the settlement decision stays auditable. +- **Not execution quality.** It says nothing about whether *this* settle succeeds. +- **Not identity proof of the live caller.** It checks a DID's reputation, not + that the entity you're paying controls that DID (see "Spoofed DID"). + +## Failure modes a caller must account for + +| # | Threat | Mitigation in this adapter | Residual risk owned by caller | +|---|---|---|---| +| 1 | **Endpoint unreachable / timeout** | Resolves to `unknown` (never rejects). Gate is fail-closed by default; `AbortController` enforces `timeoutMs`. | Choose `failOpen` deliberately; pick a sane `timeoutMs`. | +| 2 | **Spoofed DID** — recipient claims a DID it doesn't control | Out of scope: adapter checks reputation, not control of the key. | Bind the DID to the `payTo` address / verify control before trusting. | +| 3 | **Stale verdict** — score lags very recent bad behavior | Each call is live (no caching here). | If you cache, bound the TTL; don't reuse a verdict across sessions. | +| 4 | **Endpoint MITM / response tampering** | HTTPS to a pinned host (`agent.auraopenprotocol.org`). Verdict strings validated against a fixed allow-list; unknown values collapse to `unknown`. | Don't point `baseUrl` at an untrusted mirror. | +| 5 | **Score gaming / Sybil** — cheap DIDs farming a `trusted` score | Inherited from AURA's on-chain cost + dispute dimension; not solvable in the adapter. | Weight `dimensions` (e.g. require non-trivial history) for high-value settlements rather than trusting the aggregate alone. | +| 6 | **Over-trust** — using the verdict as sole gate for an irreversible payment | `new`/`unknown` rejected by default; `dimensions` exposed. | Combine with PayBot limits + escrow + manual review for high-value flows. | + +## Data handled + +- **Sent:** only the counterparty DID, as a query parameter to `/check`. No + PII, no payment payload, no secrets, no keys. +- **Stored:** nothing. The adapter is stateless. +- **Received:** the public `/check` JSON body. Surfaced verbatim on `.raw`. + +## Trust boundary summary + +``` +PayBot host --(DID only, HTTPS GET)--> AURA /check --> verdict + | | + | PayBot limits / TRUST_VIOLATION (separate, yours) | + v v + settle decision (auditable, your code) +``` + +The adapter sits on the read-only reputation edge. Signing, USDC movement, and +the final settle decision stay in PayBot, where they can be audited. diff --git a/integrations/aura/adapter.test.ts b/integrations/aura/adapter.test.ts new file mode 100644 index 0000000..a4327f9 --- /dev/null +++ b/integrations/aura/adapter.test.ts @@ -0,0 +1,147 @@ +/** + * Offline tests for the AURA trust-check adapter (vitest). + * + * No network: every call replays a recorded /check body via the `fetchImpl` + * injection seam. Run with `vitest run`. + * + * Coverage: one assertion per verdict class, the beforeSettle gate + * (allow-list pass/reject, custom allow, failOpen), the network-failure path + * (fail-closed by default), and input validation. + */ + +import { describe, it, expect } from 'vitest'; +import { + auraVerdict, + beforeSettle, + AuraUntrusted, + type FetchLike, +} from './adapter.js'; + +// ── recorded /check bodies, one per verdict class ──────────────────────────── +const RECORDED: Record> = { + 'did:aura:trusted-bot': { + did: 'did:aura:trusted-bot', verdict: 'trusted', + reason: 'strong on-chain track record (composite 0.86)', + has_history: true, score: 0.86, interactions: 142, + dimensions: { financial_integrity: 0.95, task_completion: 0.92 }, + }, + 'did:aura:caution-bot': { + did: 'did:aura:caution-bot', verdict: 'caution', + reason: 'mixed history (composite 0.55)', has_history: true, score: 0.55, + }, + 'did:aura:risky-bot': { + did: 'did:aura:risky-bot', verdict: 'high_risk', + reason: 'poor track record (composite 0.22)', has_history: true, score: 0.22, + dimensions: { financial_integrity: 0.12 }, + }, + 'did:aura:fresh-bot': { + did: 'did:aura:fresh-bot', verdict: 'new', + reason: 'registered identity, no interactions yet', has_history: false, score: null, + }, + 'did:aura:ghost-bot': { + did: 'did:aura:ghost-bot', verdict: 'unknown', + reason: 'no track record — unverified counterparty', has_history: false, score: null, + }, +}; + +const okFetch: FetchLike = async (url) => { + const did = new URL(url).searchParams.get('did') ?? ''; + const body = RECORDED[did] ?? RECORDED['did:aura:ghost-bot']; + return { ok: true, status: 200, json: async () => body }; +}; + +const failFetch: FetchLike = async () => { + throw new Error('connection refused'); +}; + +// ── verdict classes ────────────────────────────────────────────────────────── +describe('verdict classes', () => { + const cases: [string, string, boolean][] = [ + ['did:aura:trusted-bot', 'trusted', true], + ['did:aura:caution-bot', 'caution', true], + ['did:aura:risky-bot', 'high_risk', false], + ['did:aura:fresh-bot', 'new', false], + ['did:aura:ghost-bot', 'unknown', false], + ]; + it.each(cases)('%s -> %s', async (did, expected, ok) => { + const v = await auraVerdict(did, { fetchImpl: okFetch }); + expect(v.verdict).toBe(expected); + expect(v.ok).toBe(ok); + expect(v.did).toBe(did); + expect(v.reason.length).toBeGreaterThan(0); + }); + + it('exposes dimensions for agents with history', async () => { + const v = await auraVerdict('did:aura:risky-bot', { fetchImpl: okFetch }); + expect(v.hasHistory).toBe(true); + expect(v.dimensions?.financial_integrity).toBe(0.12); + }); + + it('new agent has null score', async () => { + const v = await auraVerdict('did:aura:fresh-bot', { fetchImpl: okFetch }); + expect(v.score).toBeNull(); + expect(v.hasHistory).toBe(false); + }); +}); + +// ── the beforeSettle gate ───────────────────────────────────────────────────── +describe('beforeSettle gate', () => { + it('allows trusted / caution / new by default', async () => { + expect((await beforeSettle('did:aura:trusted-bot', { fetchImpl: okFetch })).verdict).toBe('trusted'); + expect((await beforeSettle('did:aura:caution-bot', { fetchImpl: okFetch })).verdict).toBe('caution'); + expect((await beforeSettle('did:aura:fresh-bot', { fetchImpl: okFetch })).verdict).toBe('new'); + }); + + it('rejects high_risk', async () => { + await expect(beforeSettle('did:aura:risky-bot', { fetchImpl: okFetch })).rejects.toBeInstanceOf(AuraUntrusted); + }); + + it('rejects unknown by default', async () => { + await expect(beforeSettle('did:aura:ghost-bot', { fetchImpl: okFetch })).rejects.toBeInstanceOf(AuraUntrusted); + }); + + it('strict allow rejects new', async () => { + await expect( + beforeSettle('did:aura:fresh-bot', { allow: ['trusted', 'caution'], fetchImpl: okFetch }), + ).rejects.toBeInstanceOf(AuraUntrusted); + }); +}); + +// ── network-failure path ─────────────────────────────────────────────────────── +describe('network failure', () => { + it('auraVerdict returns unknown, does not throw', async () => { + const v = await auraVerdict('did:aura:trusted-bot', { fetchImpl: failFetch }); + expect(v.verdict).toBe('unknown'); + expect(v.reason.toLowerCase()).toContain('unreachable'); + }); + + it('gate is fail-closed by default on unreachable', async () => { + await expect(beforeSettle('did:aura:trusted-bot', { fetchImpl: failFetch })).rejects.toBeInstanceOf(AuraUntrusted); + }); + + it('gate passes on unreachable when failOpen', async () => { + const v = await beforeSettle('did:aura:trusted-bot', { failOpen: true, fetchImpl: failFetch }); + expect(v.verdict).toBe('unknown'); + expect(v.reachable).toBe(false); + }); + + it('failOpen does NOT pass a reachable unknown (ghost DID)', async () => { + // A reachable AURA that returns `unknown` is still rejected even with + // failOpen — failOpen only excuses transport failures. + await expect( + beforeSettle('did:aura:ghost-bot', { failOpen: true, fetchImpl: okFetch }), + ).rejects.toBeInstanceOf(AuraUntrusted); + }); + + it('reachable verdict is marked reachable', async () => { + const v = await auraVerdict('did:aura:ghost-bot', { fetchImpl: okFetch }); + expect(v.reachable).toBe(true); + }); +}); + +// ── input validation ──────────────────────────────────────────────────────────── +describe('input validation', () => { + it.each(['', 'not-a-did', 'z6Mk-no-prefix'])('rejects bad DID %s', async (bad) => { + await expect(auraVerdict(bad, { fetchImpl: okFetch })).rejects.toThrow(); + }); +}); diff --git a/integrations/aura/adapter.ts b/integrations/aura/adapter.ts new file mode 100644 index 0000000..756287b --- /dev/null +++ b/integrations/aura/adapter.ts @@ -0,0 +1,165 @@ +/** + * AURA trust-check adapter — a zero-dependency, read-only reputation lookup. + * + * Drop this folder into any agent/payment project to gate a settlement behind + * a backward-looking trust verdict for the *counterparty* agent. It does NOT + * sign, hold keys, move funds, or touch your wallet. It makes one HTTPS GET + * and returns a verdict. + * + * Design boundary (intentional): + * - read-only: the only network call is GET /check?did=... + * - no auth: /check is a public endpoint; no API key, no secret + * - no coupling: uses global fetch (Node 18+ / ESM). No third-party imports. + * - fail-closed: on network failure the verdict is `unknown`, and the default + * gate (beforeSettle) rejects `unknown` — an unreachable AURA + * never silently waves a counterparty through. Set + * `failOpen: true` to invert that. + */ + +export const DEFAULT_BASE_URL = 'https://agent.auraopenprotocol.org'; +export const DEFAULT_TIMEOUT_MS = 8000; + +/** Verdicts safe to proceed with by default — rejects high_risk + unknown. */ +export const DEFAULT_ALLOW = ['trusted', 'caution', 'new'] as const; + +export type Verdict = 'trusted' | 'caution' | 'high_risk' | 'new' | 'unknown'; +const VERDICTS: readonly Verdict[] = ['trusted', 'caution', 'high_risk', 'new', 'unknown']; + +export interface AuraVerdict { + /** the DID that was checked */ + did: string; + /** trusted | caution | high_risk | new | unknown */ + verdict: Verdict; + /** human-readable explanation */ + reason: string; + /** composite 0..1, or null when there is no history */ + score: number | null; + /** True for verdicts safe to proceed with (trusted / caution) */ + ok: boolean; + /** True once the agent has on-chain interactions */ + hasHistory: boolean; + /** per-dimension breakdown (which axis is weak), or null */ + dimensions: Record | null; + /** + * False only when AURA could not be reached (network/parse failure) and the + * verdict is a synthetic `unknown`. A reachable AURA that genuinely returns + * `unknown` has `reachable: true`. `failOpen` keys on this, not on the + * verdict alone, so it can't wave through explicitly-unverified counterparties. + */ + reachable: boolean; + /** the untouched JSON body */ + raw: Record; +} + +/** Thrown by beforeSettle() when a counterparty fails the trust gate. */ +export class AuraUntrusted extends Error { + readonly verdict: AuraVerdict; + constructor(verdict: AuraVerdict) { + super(`trust gate rejected ${verdict.did}: ${verdict.verdict} — ${verdict.reason}`); + this.name = 'AuraUntrusted'; + this.verdict = verdict; + } +} + +/** Injection seam: a fetch-compatible function. Defaults to global fetch. */ +export type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; +}>; + +export interface VerdictOptions { + baseUrl?: string; + timeoutMs?: number; + /** Override for tests; production callers ignore it. */ + fetchImpl?: FetchLike; +} + +export interface GateOptions extends VerdictOptions { + allow?: readonly string[]; + /** Treat an unreachable AURA as a pass. Off by default. */ + failOpen?: boolean; +} + +function toVerdict(did: string, body: Record): AuraVerdict { + let verdict = String(body.verdict ?? 'unknown') as Verdict; + if (!VERDICTS.includes(verdict)) verdict = 'unknown'; + return { + did: typeof body.did === 'string' ? body.did : did, + verdict, + reason: String(body.reason ?? ''), + score: typeof body.score === 'number' ? body.score : null, + ok: verdict === 'trusted' || verdict === 'caution', + hasHistory: Boolean(body.has_history), + dimensions: (body.dimensions as Record | null) ?? null, + reachable: true, + raw: body, + }; +} + +function unreachable(did: string, reason: string): AuraVerdict { + return { did, verdict: 'unknown', reason, score: null, ok: false, hasHistory: false, dimensions: null, reachable: false, raw: {} }; +} + +/** + * Look up the trust verdict for a counterparty DID. Never rejects on a + * network/parse failure — resolves to an `unknown` verdict instead, leaving + * the proceed/abort decision to the caller's policy (see beforeSettle). + * + * const v = await auraVerdict('did:aura:z6Mk...'); + * console.log(v.verdict, v.reason, v.score); + */ +export async function auraVerdict(did: string, opts: VerdictOptions = {}): Promise { + if (!did || !did.startsWith('did:')) { + throw new Error(`invalid DID: ${JSON.stringify(did)} (must start with 'did:')`); + } + const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + const url = `${baseUrl}/check?did=${encodeURIComponent(did)}`; + const doFetch = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + try { + const resp = await doFetch(url, { signal: controller.signal }); + if (!resp.ok) return unreachable(did, `AURA returned HTTP ${resp.status}`); + const body = (await resp.json()) as Record; + if (typeof body !== 'object' || body === null) { + return unreachable(did, 'AURA returned an unexpected shape'); + } + return toVerdict(did, body); + } catch (e) { + return unreachable(did, `AURA unreachable: ${(e as Error).message}`); + } finally { + clearTimeout(timer); + } +} + +/** + * Gate a settlement behind a trust check. Resolves with the verdict on pass, + * throws AuraUntrusted on fail. + * + * try { + * await beforeSettle(counterpartyDid); // rejects high_risk + unknown + * await client.pay({ resource, amount, payTo }); + * } catch (e) { + * if (e instanceof AuraUntrusted) abort(e.message); + * } + * + * Tighten to reject brand-new agents too: + * await beforeSettle(did, { allow: ['trusted', 'caution'] }); + * + * failOpen: true makes an *unreachable* AURA pass through (transport failure + * only — a reachable AURA that returns `unknown` is still rejected). Off by + * default — absence of evidence is not evidence of trust. + */ +export async function beforeSettle(did: string, opts: GateOptions = {}): Promise { + const allow = opts.allow ?? DEFAULT_ALLOW; + const v = await auraVerdict(did, opts); + if (allow.includes(v.verdict)) return v; + // failOpen only excuses a transport failure, never a reachable `unknown`. + if (opts.failOpen && !v.reachable) return v; + throw new AuraUntrusted(v); +} + +/** Alias — same gate, name that reads better at non-payment call sites. */ +export const requireTrust = beforeSettle; diff --git a/integrations/aura/index.ts b/integrations/aura/index.ts new file mode 100644 index 0000000..a622e23 --- /dev/null +++ b/integrations/aura/index.ts @@ -0,0 +1,25 @@ +/** + * AURA trust-check adapter — opt-in, read-only counterparty reputation. + * + * import { beforeSettle, AuraUntrusted } from './integrations/aura'; + * + * try { + * await beforeSettle(counterpartyDid); + * await client.pay({ resource, amount, payTo }); + * } catch (e) { + * if (e instanceof AuraUntrusted) abort(e.message); + * } + * + * Zero dependencies (global fetch). Does not sign, hold keys, or move funds. + * See README.md for the enable section and THREAT_MODEL.md for the boundary. + */ +export { + auraVerdict, + beforeSettle, + requireTrust, + AuraUntrusted, + DEFAULT_BASE_URL, + DEFAULT_ALLOW, +} from './adapter.js'; + +export type { AuraVerdict, Verdict, VerdictOptions, GateOptions, FetchLike } from './adapter.js'; diff --git a/integrations/aura/vitest.config.ts b/integrations/aura/vitest.config.ts new file mode 100644 index 0000000..d2e4a0d --- /dev/null +++ b/integrations/aura/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Standalone config so the adapter's tests run independently of the monorepo +// (the root config only globs packages/*/src). Used for local verification; +// the target repo runs these under its own vitest setup. +export default defineConfig({ + test: { + include: ['**/*.test.ts'], + environment: 'node', + }, +});