@aroha-sdk/core 1.0.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/package.json +35 -0
- package/src/conformance/l0-did-identity.conformance.test.ts +183 -0
- package/src/conformance/l1-signed-envelopes.conformance.test.ts +334 -0
- package/src/crypto/encryption.test.ts +45 -0
- package/src/crypto/encryption.ts +160 -0
- package/src/crypto/index.ts +2 -0
- package/src/crypto/signing.test.ts +79 -0
- package/src/crypto/signing.ts +104 -0
- package/src/identity/credentials.ts +164 -0
- package/src/identity/did-cache.ts +113 -0
- package/src/identity/did.test.ts +232 -0
- package/src/identity/did.ts +426 -0
- package/src/identity/index.ts +4 -0
- package/src/identity/web-did.ts +427 -0
- package/src/index.ts +14 -0
- package/src/messages/envelope.test.ts +289 -0
- package/src/messages/envelope.ts +232 -0
- package/src/messages/idempotency.ts +142 -0
- package/src/messages/index.ts +4 -0
- package/src/messages/nonce.test.ts +92 -0
- package/src/messages/nonce.ts +116 -0
- package/src/messages/types.ts +357 -0
- package/src/transport/client.ts +236 -0
- package/src/transport/http-utils.ts +30 -0
- package/src/transport/index.ts +3 -0
- package/src/transport/server.ts +383 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { NonceRegistry, MapNonceStore, type NonceStore } from "./nonce.js";
|
|
3
|
+
|
|
4
|
+
describe("MapNonceStore (default)", () => {
|
|
5
|
+
it("accepts a fresh nonce", () => {
|
|
6
|
+
const r = new NonceRegistry();
|
|
7
|
+
const exp = new Date(Date.now() + 60_000).toISOString();
|
|
8
|
+
expect(r.check("abc", exp)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects a replayed nonce", () => {
|
|
12
|
+
const r = new NonceRegistry();
|
|
13
|
+
const exp = new Date(Date.now() + 60_000).toISOString();
|
|
14
|
+
r.check("abc", exp);
|
|
15
|
+
expect(r.check("abc", exp)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("evicts expired nonces", async () => {
|
|
19
|
+
const r = new NonceRegistry(100);
|
|
20
|
+
const exp = new Date(Date.now() - 1).toISOString(); // already expired
|
|
21
|
+
r.check("stale", exp);
|
|
22
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
23
|
+
expect(r.size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts a fresh nonce after expired one is evicted", async () => {
|
|
27
|
+
const r = new NonceRegistry(100);
|
|
28
|
+
const expiredAt = new Date(Date.now() - 1).toISOString();
|
|
29
|
+
r.check("stale", expiredAt);
|
|
30
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
31
|
+
const freshExp = new Date(Date.now() + 60_000).toISOString();
|
|
32
|
+
expect(r.check("stale", freshExp)).toBe(true); // evicted, so fresh again
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("custom NonceStore injection", () => {
|
|
37
|
+
it("delegates check to custom store", async () => {
|
|
38
|
+
const calls: string[] = [];
|
|
39
|
+
const store: NonceStore = {
|
|
40
|
+
async setIfAbsent(nonce, _expiryMs) {
|
|
41
|
+
calls.push(nonce);
|
|
42
|
+
return !calls.slice(0, -1).includes(nonce);
|
|
43
|
+
},
|
|
44
|
+
async evictExpired() {},
|
|
45
|
+
};
|
|
46
|
+
const r = new NonceRegistry(60_000, store);
|
|
47
|
+
const exp = new Date(Date.now() + 60_000).toISOString();
|
|
48
|
+
expect(await r.checkAsync("n1", exp)).toBe(true);
|
|
49
|
+
expect(await r.checkAsync("n1", exp)).toBe(false);
|
|
50
|
+
expect(calls).toEqual(["n1", "n1"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("NonceRegistry async path — injected store usage", () => {
|
|
55
|
+
it("calls the injected store, not the in-process map", async () => {
|
|
56
|
+
const calls: string[] = [];
|
|
57
|
+
const mockStore: NonceStore = {
|
|
58
|
+
setIfAbsent: async (nonce) => {
|
|
59
|
+
calls.push(nonce);
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
evictExpired: async () => {},
|
|
63
|
+
};
|
|
64
|
+
const registry = new NonceRegistry(60_000, mockStore);
|
|
65
|
+
const fresh = await registry.checkAsync(
|
|
66
|
+
"abc123",
|
|
67
|
+
new Date(Date.now() + 60_000).toISOString()
|
|
68
|
+
);
|
|
69
|
+
registry.destroy();
|
|
70
|
+
expect(fresh).toBe(true);
|
|
71
|
+
expect(calls).toContain("abc123");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns false for a replayed nonce via injected store", async () => {
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
const mockStore: NonceStore = {
|
|
77
|
+
setIfAbsent: async (nonce) => {
|
|
78
|
+
if (seen.has(nonce)) return false;
|
|
79
|
+
seen.add(nonce);
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
evictExpired: async () => {},
|
|
83
|
+
};
|
|
84
|
+
const registry = new NonceRegistry(60_000, mockStore);
|
|
85
|
+
const expires = new Date(Date.now() + 60_000).toISOString();
|
|
86
|
+
const first = await registry.checkAsync("replay-me", expires);
|
|
87
|
+
const second = await registry.checkAsync("replay-me", expires);
|
|
88
|
+
registry.destroy();
|
|
89
|
+
expect(first).toBe(true);
|
|
90
|
+
expect(second).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aroha Protocol — Nonce Registry
|
|
3
|
+
*
|
|
4
|
+
* Implements the spec rule:
|
|
5
|
+
* "A receiving agent MUST reject any message where nonce has been seen before
|
|
6
|
+
* (replay attack)"
|
|
7
|
+
*
|
|
8
|
+
* Nonces are stored with a TTL equal to the message's expires window.
|
|
9
|
+
* After a nonce expires it is automatically evicted to bound memory usage.
|
|
10
|
+
*
|
|
11
|
+
* This is protocol infrastructure. It has no knowledge of message contents.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── NonceStore interface ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pluggable nonce deduplication backend.
|
|
18
|
+
* Default: MapNonceStore (in-process). Production: inject a Redis adapter.
|
|
19
|
+
*/
|
|
20
|
+
export interface NonceStore {
|
|
21
|
+
/**
|
|
22
|
+
* Record a nonce and return true if it is fresh (first seen).
|
|
23
|
+
* Returns false if the nonce was already recorded (replay).
|
|
24
|
+
* expiryMs is the absolute expiry timestamp in milliseconds.
|
|
25
|
+
*/
|
|
26
|
+
setIfAbsent(nonce: string, expiryMs: number): Promise<boolean>;
|
|
27
|
+
evictExpired(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Default in-process store ─────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export class MapNonceStore implements NonceStore {
|
|
33
|
+
private readonly seen = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
async setIfAbsent(nonce: string, expiryMs: number): Promise<boolean> {
|
|
36
|
+
if (this.seen.has(nonce)) return false;
|
|
37
|
+
this.seen.set(nonce, expiryMs);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async evictExpired(): Promise<void> {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const [nonce, expiryMs] of this.seen) {
|
|
44
|
+
if (expiryMs < now) this.seen.delete(nonce);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get size(): number { return this.seen.size; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Registry ─────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export class NonceRegistry {
|
|
54
|
+
private readonly store: NonceStore;
|
|
55
|
+
private readonly cleanupTimer: ReturnType<typeof setInterval>;
|
|
56
|
+
// Synchronous seen map for backward-compat check()
|
|
57
|
+
private readonly syncSeen = new Map<string, number>();
|
|
58
|
+
|
|
59
|
+
constructor(cleanupIntervalMs = 60_000, store?: NonceStore) {
|
|
60
|
+
this.store = store ?? new MapNonceStore();
|
|
61
|
+
this.cleanupTimer = setInterval(() => {
|
|
62
|
+
this.store.evictExpired().catch(() => {});
|
|
63
|
+
this.evictSyncSeen();
|
|
64
|
+
}, cleanupIntervalMs);
|
|
65
|
+
// Allow the process to exit even if this registry is still alive.
|
|
66
|
+
if (typeof this.cleanupTimer.unref === "function") this.cleanupTimer.unref();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Synchronous check — uses a dedicated in-process map.
|
|
71
|
+
* Use checkAsync when injecting a custom store.
|
|
72
|
+
*
|
|
73
|
+
* @param nonce The nonce string from the message envelope
|
|
74
|
+
* @param expiresAt ISO8601 expiry time from the message's "expires" field
|
|
75
|
+
* @returns true if the nonce is fresh (first time seen), false if replay
|
|
76
|
+
*/
|
|
77
|
+
check(nonce: string, expiresAt: string): boolean {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
// Evict expired entries
|
|
80
|
+
for (const [n, exp] of this.syncSeen) {
|
|
81
|
+
if (exp < now) this.syncSeen.delete(n);
|
|
82
|
+
}
|
|
83
|
+
if (this.syncSeen.has(nonce)) return false;
|
|
84
|
+
this.syncSeen.set(nonce, new Date(expiresAt).getTime());
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Async check — works with any NonceStore, including custom backends.
|
|
90
|
+
*
|
|
91
|
+
* @param nonce The nonce string from the message envelope
|
|
92
|
+
* @param expiresAt ISO8601 expiry time from the message's "expires" field
|
|
93
|
+
* @returns true if the nonce is fresh (first time seen), false if replay
|
|
94
|
+
*/
|
|
95
|
+
async checkAsync(nonce: string, expiresAt: string): Promise<boolean> {
|
|
96
|
+
const expiryMs = new Date(expiresAt).getTime();
|
|
97
|
+
return this.store.setIfAbsent(nonce, expiryMs);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Number of nonces currently tracked (sync store). */
|
|
101
|
+
get size(): number {
|
|
102
|
+
return this.syncSeen.size;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stop the background cleanup timer. Call when the owning server shuts down. */
|
|
106
|
+
destroy(): void {
|
|
107
|
+
clearInterval(this.cleanupTimer);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private evictSyncSeen(): void {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
for (const [n, exp] of this.syncSeen) {
|
|
113
|
+
if (exp < now) this.syncSeen.delete(n);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aroha Protocol — Layer 3 Message Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* All message types defined by the Aroha spec (aroha/1.0).
|
|
5
|
+
* These are the ONLY valid values for the "type" field in a Aroha envelope.
|
|
6
|
+
*
|
|
7
|
+
* Applications may not invent new top-level message types — they extend
|
|
8
|
+
* behavior through the "body" field only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Credential Token (Layer 1 extension — carried in message body) ──────────
|
|
12
|
+
//
|
|
13
|
+
// Human-triggered messages attach a base64url HumanCredential token here.
|
|
14
|
+
// Provider agents verify the token against the issuer's public key.
|
|
15
|
+
// Agent-to-agent messages omit this field; roles are resolved from the DID.
|
|
16
|
+
|
|
17
|
+
export interface WithCredential {
|
|
18
|
+
/**
|
|
19
|
+
* Registry-based auth: the credential ID returned on registration.
|
|
20
|
+
* The receiving agent resolves the full credential from the registry.
|
|
21
|
+
* Preferred over credentialToken for repeated calls — shorter and revocable.
|
|
22
|
+
*/
|
|
23
|
+
credentialId?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Inline auth: base64url-encoded signed HumanCredential.
|
|
26
|
+
* Used when no shared registry is available (e.g. first call or direct peer).
|
|
27
|
+
* If credentialId is also present, credentialId takes precedence.
|
|
28
|
+
*/
|
|
29
|
+
credentialToken?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Preference Context (Layer 5, carried in message body) ───────────────────
|
|
33
|
+
|
|
34
|
+
export interface PreferenceContext {
|
|
35
|
+
budget?: { max: number; currency: string };
|
|
36
|
+
quality?: { minRating?: number };
|
|
37
|
+
accessibility?: Record<string, boolean | string>;
|
|
38
|
+
constraints?: string[];
|
|
39
|
+
/** Signed VC granting the recipient access to read this context */
|
|
40
|
+
consentToken?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Message Body Definitions ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface ArohaRequestBody extends WithCredential {
|
|
46
|
+
capability: string;
|
|
47
|
+
params: Record<string, unknown>;
|
|
48
|
+
preferenceContext?: PreferenceContext;
|
|
49
|
+
/**
|
|
50
|
+
* Idempotency key — if set, the receiving agent deduplicates requests with
|
|
51
|
+
* the same key from the same sender within a 24-hour window.
|
|
52
|
+
* Prevents duplicate side effects on retried requests (e.g. double-booking).
|
|
53
|
+
* Use a UUID generated once per logical operation, not per retry attempt.
|
|
54
|
+
*/
|
|
55
|
+
idempotencyKey?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ArohaResponseBody {
|
|
59
|
+
capability: string;
|
|
60
|
+
result: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ArohaStreamBody {
|
|
64
|
+
capability: string;
|
|
65
|
+
progressPct: number; // 0–100
|
|
66
|
+
event: string;
|
|
67
|
+
data?: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Capability Schema Negotiation (CSN) ─────────────────────────────────────
|
|
71
|
+
// Extends the existing ArohaNegotiate flow with intent-based capability
|
|
72
|
+
// discovery. Structural JSON Schema matching is deterministic and preferred;
|
|
73
|
+
// an LLM matcher is used only when schema matching is ambiguous.
|
|
74
|
+
// Agreed mappings are SHA-256 cached for zero-round-trip reuse.
|
|
75
|
+
|
|
76
|
+
export interface CapabilitySchemaShape {
|
|
77
|
+
/** JSON Schema for the capability's input body (params field). */
|
|
78
|
+
input: Record<string, unknown>;
|
|
79
|
+
/** JSON Schema for the capability's expected output (result field). */
|
|
80
|
+
output?: Record<string, unknown>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CSNNegotiateExtension {
|
|
84
|
+
/**
|
|
85
|
+
* Natural-language description of what the requestor needs.
|
|
86
|
+
* The provider uses this as a hint when structural schema matching is
|
|
87
|
+
* ambiguous. Absent means "use schema-only matching".
|
|
88
|
+
*/
|
|
89
|
+
capabilityIntent?: string;
|
|
90
|
+
/**
|
|
91
|
+
* JSON Schema of the requestor's expected interface.
|
|
92
|
+
* The provider runs structural compatibility matching against its registry.
|
|
93
|
+
* If a high-confidence match is found, no LLM call is needed.
|
|
94
|
+
*/
|
|
95
|
+
desiredSchema?: CapabilitySchemaShape;
|
|
96
|
+
/**
|
|
97
|
+
* SHA-256 hex of the canonical desiredSchema JSON.
|
|
98
|
+
* The provider checks its cache first — if a mapping exists for this hash
|
|
99
|
+
* and this sender DID, the negotiation resolves in < 1ms without LLM.
|
|
100
|
+
*/
|
|
101
|
+
schemaHash?: string;
|
|
102
|
+
/**
|
|
103
|
+
* If true, the provider returns its full signed capability manifest
|
|
104
|
+
* in the ArohaAccept body. The requestor caches it for future calls.
|
|
105
|
+
*/
|
|
106
|
+
requestCapabilityManifest?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* When true, the provider MUST NOT use LLM-based fuzzy matching.
|
|
109
|
+
* If structural/cache match fails, return ArohaError with code CSN_NO_STRUCTURAL_MATCH.
|
|
110
|
+
*/
|
|
111
|
+
disallowLLMFallback?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CapabilityManifestEntry {
|
|
115
|
+
name: string;
|
|
116
|
+
description: string;
|
|
117
|
+
inputSchema: Record<string, unknown>;
|
|
118
|
+
outputSchema?: Record<string, unknown>;
|
|
119
|
+
supportsSaga: boolean;
|
|
120
|
+
defaultPricing?: { model: "per-call" | "subscription" | "negotiated"; amount?: number; currency?: string };
|
|
121
|
+
defaultSLA?: { maxResponseMs: number; p99Ms?: number };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SignedCapabilityManifest {
|
|
125
|
+
agentDID: string;
|
|
126
|
+
capabilities: CapabilityManifestEntry[];
|
|
127
|
+
generatedAt: string;
|
|
128
|
+
/** Ed25519 signature over the canonical manifest JSON. */
|
|
129
|
+
signature: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ArohaNegotiateBody {
|
|
133
|
+
capability: string;
|
|
134
|
+
proposedTerms: {
|
|
135
|
+
price?: { amount: number; currency: string };
|
|
136
|
+
sla?: { maxResponseMs: number; uptime: number };
|
|
137
|
+
validForMs?: number;
|
|
138
|
+
[key: string]: unknown;
|
|
139
|
+
};
|
|
140
|
+
/** CSN extension — omit for traditional price/SLA-only negotiation. */
|
|
141
|
+
csn?: CSNNegotiateExtension;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CSNAcceptExtension {
|
|
145
|
+
/**
|
|
146
|
+
* The actual registered capability name that matched the intent/schema.
|
|
147
|
+
* May differ from the `capability` field when CSN resolves an alias.
|
|
148
|
+
*/
|
|
149
|
+
resolvedCapability?: string;
|
|
150
|
+
/** The schema the provider agreed to serve. Stored by requestor for caching. */
|
|
151
|
+
agreedSchema?: CapabilitySchemaShape;
|
|
152
|
+
/** Echo of the requestor's schemaHash — allows cache population. */
|
|
153
|
+
schemaHash?: string;
|
|
154
|
+
/** Returned when requestCapabilityManifest was true. */
|
|
155
|
+
capabilityManifest?: SignedCapabilityManifest;
|
|
156
|
+
/**
|
|
157
|
+
* How the capability was matched:
|
|
158
|
+
* "schema" — deterministic structural match (preferred, no LLM)
|
|
159
|
+
* "intent" — LLM-assisted match (less certain, human approval recommended)
|
|
160
|
+
* "cache" — recovered from SHA-256 negotiation cache (zero-latency)
|
|
161
|
+
*/
|
|
162
|
+
matchMethod?: "schema" | "intent" | "cache";
|
|
163
|
+
/** Structural compatibility score (0–1) when matchMethod is "schema". */
|
|
164
|
+
compatibilityScore?: number;
|
|
165
|
+
/**
|
|
166
|
+
* Audit trail of how the match was resolved.
|
|
167
|
+
* Consumers can use this to tune caching or flag unexpected LLM usage.
|
|
168
|
+
*/
|
|
169
|
+
matchAudit?: {
|
|
170
|
+
structuralScore?: number;
|
|
171
|
+
cacheHit?: boolean;
|
|
172
|
+
llmUsed: boolean;
|
|
173
|
+
llmModel?: string;
|
|
174
|
+
fallbackReason?: string;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ArohaCounterOfferBody {
|
|
179
|
+
capability: string;
|
|
180
|
+
revisedTerms: ArohaNegotiateBody["proposedTerms"];
|
|
181
|
+
csn?: Pick<CSNAcceptExtension, "resolvedCapability" | "agreedSchema" | "capabilityManifest">;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ArohaAcceptBody {
|
|
185
|
+
capability: string;
|
|
186
|
+
acceptedTerms: ArohaNegotiateBody["proposedTerms"];
|
|
187
|
+
/** CSN extension — present when the accept resolves a capability schema match. */
|
|
188
|
+
csn?: CSNAcceptExtension;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ArohaReserveBody extends WithCredential {
|
|
192
|
+
capability: string;
|
|
193
|
+
params: Record<string, unknown>;
|
|
194
|
+
holdDurationMs: number;
|
|
195
|
+
preferenceContext?: PreferenceContext;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ArohaReserveAckBody {
|
|
199
|
+
reservationToken: string;
|
|
200
|
+
expiresAt: string; // ISO8601
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ArohaCommitBody {
|
|
204
|
+
reservationToken: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface ArohaCommitAckBody {
|
|
208
|
+
reservationToken: string;
|
|
209
|
+
result: Record<string, unknown>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ArohaCancelBody {
|
|
213
|
+
reservationToken: string;
|
|
214
|
+
reason: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface ArohaCancelAckBody {
|
|
218
|
+
reservationToken: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface ArohaDelegateBody extends WithCredential {
|
|
222
|
+
targetDID: string;
|
|
223
|
+
capability: string;
|
|
224
|
+
params: Record<string, unknown>;
|
|
225
|
+
preferenceContext?: PreferenceContext;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface ArohaErrorBody {
|
|
229
|
+
code: ArohaErrorCode;
|
|
230
|
+
message: string;
|
|
231
|
+
retryable: boolean;
|
|
232
|
+
details?: Record<string, unknown>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ArohaSatisfactionSignalBody {
|
|
236
|
+
sagaId: string;
|
|
237
|
+
agentDID: string;
|
|
238
|
+
/** The capability this signal applies to (e.g. "search-flights"). */
|
|
239
|
+
capability: string;
|
|
240
|
+
outcome: "satisfied" | "neutral" | "dissatisfied";
|
|
241
|
+
dimensions: {
|
|
242
|
+
price: "satisfied" | "neutral" | "dissatisfied";
|
|
243
|
+
quality: "satisfied" | "neutral" | "dissatisfied";
|
|
244
|
+
speed: "satisfied" | "neutral" | "dissatisfied";
|
|
245
|
+
};
|
|
246
|
+
/** Whether the saga completed within the provider's stated SLA. */
|
|
247
|
+
withinSLA: boolean;
|
|
248
|
+
/** Whether a compensation (ArohaCancel) was required to recover. */
|
|
249
|
+
requiredCompensation: boolean;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* ArohaSpendingMandate — Layer 1 authorization token for agent payments.
|
|
254
|
+
*
|
|
255
|
+
* Enables agents to spend funds on behalf of a user without ever seeing
|
|
256
|
+
* raw payment credentials. Each hop in the delegation chain can only
|
|
257
|
+
* narrow constraints — never widen them.
|
|
258
|
+
*
|
|
259
|
+
* Mandate chain: User (intent) → Personal Agent (cart) → Provider (payment).
|
|
260
|
+
*
|
|
261
|
+
* References:
|
|
262
|
+
* AP2 Protocol (Google) — Intent/Cart/Payment Mandate pattern.
|
|
263
|
+
* IETF draft-niyikiza-oauth-attenuating-agent-tokens — monotonic scope narrowing.
|
|
264
|
+
*/
|
|
265
|
+
export interface ArohaSpendingMandateBody {
|
|
266
|
+
mandateTier: "intent" | "cart" | "payment";
|
|
267
|
+
constraints: SpendingConstraints;
|
|
268
|
+
grantee: string; // DID of the agent receiving this mandate
|
|
269
|
+
grantor: string; // DID of the agent issuing this mandate
|
|
270
|
+
expiresAt: string; // ISO8601
|
|
271
|
+
parentMandateId: string | null;
|
|
272
|
+
mandateId: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface SpendingConstraints {
|
|
276
|
+
spendLimitUsd: number;
|
|
277
|
+
sessionLimitUsd?: number;
|
|
278
|
+
currency?: string; // ISO 4217, default "USD"
|
|
279
|
+
merchantCategory?: string | null; // MCC allow-list; null = any
|
|
280
|
+
requireHumanApprovalAboveUsd?: number;
|
|
281
|
+
allowedMerchants?: string[] | null; // DID allow-list; null = any
|
|
282
|
+
validFrom?: string; // ISO8601
|
|
283
|
+
validUntil?: string; // ISO8601
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Error Codes ──────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
export enum ArohaErrorCode {
|
|
289
|
+
// Auth / access errors
|
|
290
|
+
Unauthorized = "Aroha_UNAUTHORIZED", // missing or invalid credential
|
|
291
|
+
Forbidden = "Aroha_FORBIDDEN", // credential valid but role denied
|
|
292
|
+
|
|
293
|
+
// Protocol errors
|
|
294
|
+
InvalidSignature = "Aroha_INVALID_SIGNATURE",
|
|
295
|
+
ExpiredMessage = "Aroha_EXPIRED_MESSAGE",
|
|
296
|
+
ReplayDetected = "Aroha_REPLAY_DETECTED",
|
|
297
|
+
UnknownCapability = "Aroha_UNKNOWN_CAPABILITY",
|
|
298
|
+
TrustLevelInsufficient = "Aroha_TRUST_LEVEL_INSUFFICIENT",
|
|
299
|
+
|
|
300
|
+
// Saga errors
|
|
301
|
+
ReservationFailed = "Aroha_RESERVATION_FAILED",
|
|
302
|
+
ReservationExpired = "Aroha_RESERVATION_EXPIRED",
|
|
303
|
+
CommitFailed = "Aroha_COMMIT_FAILED",
|
|
304
|
+
CancelFailed = "Aroha_CANCEL_FAILED",
|
|
305
|
+
InvalidToken = "Aroha_INVALID_TOKEN",
|
|
306
|
+
|
|
307
|
+
// Availability errors
|
|
308
|
+
NoInventory = "Aroha_NO_INVENTORY",
|
|
309
|
+
CapacityExceeded = "Aroha_CAPACITY_EXCEEDED",
|
|
310
|
+
ServiceUnavailable = "Aroha_SERVICE_UNAVAILABLE",
|
|
311
|
+
|
|
312
|
+
// Generic
|
|
313
|
+
InternalError = "Aroha_INTERNAL_ERROR",
|
|
314
|
+
InvalidParams = "Aroha_INVALID_PARAMS",
|
|
315
|
+
|
|
316
|
+
// CSN errors
|
|
317
|
+
CSN_NO_STRUCTURAL_MATCH = "CSN_NO_STRUCTURAL_MATCH",
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Message Type Union ───────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
export type ArohaMessageType =
|
|
323
|
+
| "ArohaRequest"
|
|
324
|
+
| "ArohaResponse"
|
|
325
|
+
| "ArohaStream"
|
|
326
|
+
| "ArohaNegotiate"
|
|
327
|
+
| "ArohaCounterOffer"
|
|
328
|
+
| "ArohaAccept"
|
|
329
|
+
| "ArohaReserve"
|
|
330
|
+
| "ArohaReserveAck"
|
|
331
|
+
| "ArohaCommit"
|
|
332
|
+
| "ArohaCommitAck"
|
|
333
|
+
| "ArohaCancel"
|
|
334
|
+
| "ArohaCancelAck"
|
|
335
|
+
| "ArohaDelegate"
|
|
336
|
+
| "ArohaError"
|
|
337
|
+
| "ArohaSatisfactionSignal"
|
|
338
|
+
| "ArohaSpendingMandate";
|
|
339
|
+
|
|
340
|
+
export type ArohaBodyByType = {
|
|
341
|
+
ArohaRequest: ArohaRequestBody;
|
|
342
|
+
ArohaResponse: ArohaResponseBody;
|
|
343
|
+
ArohaStream: ArohaStreamBody;
|
|
344
|
+
ArohaNegotiate: ArohaNegotiateBody;
|
|
345
|
+
ArohaCounterOffer: ArohaCounterOfferBody;
|
|
346
|
+
ArohaAccept: ArohaAcceptBody;
|
|
347
|
+
ArohaReserve: ArohaReserveBody;
|
|
348
|
+
ArohaReserveAck: ArohaReserveAckBody;
|
|
349
|
+
ArohaCommit: ArohaCommitBody;
|
|
350
|
+
ArohaCommitAck: ArohaCommitAckBody;
|
|
351
|
+
ArohaCancel: ArohaCancelBody;
|
|
352
|
+
ArohaCancelAck: ArohaCancelAckBody;
|
|
353
|
+
ArohaDelegate: ArohaDelegateBody;
|
|
354
|
+
ArohaError: ArohaErrorBody;
|
|
355
|
+
ArohaSatisfactionSignal: ArohaSatisfactionSignalBody;
|
|
356
|
+
ArohaSpendingMandate: ArohaSpendingMandateBody;
|
|
357
|
+
};
|