@cello-protocol/client 0.0.2
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/dist/agent-hash-queue.d.ts +206 -0
- package/dist/agent-hash-queue.d.ts.map +1 -0
- package/dist/agent-hash-queue.js +380 -0
- package/dist/agent-hash-queue.js.map +1 -0
- package/dist/backup-key-derivation.d.ts +37 -0
- package/dist/backup-key-derivation.d.ts.map +1 -0
- package/dist/backup-key-derivation.js +48 -0
- package/dist/backup-key-derivation.js.map +1 -0
- package/dist/client-backup.d.ts +144 -0
- package/dist/client-backup.d.ts.map +1 -0
- package/dist/client-backup.js +273 -0
- package/dist/client-backup.js.map +1 -0
- package/dist/client.d.ts +249 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +4664 -0
- package/dist/client.js.map +1 -0
- package/dist/connection-policy.d.ts +163 -0
- package/dist/connection-policy.d.ts.map +1 -0
- package/dist/connection-policy.js +248 -0
- package/dist/connection-policy.js.map +1 -0
- package/dist/db-key-derivation.d.ts +26 -0
- package/dist/db-key-derivation.d.ts.map +1 -0
- package/dist/db-key-derivation.js +37 -0
- package/dist/db-key-derivation.js.map +1 -0
- package/dist/encrypted-file-signing-key-provider.d.ts +92 -0
- package/dist/encrypted-file-signing-key-provider.d.ts.map +1 -0
- package/dist/encrypted-file-signing-key-provider.js +251 -0
- package/dist/encrypted-file-signing-key-provider.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.d.ts +270 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +1155 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/network-directory-node.d.ts +85 -0
- package/dist/network-directory-node.d.ts.map +1 -0
- package/dist/network-directory-node.js +584 -0
- package/dist/network-directory-node.js.map +1 -0
- package/dist/s3-cloud-storage-provider.d.ts +54 -0
- package/dist/s3-cloud-storage-provider.d.ts.map +1 -0
- package/dist/s3-cloud-storage-provider.js +78 -0
- package/dist/s3-cloud-storage-provider.js.map +1 -0
- package/dist/sqlcipher-client-store.d.ts +68 -0
- package/dist/sqlcipher-client-store.d.ts.map +1 -0
- package/dist/sqlcipher-client-store.js +382 -0
- package/dist/sqlcipher-client-store.js.map +1 -0
- package/dist/types.d.ts +408 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CELLO-PERSIST-012 — Agent Hash Queue + Signed Relay ACKs
|
|
3
|
+
*
|
|
4
|
+
* ── Pseudocode (Phase P) ──────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The agent hash queue is a first-class protocol primitive. When relay connectivity
|
|
7
|
+
* is interrupted, the P2P conversation continues and hashes accumulate in the queue.
|
|
8
|
+
* On relay recovery or reassignment, queued hashes are submitted in FIFO order.
|
|
9
|
+
*
|
|
10
|
+
* buildSignedAckTbs(hashHex, sequenceNumber, timestamp):
|
|
11
|
+
* // RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
|
|
12
|
+
* // Deterministic TBS for signing and verification. Fixed-width encodings prevent
|
|
13
|
+
* // ambiguity: a 4-byte and an 8-byte integer concatenated to 32-byte hash bytes.
|
|
14
|
+
* hashBytes = Buffer.from(hashHex, 'hex') // 32 bytes
|
|
15
|
+
* seqBuf = 4-byte big-endian uint32 // 4 bytes
|
|
16
|
+
* tsBuf = 8-byte big-endian uint64 // 8 bytes
|
|
17
|
+
* preimage = concat(hashBytes, seqBuf, tsBuf) // 44 bytes
|
|
18
|
+
* return SHA-256(preimage) // 32 bytes
|
|
19
|
+
*
|
|
20
|
+
* verifyRelayAck(hashHex, ack, relayPubkey):
|
|
21
|
+
* // Reconstructs TBS and verifies Ed25519 signature
|
|
22
|
+
* tbs = buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp)
|
|
23
|
+
* if ack.signature.length !== 64: return false
|
|
24
|
+
* return Ed25519.verify(relayPubkey, tbs, ack.signature)
|
|
25
|
+
*
|
|
26
|
+
* AgentHashQueue.enqueue(sessionId, hashHex, correlationId):
|
|
27
|
+
* // Must happen BEFORE relay submission (AC-001)
|
|
28
|
+
* pending = await #loadPending(sessionId)
|
|
29
|
+
* entry = { hashHex, sessionId, enqueuedAt: Date.now() }
|
|
30
|
+
* pending.push(entry)
|
|
31
|
+
* await #savePending(sessionId, pending)
|
|
32
|
+
* logger.info("client.hashqueue.enqueued", { agentId, sessionId, hashHex,
|
|
33
|
+
* queueDepth: pending.length, correlationId })
|
|
34
|
+
*
|
|
35
|
+
* AgentHashQueue.processAck(sessionId, hashHex, ack, lookupRelayPubkey, correlationId):
|
|
36
|
+
* // 1. Verify ACK signature (SI-001: never remove without valid ACK)
|
|
37
|
+
* relayPubkey = await lookupRelayPubkey(ack.relayId)
|
|
38
|
+
* if relayPubkey === null || !verifyRelayAck(hashHex, ack, relayPubkey):
|
|
39
|
+
* logger.warn("client.relay.ack.invalid", { agentId, sessionId, hashHex, reason })
|
|
40
|
+
* return // hash stays in queue
|
|
41
|
+
* // 2. Store ACK as cryptographic receipt (SI-003: immutable, never overwrite)
|
|
42
|
+
* existing = await #loadAck(hashHex)
|
|
43
|
+
* if existing === undefined:
|
|
44
|
+
* await #saveAck(hashHex, ack)
|
|
45
|
+
* // 3. Remove from pending queue (only AFTER ACK is stored)
|
|
46
|
+
* pending = await #loadPending(sessionId)
|
|
47
|
+
* pending = pending.filter(e => e.hashHex !== hashHex)
|
|
48
|
+
* await #savePending(sessionId, pending)
|
|
49
|
+
* logger.info("client.hashqueue.acked", { agentId, sessionId, hashHex,
|
|
50
|
+
* sequenceNumber: ack.sequenceNumber, correlationId })
|
|
51
|
+
*
|
|
52
|
+
* AgentHashQueue.pollDepth():
|
|
53
|
+
* // Called on 30-second interval (externally scheduled)
|
|
54
|
+
* // Logs client.hashqueue.depth when depth > 0 (AC-006)
|
|
55
|
+
* // Also checks for stale entries (DB-001)
|
|
56
|
+
* allPending = await #loadAllPending()
|
|
57
|
+
* depth = total pending count
|
|
58
|
+
* if depth === 0: return
|
|
59
|
+
* logger.info("client.hashqueue.depth", { agentId, depth })
|
|
60
|
+
* // Check staleness
|
|
61
|
+
* oldestEntry = entry with minimum enqueuedAt
|
|
62
|
+
* oldestAge = Date.now() - oldestEntry.enqueuedAt
|
|
63
|
+
* if oldestAge > hashQueueMaxAgeMs:
|
|
64
|
+
* logger.warn("client.hashqueue.stale", { agentId, depth, oldestHashAge: oldestAge })
|
|
65
|
+
*
|
|
66
|
+
* Storage keys:
|
|
67
|
+
* pending queue: "hq:pending:{sessionId}" → JSON PendingHashEntry[]
|
|
68
|
+
* ACK receipt: "hq:ack:{hashHex}" → JSON StoredRelayAck
|
|
69
|
+
* All-sessions index: "hq:sessions" → JSON string[] of sessionIds with queues
|
|
70
|
+
*/
|
|
71
|
+
import type { ClientStore, Logger } from "@cello-protocol/interfaces";
|
|
72
|
+
/** A single entry in the local pending hash queue. */
|
|
73
|
+
export interface PendingHashEntry {
|
|
74
|
+
/** Session this hash belongs to. */
|
|
75
|
+
sessionId: string;
|
|
76
|
+
/** SHA-256 content hash as lowercase hex (32 bytes → 64 chars). */
|
|
77
|
+
hashHex: string;
|
|
78
|
+
/** Unix ms when the entry was enqueued. Used for staleness checks. */
|
|
79
|
+
enqueuedAt: number;
|
|
80
|
+
}
|
|
81
|
+
/** A relay's signed ACK for a submitted hash. Stored as an immutable receipt. */
|
|
82
|
+
export interface RelayAck {
|
|
83
|
+
/** Stable identifier for the signing relay (used for pubkey lookup). */
|
|
84
|
+
relayId: string;
|
|
85
|
+
/** 32-byte Ed25519 public key of the relay that signed this ACK. */
|
|
86
|
+
relayPubkey: Uint8Array;
|
|
87
|
+
/** Relay-assigned sequence number for this hash. */
|
|
88
|
+
sequenceNumber: number;
|
|
89
|
+
/** Unix ms timestamp embedded in the ACK TBS. */
|
|
90
|
+
timestamp: number;
|
|
91
|
+
/** 64-byte Ed25519 signature over SHA-256(hashHex_bytes || seq_BE4 || ts_BE8). */
|
|
92
|
+
signature: Uint8Array;
|
|
93
|
+
}
|
|
94
|
+
/** Sentinel returned by handleResubmitRejection when predecessor relay is unknown. */
|
|
95
|
+
export declare const RELAY_PREDECESSOR_UNKNOWN: "RELAY_PREDECESSOR_UNKNOWN";
|
|
96
|
+
export type RelayPredecessorUnknown = typeof RELAY_PREDECESSOR_UNKNOWN;
|
|
97
|
+
/** Options for constructing an AgentHashQueue. */
|
|
98
|
+
export interface AgentHashQueueOptions {
|
|
99
|
+
/** ClientStore for persisting the queue and ACK receipts. */
|
|
100
|
+
store: ClientStore;
|
|
101
|
+
/** Stable agent identifier — included in all log events. */
|
|
102
|
+
agentId: string;
|
|
103
|
+
/** Structured logger injected at the composition root. */
|
|
104
|
+
logger: Logger;
|
|
105
|
+
/**
|
|
106
|
+
* Maximum age (ms) for a pending hash before DB-001 stale warning fires.
|
|
107
|
+
* Default: 24 * 60 * 60 * 1000 (24 hours).
|
|
108
|
+
*/
|
|
109
|
+
hashQueueMaxAgeMs?: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build the to-be-signed bytes for a relay ACK.
|
|
113
|
+
*
|
|
114
|
+
* Hex-decodes hashHex and delegates to buildRelayAckTbs (the canonical
|
|
115
|
+
* implementation in @cello-protocol/crypto shared with the relay signer).
|
|
116
|
+
*
|
|
117
|
+
* TBS = SHA-256(hash_bytes || seq_BE4 || ts_BE8). RFC 8032, FIPS 180-4.
|
|
118
|
+
*/
|
|
119
|
+
export declare function buildSignedAckTbs(hashHex: string, sequenceNumber: number, timestamp: number): Uint8Array;
|
|
120
|
+
/**
|
|
121
|
+
* Verify a relay ACK signature.
|
|
122
|
+
*
|
|
123
|
+
* Returns true if:
|
|
124
|
+
* - ack.signature is exactly 64 bytes
|
|
125
|
+
* - Ed25519.verify(relayPubkey, buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp), ack.signature) is true
|
|
126
|
+
*
|
|
127
|
+
* RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
|
|
128
|
+
*/
|
|
129
|
+
export declare function verifyRelayAck(hashHex: string, ack: RelayAck, relayPubkey: Uint8Array): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Manages the local queue of Structure 1 hashes pending relay submission.
|
|
132
|
+
*
|
|
133
|
+
* Security invariants:
|
|
134
|
+
* SI-001: A hash is never removed from the pending queue without a valid stored ACK.
|
|
135
|
+
* The remove-from-queue step only executes after ACK verification passes.
|
|
136
|
+
* SI-002: getPending() returns items in strict insertion order. The caller is
|
|
137
|
+
* responsible for FIFO submission — the queue does not reorder.
|
|
138
|
+
* SI-003: Once an ACK is stored for a hash, subsequent ACKs for the same hash
|
|
139
|
+
* do not overwrite the existing receipt.
|
|
140
|
+
*
|
|
141
|
+
* Persistence: all state is stored in the injected ClientStore using JSON encoding.
|
|
142
|
+
* Keys: "hq:pending:{sessionId}" and "hq:ack:{hashHex}".
|
|
143
|
+
*/
|
|
144
|
+
export declare class AgentHashQueue {
|
|
145
|
+
#private;
|
|
146
|
+
constructor(opts: AgentHashQueueOptions);
|
|
147
|
+
/**
|
|
148
|
+
* Enqueue a hash in the local hash queue before relay submission.
|
|
149
|
+
*
|
|
150
|
+
* Must be called BEFORE attempting relay submission (AC-001).
|
|
151
|
+
* The hash is present in the queue until a valid signed ACK is processed.
|
|
152
|
+
*
|
|
153
|
+
* Logs: client.hashqueue.enqueued with { agentId, sessionId, hashHex, queueDepth, correlationId }
|
|
154
|
+
*/
|
|
155
|
+
enqueue(sessionId: string, hashHex: string, correlationId: string): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Process a relay ACK for a previously enqueued hash.
|
|
158
|
+
*
|
|
159
|
+
* Steps (SI-001 enforced):
|
|
160
|
+
* 1. Look up the relay's public key via lookupRelayPubkey(ack.relayId).
|
|
161
|
+
* 2. Verify the ACK signature with verifyRelayAck().
|
|
162
|
+
* 3. If verification fails: log client.relay.ack.invalid; return WITHOUT touching the queue.
|
|
163
|
+
* 4. Store the ACK as immutable receipt (SI-003: skip if already stored).
|
|
164
|
+
* 5. Remove the hash from the pending queue ONLY after ACK is stored.
|
|
165
|
+
*
|
|
166
|
+
* @param sessionId - session the hash belongs to
|
|
167
|
+
* @param hashHex - the hash being ACKed
|
|
168
|
+
* @param ack - the relay's ACK frame
|
|
169
|
+
* @param lookupRelayPubkey - async function that looks up relay pubkey by relayId
|
|
170
|
+
* @param correlationId - correlation ID for log tracing
|
|
171
|
+
*/
|
|
172
|
+
processAck(sessionId: string, hashHex: string, ack: RelayAck, lookupRelayPubkey: (relayId: string) => Promise<Uint8Array | null>, correlationId: string): Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* Return all pending (un-ACKed) hashes for a session in insertion order (SI-002).
|
|
175
|
+
*/
|
|
176
|
+
getPending(sessionId: string): Promise<PendingHashEntry[]>;
|
|
177
|
+
/**
|
|
178
|
+
* Return the stored relay ACK for a hash, or undefined if not yet ACKed.
|
|
179
|
+
*/
|
|
180
|
+
getAck(hashHex: string): Promise<RelayAck | undefined>;
|
|
181
|
+
/**
|
|
182
|
+
* Return the total number of pending hashes for a session.
|
|
183
|
+
*/
|
|
184
|
+
depth(sessionId: string): Promise<number>;
|
|
185
|
+
/**
|
|
186
|
+
* Poll the total queue depth across all sessions.
|
|
187
|
+
*
|
|
188
|
+
* If depth > 0: logs client.hashqueue.depth at INFO with { agentId, depth }.
|
|
189
|
+
* If depth > 0 and oldest entry is older than hashQueueMaxAgeMs: additionally logs
|
|
190
|
+
* client.hashqueue.stale at WARN with { agentId, depth, oldestHashAge } (DB-001).
|
|
191
|
+
*
|
|
192
|
+
* Called externally on a 30-second interval while the agent is running.
|
|
193
|
+
* Test-friendly: call directly without waiting.
|
|
194
|
+
*/
|
|
195
|
+
pollDepth(): Promise<void>;
|
|
196
|
+
/**
|
|
197
|
+
* Handle a re-submission rejection from the new relay.
|
|
198
|
+
*
|
|
199
|
+
* Logs client.relay.resubmit.rejected at ERROR with { agentId, sessionId, hashHex, reason }.
|
|
200
|
+
* The hash remains in the pending queue (DB-002: operator must resolve manually).
|
|
201
|
+
*
|
|
202
|
+
* @returns the reason string (for caller convenience)
|
|
203
|
+
*/
|
|
204
|
+
handleResubmitRejection(sessionId: string, hashHex: string, reason: string): Promise<string>;
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=agent-hash-queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-hash-queue.d.ts","sourceRoot":"","sources":["../src/agent-hash-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAC;AAItE,sDAAsD;AACtD,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,WAAW,EAAE,UAAU,CAAC;IACxB,oDAAoD;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,SAAS,EAAE,UAAU,CAAC;CACvB;AAWD,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,2BAAoC,CAAC;AAC9E,MAAM,MAAM,uBAAuB,GAAG,OAAO,yBAAyB,CAAC;AAEvE,kDAAkD;AAClD,MAAM,WAAW,qBAAqB;IACpC,6DAA6D;IAC7D,KAAK,EAAE,WAAW,CAAC;IACnB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAgBD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,GAChB,UAAU,CAEZ;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,QAAQ,EACb,WAAW,EAAE,UAAU,GACtB,OAAO,CAIT;AAID;;;;;;;;;;;;;GAaG;AACH,qBAAa,cAAc;;gBAMb,IAAI,EAAE,qBAAqB;IASvC;;;;;;;OAOG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwBvF;;;;;;;;;;;;;;;OAeG;IACG,UAAU,CACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,QAAQ,EACb,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,EAClE,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,IAAI,CAAC;IAgDhB;;OAEG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAIhE;;OAEG;IACG,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC;IAI5D;;OAEG;IACG,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAO/C;;;;;;;;;OASG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAgChC;;;;;;;OAOG;IACG,uBAAuB,CAC3B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC;CA2FnB"}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CELLO-PERSIST-012 — Agent Hash Queue + Signed Relay ACKs
|
|
3
|
+
*
|
|
4
|
+
* ── Pseudocode (Phase P) ──────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The agent hash queue is a first-class protocol primitive. When relay connectivity
|
|
7
|
+
* is interrupted, the P2P conversation continues and hashes accumulate in the queue.
|
|
8
|
+
* On relay recovery or reassignment, queued hashes are submitted in FIFO order.
|
|
9
|
+
*
|
|
10
|
+
* buildSignedAckTbs(hashHex, sequenceNumber, timestamp):
|
|
11
|
+
* // RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
|
|
12
|
+
* // Deterministic TBS for signing and verification. Fixed-width encodings prevent
|
|
13
|
+
* // ambiguity: a 4-byte and an 8-byte integer concatenated to 32-byte hash bytes.
|
|
14
|
+
* hashBytes = Buffer.from(hashHex, 'hex') // 32 bytes
|
|
15
|
+
* seqBuf = 4-byte big-endian uint32 // 4 bytes
|
|
16
|
+
* tsBuf = 8-byte big-endian uint64 // 8 bytes
|
|
17
|
+
* preimage = concat(hashBytes, seqBuf, tsBuf) // 44 bytes
|
|
18
|
+
* return SHA-256(preimage) // 32 bytes
|
|
19
|
+
*
|
|
20
|
+
* verifyRelayAck(hashHex, ack, relayPubkey):
|
|
21
|
+
* // Reconstructs TBS and verifies Ed25519 signature
|
|
22
|
+
* tbs = buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp)
|
|
23
|
+
* if ack.signature.length !== 64: return false
|
|
24
|
+
* return Ed25519.verify(relayPubkey, tbs, ack.signature)
|
|
25
|
+
*
|
|
26
|
+
* AgentHashQueue.enqueue(sessionId, hashHex, correlationId):
|
|
27
|
+
* // Must happen BEFORE relay submission (AC-001)
|
|
28
|
+
* pending = await #loadPending(sessionId)
|
|
29
|
+
* entry = { hashHex, sessionId, enqueuedAt: Date.now() }
|
|
30
|
+
* pending.push(entry)
|
|
31
|
+
* await #savePending(sessionId, pending)
|
|
32
|
+
* logger.info("client.hashqueue.enqueued", { agentId, sessionId, hashHex,
|
|
33
|
+
* queueDepth: pending.length, correlationId })
|
|
34
|
+
*
|
|
35
|
+
* AgentHashQueue.processAck(sessionId, hashHex, ack, lookupRelayPubkey, correlationId):
|
|
36
|
+
* // 1. Verify ACK signature (SI-001: never remove without valid ACK)
|
|
37
|
+
* relayPubkey = await lookupRelayPubkey(ack.relayId)
|
|
38
|
+
* if relayPubkey === null || !verifyRelayAck(hashHex, ack, relayPubkey):
|
|
39
|
+
* logger.warn("client.relay.ack.invalid", { agentId, sessionId, hashHex, reason })
|
|
40
|
+
* return // hash stays in queue
|
|
41
|
+
* // 2. Store ACK as cryptographic receipt (SI-003: immutable, never overwrite)
|
|
42
|
+
* existing = await #loadAck(hashHex)
|
|
43
|
+
* if existing === undefined:
|
|
44
|
+
* await #saveAck(hashHex, ack)
|
|
45
|
+
* // 3. Remove from pending queue (only AFTER ACK is stored)
|
|
46
|
+
* pending = await #loadPending(sessionId)
|
|
47
|
+
* pending = pending.filter(e => e.hashHex !== hashHex)
|
|
48
|
+
* await #savePending(sessionId, pending)
|
|
49
|
+
* logger.info("client.hashqueue.acked", { agentId, sessionId, hashHex,
|
|
50
|
+
* sequenceNumber: ack.sequenceNumber, correlationId })
|
|
51
|
+
*
|
|
52
|
+
* AgentHashQueue.pollDepth():
|
|
53
|
+
* // Called on 30-second interval (externally scheduled)
|
|
54
|
+
* // Logs client.hashqueue.depth when depth > 0 (AC-006)
|
|
55
|
+
* // Also checks for stale entries (DB-001)
|
|
56
|
+
* allPending = await #loadAllPending()
|
|
57
|
+
* depth = total pending count
|
|
58
|
+
* if depth === 0: return
|
|
59
|
+
* logger.info("client.hashqueue.depth", { agentId, depth })
|
|
60
|
+
* // Check staleness
|
|
61
|
+
* oldestEntry = entry with minimum enqueuedAt
|
|
62
|
+
* oldestAge = Date.now() - oldestEntry.enqueuedAt
|
|
63
|
+
* if oldestAge > hashQueueMaxAgeMs:
|
|
64
|
+
* logger.warn("client.hashqueue.stale", { agentId, depth, oldestHashAge: oldestAge })
|
|
65
|
+
*
|
|
66
|
+
* Storage keys:
|
|
67
|
+
* pending queue: "hq:pending:{sessionId}" → JSON PendingHashEntry[]
|
|
68
|
+
* ACK receipt: "hq:ack:{hashHex}" → JSON StoredRelayAck
|
|
69
|
+
* All-sessions index: "hq:sessions" → JSON string[] of sessionIds with queues
|
|
70
|
+
*/
|
|
71
|
+
import { verify, buildRelayAckTbs } from "@cello-protocol/crypto";
|
|
72
|
+
/** Sentinel returned by handleResubmitRejection when predecessor relay is unknown. */
|
|
73
|
+
export const RELAY_PREDECESSOR_UNKNOWN = "RELAY_PREDECESSOR_UNKNOWN";
|
|
74
|
+
// ─── Storage key helpers ──────────────────────────────────────────────────────
|
|
75
|
+
function pendingKey(sessionId) {
|
|
76
|
+
return `hq:pending:${sessionId}`;
|
|
77
|
+
}
|
|
78
|
+
function ackKey(hashHex) {
|
|
79
|
+
return `hq:ack:${hashHex}`;
|
|
80
|
+
}
|
|
81
|
+
const SESSIONS_INDEX_KEY = "hq:sessions";
|
|
82
|
+
// ─── Pure crypto helpers ──────────────────────────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Build the to-be-signed bytes for a relay ACK.
|
|
85
|
+
*
|
|
86
|
+
* Hex-decodes hashHex and delegates to buildRelayAckTbs (the canonical
|
|
87
|
+
* implementation in @cello-protocol/crypto shared with the relay signer).
|
|
88
|
+
*
|
|
89
|
+
* TBS = SHA-256(hash_bytes || seq_BE4 || ts_BE8). RFC 8032, FIPS 180-4.
|
|
90
|
+
*/
|
|
91
|
+
export function buildSignedAckTbs(hashHex, sequenceNumber, timestamp) {
|
|
92
|
+
return buildRelayAckTbs(Buffer.from(hashHex, "hex"), sequenceNumber, timestamp);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Verify a relay ACK signature.
|
|
96
|
+
*
|
|
97
|
+
* Returns true if:
|
|
98
|
+
* - ack.signature is exactly 64 bytes
|
|
99
|
+
* - Ed25519.verify(relayPubkey, buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp), ack.signature) is true
|
|
100
|
+
*
|
|
101
|
+
* RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
|
|
102
|
+
*/
|
|
103
|
+
export function verifyRelayAck(hashHex, ack, relayPubkey) {
|
|
104
|
+
if (ack.signature.length !== 64)
|
|
105
|
+
return false;
|
|
106
|
+
const tbs = buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp);
|
|
107
|
+
return verify(relayPubkey, tbs, ack.signature);
|
|
108
|
+
}
|
|
109
|
+
// ─── AgentHashQueue ───────────────────────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* Manages the local queue of Structure 1 hashes pending relay submission.
|
|
112
|
+
*
|
|
113
|
+
* Security invariants:
|
|
114
|
+
* SI-001: A hash is never removed from the pending queue without a valid stored ACK.
|
|
115
|
+
* The remove-from-queue step only executes after ACK verification passes.
|
|
116
|
+
* SI-002: getPending() returns items in strict insertion order. The caller is
|
|
117
|
+
* responsible for FIFO submission — the queue does not reorder.
|
|
118
|
+
* SI-003: Once an ACK is stored for a hash, subsequent ACKs for the same hash
|
|
119
|
+
* do not overwrite the existing receipt.
|
|
120
|
+
*
|
|
121
|
+
* Persistence: all state is stored in the injected ClientStore using JSON encoding.
|
|
122
|
+
* Keys: "hq:pending:{sessionId}" and "hq:ack:{hashHex}".
|
|
123
|
+
*/
|
|
124
|
+
export class AgentHashQueue {
|
|
125
|
+
#store;
|
|
126
|
+
#agentId;
|
|
127
|
+
#logger;
|
|
128
|
+
#hashQueueMaxAgeMs;
|
|
129
|
+
constructor(opts) {
|
|
130
|
+
this.#store = opts.store;
|
|
131
|
+
this.#agentId = opts.agentId;
|
|
132
|
+
this.#logger = opts.logger;
|
|
133
|
+
this.#hashQueueMaxAgeMs = opts.hashQueueMaxAgeMs ?? 24 * 60 * 60 * 1000;
|
|
134
|
+
}
|
|
135
|
+
// ─── Enqueue ───────────────────────────────────────────────────────────────
|
|
136
|
+
/**
|
|
137
|
+
* Enqueue a hash in the local hash queue before relay submission.
|
|
138
|
+
*
|
|
139
|
+
* Must be called BEFORE attempting relay submission (AC-001).
|
|
140
|
+
* The hash is present in the queue until a valid signed ACK is processed.
|
|
141
|
+
*
|
|
142
|
+
* Logs: client.hashqueue.enqueued with { agentId, sessionId, hashHex, queueDepth, correlationId }
|
|
143
|
+
*/
|
|
144
|
+
async enqueue(sessionId, hashHex, correlationId) {
|
|
145
|
+
// Track the session in the sessions index for pollDepth()
|
|
146
|
+
await this.#addToSessionsIndex(sessionId);
|
|
147
|
+
const pending = await this.#loadPending(sessionId);
|
|
148
|
+
const entry = {
|
|
149
|
+
sessionId,
|
|
150
|
+
hashHex,
|
|
151
|
+
enqueuedAt: Date.now(),
|
|
152
|
+
};
|
|
153
|
+
pending.push(entry);
|
|
154
|
+
await this.#savePending(sessionId, pending);
|
|
155
|
+
this.#logger.info("client.hashqueue.enqueued", {
|
|
156
|
+
agentId: this.#agentId,
|
|
157
|
+
sessionId,
|
|
158
|
+
hashHex,
|
|
159
|
+
queueDepth: pending.length,
|
|
160
|
+
correlationId,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// ─── Process ACK ──────────────────────────────────────────────────────────
|
|
164
|
+
/**
|
|
165
|
+
* Process a relay ACK for a previously enqueued hash.
|
|
166
|
+
*
|
|
167
|
+
* Steps (SI-001 enforced):
|
|
168
|
+
* 1. Look up the relay's public key via lookupRelayPubkey(ack.relayId).
|
|
169
|
+
* 2. Verify the ACK signature with verifyRelayAck().
|
|
170
|
+
* 3. If verification fails: log client.relay.ack.invalid; return WITHOUT touching the queue.
|
|
171
|
+
* 4. Store the ACK as immutable receipt (SI-003: skip if already stored).
|
|
172
|
+
* 5. Remove the hash from the pending queue ONLY after ACK is stored.
|
|
173
|
+
*
|
|
174
|
+
* @param sessionId - session the hash belongs to
|
|
175
|
+
* @param hashHex - the hash being ACKed
|
|
176
|
+
* @param ack - the relay's ACK frame
|
|
177
|
+
* @param lookupRelayPubkey - async function that looks up relay pubkey by relayId
|
|
178
|
+
* @param correlationId - correlation ID for log tracing
|
|
179
|
+
*/
|
|
180
|
+
async processAck(sessionId, hashHex, ack, lookupRelayPubkey, correlationId) {
|
|
181
|
+
// Step 1: Look up the relay's public key
|
|
182
|
+
const relayPubkey = await lookupRelayPubkey(ack.relayId);
|
|
183
|
+
// Step 2: Verify ACK signature
|
|
184
|
+
let validationError = null;
|
|
185
|
+
if (relayPubkey === null) {
|
|
186
|
+
validationError = "relay_pubkey_not_found";
|
|
187
|
+
}
|
|
188
|
+
else if (ack.signature.length !== 64) {
|
|
189
|
+
validationError = "signature_malformed";
|
|
190
|
+
}
|
|
191
|
+
else if (!verifyRelayAck(hashHex, ack, relayPubkey)) {
|
|
192
|
+
validationError = "signature_invalid";
|
|
193
|
+
}
|
|
194
|
+
if (validationError !== null) {
|
|
195
|
+
// Step 3: Invalid — log warning and leave hash in queue (SI-001)
|
|
196
|
+
this.#logger.warn("client.relay.ack.invalid", {
|
|
197
|
+
agentId: this.#agentId,
|
|
198
|
+
sessionId,
|
|
199
|
+
hashHex,
|
|
200
|
+
reason: validationError,
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Step 4: Store ACK as immutable receipt (SI-003: never overwrite existing ACK)
|
|
205
|
+
const existing = await this.#loadAck(hashHex);
|
|
206
|
+
if (existing === undefined) {
|
|
207
|
+
await this.#saveAck(hashHex, ack);
|
|
208
|
+
}
|
|
209
|
+
// If already stored: SI-003 guarantees the first ACK is preserved.
|
|
210
|
+
// Step 5: Remove from pending queue ONLY after ACK is stored
|
|
211
|
+
const pending = await this.#loadPending(sessionId);
|
|
212
|
+
const updated = pending.filter((e) => e.hashHex !== hashHex);
|
|
213
|
+
await this.#savePending(sessionId, updated);
|
|
214
|
+
this.#logger.info("client.hashqueue.acked", {
|
|
215
|
+
agentId: this.#agentId,
|
|
216
|
+
sessionId,
|
|
217
|
+
hashHex,
|
|
218
|
+
sequenceNumber: ack.sequenceNumber,
|
|
219
|
+
correlationId,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// ─── Queue inspection ──────────────────────────────────────────────────────
|
|
223
|
+
/**
|
|
224
|
+
* Return all pending (un-ACKed) hashes for a session in insertion order (SI-002).
|
|
225
|
+
*/
|
|
226
|
+
async getPending(sessionId) {
|
|
227
|
+
return this.#loadPending(sessionId);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Return the stored relay ACK for a hash, or undefined if not yet ACKed.
|
|
231
|
+
*/
|
|
232
|
+
async getAck(hashHex) {
|
|
233
|
+
return this.#loadAck(hashHex);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Return the total number of pending hashes for a session.
|
|
237
|
+
*/
|
|
238
|
+
async depth(sessionId) {
|
|
239
|
+
const pending = await this.#loadPending(sessionId);
|
|
240
|
+
return pending.length;
|
|
241
|
+
}
|
|
242
|
+
// ─── Depth polling (AC-006, DB-001) ──────────────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* Poll the total queue depth across all sessions.
|
|
245
|
+
*
|
|
246
|
+
* If depth > 0: logs client.hashqueue.depth at INFO with { agentId, depth }.
|
|
247
|
+
* If depth > 0 and oldest entry is older than hashQueueMaxAgeMs: additionally logs
|
|
248
|
+
* client.hashqueue.stale at WARN with { agentId, depth, oldestHashAge } (DB-001).
|
|
249
|
+
*
|
|
250
|
+
* Called externally on a 30-second interval while the agent is running.
|
|
251
|
+
* Test-friendly: call directly without waiting.
|
|
252
|
+
*/
|
|
253
|
+
async pollDepth() {
|
|
254
|
+
const allPending = await this.#loadAllPending();
|
|
255
|
+
const depth = allPending.length;
|
|
256
|
+
if (depth === 0)
|
|
257
|
+
return;
|
|
258
|
+
// DB-001: compute oldest age before logging so it appears in the depth event
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
let oldest = now;
|
|
261
|
+
for (const entry of allPending) {
|
|
262
|
+
if (entry.enqueuedAt < oldest) {
|
|
263
|
+
oldest = entry.enqueuedAt;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const oldestHashAge = now - oldest;
|
|
267
|
+
this.#logger.info("client.hashqueue.depth", {
|
|
268
|
+
agentId: this.#agentId,
|
|
269
|
+
depth,
|
|
270
|
+
oldestHashAge,
|
|
271
|
+
});
|
|
272
|
+
if (oldestHashAge > this.#hashQueueMaxAgeMs) {
|
|
273
|
+
this.#logger.warn("client.hashqueue.stale", {
|
|
274
|
+
agentId: this.#agentId,
|
|
275
|
+
depth,
|
|
276
|
+
oldestHashAge,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ─── Error handlers ────────────────────────────────────────────────────────
|
|
281
|
+
/**
|
|
282
|
+
* Handle a re-submission rejection from the new relay.
|
|
283
|
+
*
|
|
284
|
+
* Logs client.relay.resubmit.rejected at ERROR with { agentId, sessionId, hashHex, reason }.
|
|
285
|
+
* The hash remains in the pending queue (DB-002: operator must resolve manually).
|
|
286
|
+
*
|
|
287
|
+
* @returns the reason string (for caller convenience)
|
|
288
|
+
*/
|
|
289
|
+
async handleResubmitRejection(sessionId, hashHex, reason) {
|
|
290
|
+
this.#logger.error("client.relay.resubmit.rejected", {
|
|
291
|
+
agentId: this.#agentId,
|
|
292
|
+
sessionId,
|
|
293
|
+
hashHex,
|
|
294
|
+
reason,
|
|
295
|
+
});
|
|
296
|
+
// Hash stays in queue — no state mutation
|
|
297
|
+
return reason;
|
|
298
|
+
}
|
|
299
|
+
// ─── Private storage helpers ───────────────────────────────────────────────
|
|
300
|
+
async #loadPending(sessionId) {
|
|
301
|
+
const raw = await this.#store.get(pendingKey(sessionId));
|
|
302
|
+
if (raw === undefined)
|
|
303
|
+
return [];
|
|
304
|
+
try {
|
|
305
|
+
return JSON.parse(Buffer.from(raw).toString("utf8"));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async #savePending(sessionId, entries) {
|
|
312
|
+
const raw = Buffer.from(JSON.stringify(entries), "utf8");
|
|
313
|
+
await this.#store.set(pendingKey(sessionId), new Uint8Array(raw));
|
|
314
|
+
}
|
|
315
|
+
async #loadAck(hashHex) {
|
|
316
|
+
const raw = await this.#store.get(ackKey(hashHex));
|
|
317
|
+
if (raw === undefined)
|
|
318
|
+
return undefined;
|
|
319
|
+
try {
|
|
320
|
+
const stored = JSON.parse(Buffer.from(raw).toString("utf8"));
|
|
321
|
+
return {
|
|
322
|
+
relayId: stored.relayId,
|
|
323
|
+
relayPubkey: new Uint8Array(Buffer.from(stored.relayPubkeyHex, "hex")),
|
|
324
|
+
sequenceNumber: stored.sequenceNumber,
|
|
325
|
+
timestamp: stored.timestamp,
|
|
326
|
+
signature: new Uint8Array(Buffer.from(stored.signatureHex, "hex")),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async #saveAck(hashHex, ack) {
|
|
334
|
+
const stored = {
|
|
335
|
+
relayId: ack.relayId,
|
|
336
|
+
relayPubkeyHex: Buffer.from(ack.relayPubkey).toString("hex"),
|
|
337
|
+
sequenceNumber: ack.sequenceNumber,
|
|
338
|
+
timestamp: ack.timestamp,
|
|
339
|
+
signatureHex: Buffer.from(ack.signature).toString("hex"),
|
|
340
|
+
};
|
|
341
|
+
const raw = Buffer.from(JSON.stringify(stored), "utf8");
|
|
342
|
+
await this.#store.set(ackKey(hashHex), new Uint8Array(raw));
|
|
343
|
+
}
|
|
344
|
+
async #addToSessionsIndex(sessionId) {
|
|
345
|
+
const raw = await this.#store.get(SESSIONS_INDEX_KEY);
|
|
346
|
+
let sessions = [];
|
|
347
|
+
if (raw !== undefined) {
|
|
348
|
+
try {
|
|
349
|
+
sessions = JSON.parse(Buffer.from(raw).toString("utf8"));
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
sessions = [];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!sessions.includes(sessionId)) {
|
|
356
|
+
sessions.push(sessionId);
|
|
357
|
+
const newRaw = Buffer.from(JSON.stringify(sessions), "utf8");
|
|
358
|
+
await this.#store.set(SESSIONS_INDEX_KEY, new Uint8Array(newRaw));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async #loadAllPending() {
|
|
362
|
+
const raw = await this.#store.get(SESSIONS_INDEX_KEY);
|
|
363
|
+
if (raw === undefined)
|
|
364
|
+
return [];
|
|
365
|
+
let sessions = [];
|
|
366
|
+
try {
|
|
367
|
+
sessions = JSON.parse(Buffer.from(raw).toString("utf8"));
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
const all = [];
|
|
373
|
+
for (const sessionId of sessions) {
|
|
374
|
+
const pending = await this.#loadPending(sessionId);
|
|
375
|
+
all.push(...pending);
|
|
376
|
+
}
|
|
377
|
+
return all;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
//# sourceMappingURL=agent-hash-queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-hash-queue.js","sourceRoot":"","sources":["../src/agent-hash-queue.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backup-key-derivation.ts — HKDF derivation of the cloud backup encryption key.
|
|
3
|
+
*
|
|
4
|
+
* PERSIST-011: backup_key = HKDF-SHA256(ikm=identity_key, salt=none,
|
|
5
|
+
* info='backup-key' || agent_id, length=32)
|
|
6
|
+
*
|
|
7
|
+
* Security invariants (PERSIST-011 SI-001):
|
|
8
|
+
* - identity_key is passed in by the caller; it is NEVER stored here.
|
|
9
|
+
* - The returned backup_key is NEVER logged or persisted by this function.
|
|
10
|
+
* - This function has no side effects — pure transformation only.
|
|
11
|
+
*
|
|
12
|
+
* Key independence (AC-001):
|
|
13
|
+
* - backup_key and db_key use distinct info strings:
|
|
14
|
+
* db_key: info = 'local-db-key\x00' + agentId
|
|
15
|
+
* backup_key: info = 'backup-key\x00' + agentId
|
|
16
|
+
* - For the same identity_key and agentId, backup_key != db_key.
|
|
17
|
+
* - Losing one key does not compromise the other.
|
|
18
|
+
*
|
|
19
|
+
* RFC reference: RFC 5869 (HKDF). Node.js implementation: crypto.hkdfSync.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Derive a 32-byte cloud backup encryption key from the agent's identity_key.
|
|
23
|
+
*
|
|
24
|
+
* @param identityKey - The agent's long-term root key (32 bytes). Never stored or logged.
|
|
25
|
+
* @param agentId - The stable agent identifier. Binds the key to a specific agent.
|
|
26
|
+
* @returns - A 32-byte Uint8Array suitable for use as an AES-256-GCM key.
|
|
27
|
+
*
|
|
28
|
+
* Security: The backup_key is deterministic — same inputs always produce the same output.
|
|
29
|
+
* This is intentional: the client must re-derive the key on every backup/restore without
|
|
30
|
+
* storing it, using only the identity_key (which is stored separately, protected
|
|
31
|
+
* by the OS keychain or equivalent).
|
|
32
|
+
*
|
|
33
|
+
* The null-byte separator between the literal and agentId prevents prefix-collision
|
|
34
|
+
* attacks where two different (literal, agentId) pairs produce the same concatenation.
|
|
35
|
+
*/
|
|
36
|
+
export declare function deriveBackupKey(identityKey: Uint8Array, agentId: string): Uint8Array;
|
|
37
|
+
//# sourceMappingURL=backup-key-derivation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backup-key-derivation.d.ts","sourceRoot":"","sources":["../src/backup-key-derivation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,UAAU,CAYpF"}
|