@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.
@@ -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
+ };