@novasamatech/statement-store 0.6.13 → 0.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -1
- package/dist/session/session.d.ts +3 -1
- package/dist/session/session.js +250 -72
- package/dist/session/session.spec.d.ts +1 -0
- package/dist/session/session.spec.js +440 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,272 @@
|
|
|
1
1
|
# @novasamatech/statement-store
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Encrypted, signed messaging over a Polkadot statement store. Provides a session abstraction for sending and receiving typed request/response messages between two on-chain accounts.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
+
The library wraps the raw statement-store RPC into a typed session with:
|
|
8
|
+
|
|
9
|
+
- End-to-end AES-GCM encryption (ECDH shared secret via sr25519)
|
|
10
|
+
- sr25519 proof generation and verification on every statement
|
|
11
|
+
- Codec-based message serialization / deserialization
|
|
12
|
+
- Request/response correlation with automatic response acknowledgement
|
|
13
|
+
|
|
7
14
|
## Installation
|
|
8
15
|
|
|
9
16
|
```shell
|
|
10
17
|
npm install @novasamatech/statement-store --save -E
|
|
11
18
|
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import {
|
|
24
|
+
createSession,
|
|
25
|
+
createLocalSessionAccount,
|
|
26
|
+
createRemoteSessionAccount,
|
|
27
|
+
createAccountId,
|
|
28
|
+
createSr25519Secret,
|
|
29
|
+
deriveSr25519PublicKey,
|
|
30
|
+
createSr25519Prover,
|
|
31
|
+
createEncryption,
|
|
32
|
+
createLazyClient,
|
|
33
|
+
createPapiStatementStoreAdapter,
|
|
34
|
+
} from '@novasamatech/statement-store';
|
|
35
|
+
import { str } from 'scale-ts';
|
|
36
|
+
|
|
37
|
+
// 1. Derive local key pair from entropy
|
|
38
|
+
const localSecret = createSr25519Secret(entropy, '//wallet');
|
|
39
|
+
const localPublicKey = deriveSr25519PublicKey(localSecret);
|
|
40
|
+
|
|
41
|
+
// 2. Build account descriptors
|
|
42
|
+
const localAccount = createLocalSessionAccount(createAccountId(localPublicKey));
|
|
43
|
+
const remoteAccount = createRemoteSessionAccount(
|
|
44
|
+
createAccountId(remotePublicKey),
|
|
45
|
+
remotePublicKey,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// 3. Wire up the chain adapter
|
|
49
|
+
const client = createLazyClient(provider);
|
|
50
|
+
const statementStore = createPapiStatementStoreAdapter(client);
|
|
51
|
+
|
|
52
|
+
// 4. Create session dependencies
|
|
53
|
+
const prover = createSr25519Prover(localSecret);
|
|
54
|
+
const encryption = createEncryption(remoteAccount.publicKey);
|
|
55
|
+
|
|
56
|
+
// 5. Open session
|
|
57
|
+
const session = createSession({ localAccount, remoteAccount, statementStore, encryption, prover });
|
|
58
|
+
|
|
59
|
+
// Send a typed request and wait for the remote acknowledgement
|
|
60
|
+
const result = await session.request(str, 'hello');
|
|
61
|
+
|
|
62
|
+
// Clean up
|
|
63
|
+
session.dispose();
|
|
64
|
+
client.disconnect();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `createSession(params)`
|
|
70
|
+
|
|
71
|
+
Creates a `Session` for bidirectional typed messaging between two accounts.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
function createSession(params: SessionParams): Session
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**`SessionParams`**
|
|
78
|
+
|
|
79
|
+
| Property | Type | Description |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `localAccount` | `LocalSessionAccount` | The local side of the session |
|
|
82
|
+
| `remoteAccount` | `RemoteSessionAccount` | The remote side of the session |
|
|
83
|
+
| `statementStore` | `StatementStoreAdapter` | Chain adapter for submitting/subscribing to statements |
|
|
84
|
+
| `encryption` | `Encryption` | Encryption instance (use `createEncryption`) |
|
|
85
|
+
| `prover` | `StatementProver` | Proof signer/verifier (use `createSr25519Prover`) |
|
|
86
|
+
|
|
87
|
+
**`Session` methods**
|
|
88
|
+
|
|
89
|
+
| Method | Signature | Description |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `request` | `(codec, payload) → ResultAsync<void, Error>` | Submit a request and wait for the remote to acknowledge it. Resolves when the remote sends a success response; rejects on decoding/decryption failure or unknown error. |
|
|
92
|
+
| `submitRequestMessage` | `(codec, payload) → ResultAsync<{ requestId }, Error>` | Submit a request without waiting for a response. Returns the generated `requestId`. |
|
|
93
|
+
| `submitResponseMessage` | `(requestId, responseCode) → ResultAsync<void, Error>` | Send an explicit response to a request identified by `requestId`. |
|
|
94
|
+
| `waitForRequestMessage` | `(codec, filter) → ResultAsync<S, Error>` | Wait for the next incoming request whose decoded payload passes `filter`. Unsubscribes automatically once matched. |
|
|
95
|
+
| `waitForResponseMessage` | `(requestId) → ResultAsync<ResponseMessage, Error>` | Wait for the response to a specific outgoing request. |
|
|
96
|
+
| `subscribe` | `(codec, callback) → VoidFunction` | Subscribe to all incoming messages, decoded with `codec`. Returns an unsubscribe function. |
|
|
97
|
+
| `dispose` | `() → void` | Unsubscribe all active subscriptions created by this session. |
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### Account factories
|
|
102
|
+
|
|
103
|
+
#### `createAccountId(value)`
|
|
104
|
+
|
|
105
|
+
Creates a 32-byte `AccountId` from a raw public key buffer.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
function createAccountId(value: Uint8Array): AccountId
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### `createLocalSessionAccount(accountId, pin?)`
|
|
112
|
+
|
|
113
|
+
Creates a `LocalSessionAccount` representing the local participant.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
function createLocalSessionAccount(accountId: AccountId, pin?: string): LocalSessionAccount
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`pin` is an optional string used to namespace the session channel.
|
|
120
|
+
|
|
121
|
+
#### `createRemoteSessionAccount(accountId, publicKey, pin?)`
|
|
122
|
+
|
|
123
|
+
Creates a `RemoteSessionAccount` representing the remote participant. `publicKey` is used for shared-secret derivation and session ID computation.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
function createRemoteSessionAccount(
|
|
127
|
+
accountId: AccountId,
|
|
128
|
+
publicKey: Uint8Array,
|
|
129
|
+
pin?: string,
|
|
130
|
+
): RemoteSessionAccount
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### `createSessionId(sharedSecret, accountA, accountB)`
|
|
134
|
+
|
|
135
|
+
Derives a deterministic 32-byte session channel ID from a shared secret and two accounts.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
function createSessionId(
|
|
139
|
+
sharedSecret: Uint8Array,
|
|
140
|
+
accountA: SessionAccount,
|
|
141
|
+
accountB: SessionAccount,
|
|
142
|
+
): SessionId
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### Encryption
|
|
148
|
+
|
|
149
|
+
#### `createEncryption(sharedSecret)`
|
|
150
|
+
|
|
151
|
+
Creates an `Encryption` instance that encrypts/decrypts payloads with AES-256-GCM. The shared secret is typically the remote account's public key (ECDH result).
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
function createEncryption(sharedSecret: Uint8Array): Encryption
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`Encryption` interface:
|
|
158
|
+
|
|
159
|
+
| Method | Description |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `encrypt(plaintext)` | Encrypts with a random 12-byte nonce prepended to the output |
|
|
162
|
+
| `decrypt(ciphertext)` | Strips the nonce prefix and decrypts |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
### Proof generation
|
|
167
|
+
|
|
168
|
+
#### `createSr25519Prover(secret)`
|
|
169
|
+
|
|
170
|
+
Creates a `StatementProver` that signs and verifies statement proofs using sr25519.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
function createSr25519Prover(secret: Uint8Array): StatementProver
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`StatementProver` interface:
|
|
177
|
+
|
|
178
|
+
| Method | Description |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `generateMessageProof(statement)` | Signs the statement and returns a `SignedStatement` |
|
|
181
|
+
| `verifyMessageProof(statement)` | Verifies the sr25519 signature on an incoming statement |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### Chain adapter
|
|
186
|
+
|
|
187
|
+
#### `createPapiStatementStoreAdapter(lazyClient)`
|
|
188
|
+
|
|
189
|
+
Creates a `StatementStoreAdapter` backed by the polkadot-api JSON-RPC client.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
function createPapiStatementStoreAdapter(lazyClient: LazyClient): StatementStoreAdapter
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`StatementStoreAdapter` interface:
|
|
196
|
+
|
|
197
|
+
| Method | Description |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `queryStatements(topics, destination?)` | Fetch all current statements matching the given topics |
|
|
200
|
+
| `subscribeStatements(topics, callback)` | Subscribe to new statements on topics; returns unsubscribe function |
|
|
201
|
+
| `submitStatement(statement)` | Submit a signed statement; resolves on success, rejects with a typed error on failure |
|
|
202
|
+
|
|
203
|
+
#### `createLazyClient(provider)`
|
|
204
|
+
|
|
205
|
+
Creates a `LazyClient` that lazily initialises polkadot-api and substrate-client instances from a JSON-RPC provider.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
function createLazyClient(provider: JsonRpcProvider): LazyClient
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`LazyClient` methods:
|
|
212
|
+
|
|
213
|
+
| Method | Description |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `getClient()` | Returns (or creates) a `PolkadotClient` |
|
|
216
|
+
| `getRequestFn()` | Returns a `RequestFn` for use with `sdk-statement` |
|
|
217
|
+
| `getSubscribeFn()` | Returns a `SubscribeFn` for use with `sdk-statement` |
|
|
218
|
+
| `disconnect()` | Destroys both underlying clients |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
### Crypto utilities
|
|
223
|
+
|
|
224
|
+
Low-level sr25519 helpers used to produce keys and proofs.
|
|
225
|
+
|
|
226
|
+
#### `createSr25519Secret(entropy, derivation?)`
|
|
227
|
+
|
|
228
|
+
Derives an sr25519 secret key from raw entropy, optionally applying a derivation path (`//hard` or `/soft` segments).
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
function createSr25519Secret(entropy: Uint8Array, derivation?: string): Uint8Array
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### `createSr25519Derivation(secret, derivation)`
|
|
235
|
+
|
|
236
|
+
Applies a derivation path string to an existing sr25519 secret.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
function createSr25519Derivation(secret: Uint8Array, derivation: string): Uint8Array
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
#### `deriveSr25519PublicKey(secret)`
|
|
243
|
+
|
|
244
|
+
Derives the sr25519 public key from a secret key.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
function deriveSr25519PublicKey(secret: Uint8Array): Uint8Array
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
#### `signWithSr25519Secret(secret, message)`
|
|
251
|
+
|
|
252
|
+
Signs a message with an sr25519 secret key.
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
function signWithSr25519Secret(secret: Uint8Array, message: Uint8Array): Uint8Array
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
#### `verifySr25519Signature(message, signature, publicKey)`
|
|
259
|
+
|
|
260
|
+
Verifies an sr25519 signature. Returns `true` if valid.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
function verifySr25519Signature(message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### `khash(secret, message)`
|
|
267
|
+
|
|
268
|
+
Computes a keyed blake2b-256 hash. Used internally to derive session channel IDs.
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
function khash(secret: Uint8Array, message: Uint8Array): Uint8Array
|
|
272
|
+
```
|
|
@@ -9,5 +9,7 @@ export type SessionParams = {
|
|
|
9
9
|
statementStore: StatementStoreAdapter;
|
|
10
10
|
encryption: Encryption;
|
|
11
11
|
prover: StatementProver;
|
|
12
|
+
maxRequestSize?: number;
|
|
12
13
|
};
|
|
13
|
-
export declare function
|
|
14
|
+
export declare function nextExpiry(current: bigint): bigint;
|
|
15
|
+
export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, maxRequestSize, }: SessionParams): Session;
|
package/dist/session/session.js
CHANGED
|
@@ -1,55 +1,246 @@
|
|
|
1
1
|
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
2
|
import { toHex } from '@polkadot-api/utils';
|
|
3
3
|
import { nanoid } from 'nanoid';
|
|
4
|
-
import { ResultAsync, err, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
|
|
5
|
-
import { Bytes } from 'scale-ts';
|
|
4
|
+
import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
|
|
6
5
|
import { khash, stringToBytes } from '../crypto.js';
|
|
7
6
|
import { nonNullable, toError } from '../helpers.js';
|
|
8
7
|
import { createSessionId } from '../model/session.js';
|
|
9
8
|
import { DecodingError, DecryptionError, UnknownError } from './error.js';
|
|
10
9
|
import { toMessage } from './messageMapper.js';
|
|
11
10
|
import { StatementData } from './scale/statementData.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const DEFAULT_EXPIRY_DURATION_SECS = 7 * 24 * 60 * 60; // 7 days
|
|
12
|
+
const DEFAULT_MAX_REQUEST_SIZE = 4096;
|
|
13
|
+
export function nextExpiry(current) {
|
|
14
|
+
const fresh = createExpiryFromDuration(DEFAULT_EXPIRY_DURATION_SECS);
|
|
15
|
+
return fresh > current ? fresh : current + 1n;
|
|
16
|
+
}
|
|
17
|
+
export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
|
|
18
|
+
const outgoingSessionId = createSessionId(remoteAccount.publicKey, localAccount, remoteAccount);
|
|
19
|
+
const incomingSessionId = createSessionId(remoteAccount.publicKey, remoteAccount, localAccount);
|
|
20
|
+
const state = {
|
|
21
|
+
phase: 'initialization',
|
|
22
|
+
expiry: 0n,
|
|
23
|
+
outgoingRequest: null,
|
|
24
|
+
incomingRequest: null,
|
|
25
|
+
respondedIncomingRequest: false,
|
|
26
|
+
messageQueue: [],
|
|
27
|
+
pendingDelivery: new Map(),
|
|
28
|
+
seenStatements: new Set(),
|
|
29
|
+
};
|
|
30
|
+
let subscribers = [];
|
|
31
|
+
const bufferedMessages = [];
|
|
32
|
+
let storeUnsub = null;
|
|
33
|
+
function submitStatementData(channel, topicSessionId, data) {
|
|
34
|
+
state.expiry = nextExpiry(state.expiry);
|
|
35
|
+
const expiry = state.expiry;
|
|
15
36
|
return encryption
|
|
16
37
|
.encrypt(data)
|
|
17
|
-
.map(
|
|
18
|
-
expiry
|
|
38
|
+
.map(encrypted => ({
|
|
39
|
+
expiry,
|
|
19
40
|
channel: toHex(channel),
|
|
20
|
-
topics: [toHex(
|
|
21
|
-
data,
|
|
41
|
+
topics: [toHex(topicSessionId)],
|
|
42
|
+
data: encrypted,
|
|
22
43
|
}))
|
|
23
44
|
.asyncAndThen(prover.generateMessageProof)
|
|
24
45
|
.andThen(statementStore.submitStatement);
|
|
25
46
|
}
|
|
47
|
+
function encodeAndSubmitRequest(requestId, messages) {
|
|
48
|
+
const encode = fromThrowable(StatementData.enc, toError);
|
|
49
|
+
encode({ tag: 'request', value: { requestId, data: messages } })
|
|
50
|
+
.asyncAndThen(data => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, data))
|
|
51
|
+
.mapErr(e => {
|
|
52
|
+
console.error('submitRequest failed:', e);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function deliverStatementData(statementData) {
|
|
56
|
+
if (subscribers.length === 0) {
|
|
57
|
+
if (state.phase === 'initialization') {
|
|
58
|
+
bufferedMessages.push(statementData);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const sub of subscribers) {
|
|
63
|
+
const messages = toMessage(statementData, sub.codec);
|
|
64
|
+
if (messages.length > 0)
|
|
65
|
+
sub.callback(messages);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function tryDecodeStatement(statement) {
|
|
69
|
+
if (!statement.data)
|
|
70
|
+
return okAsync(null);
|
|
71
|
+
const data = statement.data;
|
|
72
|
+
return prover
|
|
73
|
+
.verifyMessageProof(statement)
|
|
74
|
+
.andThen(verified => (verified ? ok() : err(new Error('Invalid proof'))))
|
|
75
|
+
.andThen(() => encryption.decrypt(data))
|
|
76
|
+
.map(decrypted => StatementData.dec(decrypted))
|
|
77
|
+
.orElse(() => ok(null));
|
|
78
|
+
}
|
|
79
|
+
function processIncomingStatement(statement) {
|
|
80
|
+
if (!statement.data)
|
|
81
|
+
return;
|
|
82
|
+
const key = toHex(statement.data);
|
|
83
|
+
if (state.seenStatements.has(key))
|
|
84
|
+
return;
|
|
85
|
+
state.seenStatements.add(key);
|
|
86
|
+
tryDecodeStatement(statement).andTee(statementData => {
|
|
87
|
+
if (!statementData)
|
|
88
|
+
return;
|
|
89
|
+
if (statementData.tag === 'request') {
|
|
90
|
+
if (statementData.value.requestId === state.incomingRequest?.requestId)
|
|
91
|
+
return;
|
|
92
|
+
state.incomingRequest = { requestId: statementData.value.requestId };
|
|
93
|
+
state.respondedIncomingRequest = false;
|
|
94
|
+
deliverStatementData(statementData);
|
|
95
|
+
}
|
|
96
|
+
else if (statementData.tag === 'response') {
|
|
97
|
+
if (state.outgoingRequest?.requestId !== statementData.value.requestId)
|
|
98
|
+
return;
|
|
99
|
+
const responseMessage = {
|
|
100
|
+
type: 'response',
|
|
101
|
+
localId: statementData.value.requestId,
|
|
102
|
+
requestId: statementData.value.requestId,
|
|
103
|
+
responseCode: statementData.value.responseCode,
|
|
104
|
+
};
|
|
105
|
+
for (const token of state.outgoingRequest.tokens) {
|
|
106
|
+
const deferred = state.pendingDelivery.get(token);
|
|
107
|
+
if (deferred) {
|
|
108
|
+
deferred.resolve(responseMessage);
|
|
109
|
+
state.pendingDelivery.delete(token);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
state.outgoingRequest = null;
|
|
113
|
+
deliverStatementData(statementData);
|
|
114
|
+
processMessageQueue();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function processNewMessage(encoded, token) {
|
|
119
|
+
if (state.outgoingRequest === null) {
|
|
120
|
+
const requestId = nanoid();
|
|
121
|
+
state.outgoingRequest = { requestId, messages: [encoded], tokens: [token] };
|
|
122
|
+
encodeAndSubmitRequest(requestId, state.outgoingRequest.messages);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const currentTotal = state.outgoingRequest.messages.reduce((s, m) => s + m.length, 0);
|
|
126
|
+
if (currentTotal + encoded.length <= maxRequestSize) {
|
|
127
|
+
state.outgoingRequest.messages.push(encoded);
|
|
128
|
+
state.outgoingRequest.tokens.push(token);
|
|
129
|
+
state.outgoingRequest.requestId = nanoid();
|
|
130
|
+
encodeAndSubmitRequest(state.outgoingRequest.requestId, state.outgoingRequest.messages);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
state.messageQueue.push({ encoded, token });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function processMessageQueue() {
|
|
138
|
+
const currentTotal = state.outgoingRequest?.messages.reduce((s, m) => s + m.length, 0) ?? 0;
|
|
139
|
+
while (state.messageQueue.length > 0) {
|
|
140
|
+
const head = state.messageQueue[0];
|
|
141
|
+
if (state.outgoingRequest !== null && currentTotal + head.encoded.length > maxRequestSize)
|
|
142
|
+
break;
|
|
143
|
+
state.messageQueue.shift();
|
|
144
|
+
processNewMessage(head.encoded, head.token);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function ensureStoreSubscription() {
|
|
148
|
+
if (storeUnsub)
|
|
149
|
+
return;
|
|
150
|
+
storeUnsub = statementStore.subscribeStatements([incomingSessionId], statements => {
|
|
151
|
+
for (const statement of statements) {
|
|
152
|
+
processIncomingStatement(statement);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async function init() {
|
|
157
|
+
const [ownResult, peerResult] = await Promise.all([
|
|
158
|
+
statementStore.queryStatements([outgoingSessionId]),
|
|
159
|
+
statementStore.queryStatements([incomingSessionId]),
|
|
160
|
+
]);
|
|
161
|
+
if (ownResult.isErr() || peerResult.isErr())
|
|
162
|
+
return;
|
|
163
|
+
const ownStatements = ownResult.value;
|
|
164
|
+
const peerStatements = peerResult.value;
|
|
165
|
+
let maxExpiry = 0n;
|
|
166
|
+
for (const s of ownStatements) {
|
|
167
|
+
if (s.expiry !== undefined && s.expiry > maxExpiry)
|
|
168
|
+
maxExpiry = s.expiry;
|
|
169
|
+
}
|
|
170
|
+
state.expiry = nextExpiry(maxExpiry);
|
|
171
|
+
for (const s of [...ownStatements, ...peerStatements]) {
|
|
172
|
+
if (s.data)
|
|
173
|
+
state.seenStatements.add(toHex(s.data));
|
|
174
|
+
}
|
|
175
|
+
const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).match(v => v, () => null))).then(r => r.filter(nonNullable));
|
|
176
|
+
const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
|
|
177
|
+
const ownRequest = ownDecoded.find(d => d.tag === 'request');
|
|
178
|
+
const ownResponse = ownDecoded.find(d => d.tag === 'response');
|
|
179
|
+
const peerRequest = peerDecoded.find(d => d.tag === 'request');
|
|
180
|
+
const peerResponse = peerDecoded.find(d => d.tag === 'response');
|
|
181
|
+
if (ownRequest?.tag === 'request') {
|
|
182
|
+
const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
|
|
183
|
+
if (!hasResponse) {
|
|
184
|
+
state.outgoingRequest = {
|
|
185
|
+
requestId: ownRequest.value.requestId,
|
|
186
|
+
messages: ownRequest.value.data,
|
|
187
|
+
tokens: [], // tokens from previous session cannot be restored
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (peerRequest?.tag === 'request') {
|
|
192
|
+
state.incomingRequest = { requestId: peerRequest.value.requestId };
|
|
193
|
+
state.respondedIncomingRequest =
|
|
194
|
+
ownResponse?.tag === 'response' && ownResponse.value.requestId === peerRequest.value.requestId;
|
|
195
|
+
}
|
|
196
|
+
// Notify app of any unresponded incoming request.
|
|
197
|
+
// Delivered while phase is still 'initialization' so that deliverStatementData
|
|
198
|
+
// buffers the message for replay if no subscriber is registered yet.
|
|
199
|
+
if (peerRequest && state.incomingRequest && !state.respondedIncomingRequest) {
|
|
200
|
+
deliverStatementData(peerRequest);
|
|
201
|
+
}
|
|
202
|
+
state.phase = 'active';
|
|
203
|
+
processMessageQueue();
|
|
204
|
+
}
|
|
26
205
|
const session = {
|
|
27
206
|
request(codec, data) {
|
|
28
|
-
return session
|
|
29
|
-
|
|
30
|
-
|
|
207
|
+
return session
|
|
208
|
+
.submitRequestMessage(codec, data)
|
|
209
|
+
.andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
|
|
31
210
|
},
|
|
32
211
|
submitRequestMessage(codec, message) {
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
212
|
+
const encode = fromThrowable(codec.enc, toError);
|
|
213
|
+
const encodedResult = encode(message);
|
|
214
|
+
if (encodedResult.isErr())
|
|
215
|
+
return errAsync(encodedResult.error);
|
|
216
|
+
const encoded = encodedResult.value;
|
|
217
|
+
if (encoded.length > maxRequestSize)
|
|
218
|
+
return errAsync(new Error('message too big'));
|
|
219
|
+
const token = nanoid();
|
|
220
|
+
let resolveFn;
|
|
221
|
+
let rejectFn;
|
|
222
|
+
const promise = new Promise((res, rej) => {
|
|
223
|
+
resolveFn = res;
|
|
224
|
+
rejectFn = rej;
|
|
40
225
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
226
|
+
state.pendingDelivery.set(token, { resolve: resolveFn, reject: rejectFn, promise });
|
|
227
|
+
if (state.phase === 'initialization') {
|
|
228
|
+
state.messageQueue.push({ encoded, token });
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
processNewMessage(encoded, token);
|
|
232
|
+
}
|
|
233
|
+
return okAsync({ requestId: token });
|
|
44
234
|
},
|
|
45
235
|
submitResponseMessage(requestId, responseCode) {
|
|
46
|
-
|
|
236
|
+
if (state.respondedIncomingRequest)
|
|
237
|
+
return okAsync(undefined);
|
|
238
|
+
if (state.incomingRequest?.requestId !== requestId) {
|
|
239
|
+
return errAsync(new Error(`No incoming request with id ${requestId}`));
|
|
240
|
+
}
|
|
241
|
+
state.respondedIncomingRequest = true;
|
|
47
242
|
const encode = fromThrowable(StatementData.enc, toError);
|
|
48
|
-
|
|
49
|
-
tag: 'response',
|
|
50
|
-
value: { requestId, responseCode },
|
|
51
|
-
});
|
|
52
|
-
return rawData.asyncAndThen(data => submit(sessionId, createResponseChannel(sessionId), data));
|
|
243
|
+
return encode({ tag: 'response', value: { requestId, responseCode } }).asyncAndThen(data => submitStatementData(createResponseChannel(incomingSessionId), incomingSessionId, data));
|
|
53
244
|
},
|
|
54
245
|
waitForRequestMessage(codec, filter) {
|
|
55
246
|
const promise = new Promise(resolve => {
|
|
@@ -71,56 +262,47 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
71
262
|
});
|
|
72
263
|
return fromPromise(promise, toError);
|
|
73
264
|
},
|
|
74
|
-
waitForResponseMessage(
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
unsub();
|
|
80
|
-
resolve(response);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
return fromPromise(promise, toError);
|
|
265
|
+
waitForResponseMessage(token) {
|
|
266
|
+
const deferred = state.pendingDelivery.get(token);
|
|
267
|
+
if (!deferred)
|
|
268
|
+
return errAsync(new Error(`No pending delivery for token ${token}`));
|
|
269
|
+
return fromPromise(deferred.promise, toError);
|
|
85
270
|
},
|
|
86
271
|
subscribe(codec, callback) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
272
|
+
const sub = {
|
|
273
|
+
codec: codec,
|
|
274
|
+
callback: callback,
|
|
275
|
+
};
|
|
276
|
+
subscribers.push(sub);
|
|
277
|
+
ensureStoreSubscription();
|
|
278
|
+
// Deliver buffered init messages to this subscriber
|
|
279
|
+
if (bufferedMessages.length > 0) {
|
|
280
|
+
const messages = bufferedMessages.flatMap(sd => toMessage(sd, codec));
|
|
281
|
+
if (messages.length > 0)
|
|
282
|
+
callback(messages);
|
|
98
283
|
}
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
})
|
|
107
|
-
// TODO rework
|
|
108
|
-
.andTee(messages => {
|
|
109
|
-
const requests = messages.filter(m => m.type === 'request').map(m => m.requestId);
|
|
110
|
-
const responses = requests.map(requestId => session.submitResponseMessage(requestId, 'success'));
|
|
111
|
-
return ResultAsync.combine(responses);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
284
|
+
return () => {
|
|
285
|
+
subscribers = subscribers.filter(s => s !== sub);
|
|
286
|
+
if (subscribers.length === 0 && storeUnsub) {
|
|
287
|
+
storeUnsub();
|
|
288
|
+
storeUnsub = null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
114
291
|
},
|
|
115
292
|
dispose() {
|
|
116
|
-
|
|
117
|
-
|
|
293
|
+
storeUnsub?.();
|
|
294
|
+
storeUnsub = null;
|
|
295
|
+
subscribers = [];
|
|
296
|
+
for (const [, deferred] of state.pendingDelivery) {
|
|
297
|
+
deferred.reject(new Error('Session disposed'));
|
|
118
298
|
}
|
|
119
|
-
|
|
299
|
+
state.pendingDelivery.clear();
|
|
120
300
|
},
|
|
121
301
|
};
|
|
302
|
+
void init();
|
|
122
303
|
return session;
|
|
123
304
|
}
|
|
305
|
+
// ── module-level helpers ──────────────────────────────────────────────────────
|
|
124
306
|
function mapResponseCode(responseCode) {
|
|
125
307
|
switch (responseCode) {
|
|
126
308
|
case 'success':
|
|
@@ -133,10 +315,6 @@ function mapResponseCode(responseCode) {
|
|
|
133
315
|
return err(new UnknownError());
|
|
134
316
|
}
|
|
135
317
|
}
|
|
136
|
-
const DEFAULT_EXPIRY_DURATION_SECS = 7 * 24 * 60 * 60; // 7 days
|
|
137
|
-
function getExpiry() {
|
|
138
|
-
return createExpiryFromDuration(DEFAULT_EXPIRY_DURATION_SECS);
|
|
139
|
-
}
|
|
140
318
|
function createRequestChannel(sessionId) {
|
|
141
319
|
return khash(sessionId, stringToBytes('request'));
|
|
142
320
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
|
+
import { ok, okAsync } from 'neverthrow';
|
|
3
|
+
import { Bytes, str } from 'scale-ts';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
|
|
6
|
+
import { StatementData } from './scale/statementData.js';
|
|
7
|
+
import { createSession, nextExpiry } from './session.js';
|
|
8
|
+
// ── test helpers ──────────────────────────────────────────────────────────────
|
|
9
|
+
function makeAccounts() {
|
|
10
|
+
const localAccount = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(1)));
|
|
11
|
+
const remoteAccount = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(2)), new Uint8Array(32).fill(3));
|
|
12
|
+
return { localAccount, remoteAccount };
|
|
13
|
+
}
|
|
14
|
+
function makeEncryption() {
|
|
15
|
+
return {
|
|
16
|
+
encrypt: (data) => ok(data),
|
|
17
|
+
decrypt: (data) => ok(data),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function makeProver() {
|
|
21
|
+
return {
|
|
22
|
+
generateMessageProof: s => okAsync({
|
|
23
|
+
...s,
|
|
24
|
+
proof: {
|
|
25
|
+
type: 'sr25519',
|
|
26
|
+
value: {
|
|
27
|
+
signature: `0x${'00'.repeat(64)}`,
|
|
28
|
+
signer: `0x${'00'.repeat(32)}`,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
verifyMessageProof: () => okAsync(true),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeAdapter() {
|
|
36
|
+
const unsub = vi.fn();
|
|
37
|
+
return {
|
|
38
|
+
queryStatements: vi.fn().mockReturnValue(okAsync([])),
|
|
39
|
+
subscribeStatements: vi.fn().mockReturnValue(unsub),
|
|
40
|
+
submitStatement: vi.fn().mockReturnValue(okAsync(undefined)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function makeStatement(statementData, expiry) {
|
|
44
|
+
return {
|
|
45
|
+
expiry: expiry ?? createExpiryFromDuration(7 * 24 * 60 * 60),
|
|
46
|
+
data: StatementData.enc(statementData),
|
|
47
|
+
topics: [],
|
|
48
|
+
channel: `0x${'00'.repeat(32)}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function makeSession(overrides) {
|
|
52
|
+
const { localAccount, remoteAccount } = makeAccounts();
|
|
53
|
+
const { maxRequestSize, ...adapterOverrides } = overrides ?? {};
|
|
54
|
+
const adapter = { ...makeAdapter(), ...adapterOverrides };
|
|
55
|
+
const session = createSession({
|
|
56
|
+
localAccount,
|
|
57
|
+
remoteAccount,
|
|
58
|
+
statementStore: adapter,
|
|
59
|
+
encryption: makeEncryption(),
|
|
60
|
+
prover: makeProver(),
|
|
61
|
+
maxRequestSize,
|
|
62
|
+
});
|
|
63
|
+
return { session, adapter };
|
|
64
|
+
}
|
|
65
|
+
async function flushPromises() {
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
67
|
+
}
|
|
68
|
+
describe('session', () => {
|
|
69
|
+
describe('nextExpiry', () => {
|
|
70
|
+
it('returns fresh expiry when current is 0', () => {
|
|
71
|
+
const result = nextExpiry(0n);
|
|
72
|
+
expect(result).toBeGreaterThan(0n);
|
|
73
|
+
});
|
|
74
|
+
it('returns fresh expiry when current is less than fresh', () => {
|
|
75
|
+
const stale = createExpiryFromDuration(1); // 1 second from now, will be smaller than 7-day
|
|
76
|
+
const result = nextExpiry(stale);
|
|
77
|
+
const fresh = createExpiryFromDuration(7 * 24 * 60 * 60);
|
|
78
|
+
expect(result).toBeGreaterThanOrEqual(fresh);
|
|
79
|
+
});
|
|
80
|
+
it('returns current + 1n when current is already at or above fresh', () => {
|
|
81
|
+
const high = createExpiryFromDuration(7 * 24 * 60 * 60 + 999999);
|
|
82
|
+
const result = nextExpiry(high);
|
|
83
|
+
expect(result).toBe(high + 1n);
|
|
84
|
+
});
|
|
85
|
+
it('is monotonically increasing across repeated calls', () => {
|
|
86
|
+
let expiry = 0n;
|
|
87
|
+
for (let i = 0; i < 5; i++) {
|
|
88
|
+
const next = nextExpiry(expiry);
|
|
89
|
+
expect(next).toBeGreaterThan(expiry);
|
|
90
|
+
expiry = next;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('createSession initialization', () => {
|
|
95
|
+
it('queries own and peer statements on creation', async () => {
|
|
96
|
+
const { adapter } = makeSession();
|
|
97
|
+
await flushPromises();
|
|
98
|
+
expect(adapter.queryStatements).toHaveBeenCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
it('expiry is initialized from max own statement expiry', async () => {
|
|
101
|
+
const highExpiry = createExpiryFromDuration(7 * 24 * 60 * 60) + 9999n;
|
|
102
|
+
const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'r1', data: [] } }, highExpiry);
|
|
103
|
+
const adapter = makeAdapter();
|
|
104
|
+
let firstCall = true;
|
|
105
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
106
|
+
if (firstCall) {
|
|
107
|
+
firstCall = false;
|
|
108
|
+
return okAsync([ownRequest]);
|
|
109
|
+
}
|
|
110
|
+
return okAsync([]);
|
|
111
|
+
});
|
|
112
|
+
const { session } = makeSession(adapter);
|
|
113
|
+
await flushPromises();
|
|
114
|
+
// Submit a message to trigger a statement — its expiry must be greater than highExpiry
|
|
115
|
+
const rawCodec = Bytes();
|
|
116
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
117
|
+
await flushPromises();
|
|
118
|
+
const submittedStatement = adapter.submitStatement.mock.calls[0]?.[0];
|
|
119
|
+
if (submittedStatement) {
|
|
120
|
+
expect(submittedStatement.expiry).toBeGreaterThan(highExpiry);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
it('marks own and peer statement data as seen during init', async () => {
|
|
124
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'r2', data: [new Uint8Array([1])] } });
|
|
125
|
+
const adapter = makeAdapter();
|
|
126
|
+
let callCount = 0;
|
|
127
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
128
|
+
callCount++;
|
|
129
|
+
if (callCount === 2)
|
|
130
|
+
return okAsync([peerRequest]);
|
|
131
|
+
return okAsync([]);
|
|
132
|
+
});
|
|
133
|
+
const { session } = makeSession(adapter);
|
|
134
|
+
await flushPromises();
|
|
135
|
+
// Register subscriber AFTER init — buffered incoming request should be delivered
|
|
136
|
+
const callback = vi.fn();
|
|
137
|
+
session.subscribe(Bytes(), callback);
|
|
138
|
+
expect(callback).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
it('transitions to active phase after queries complete', async () => {
|
|
141
|
+
const { session, adapter } = makeSession();
|
|
142
|
+
// Before init completes, submitRequestMessage queues the message
|
|
143
|
+
const rawCodec = str;
|
|
144
|
+
void session.submitRequestMessage(rawCodec, 'hello');
|
|
145
|
+
// Statement should NOT be submitted yet (still initializing)
|
|
146
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
147
|
+
await flushPromises();
|
|
148
|
+
// After init, queued messages are processed → submitStatement called
|
|
149
|
+
expect(adapter.submitStatement).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe('session state restoration', () => {
|
|
153
|
+
it('restores outgoingRequest when own has request with no peer response', async () => {
|
|
154
|
+
const requestId = 'saved-request-id';
|
|
155
|
+
const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
156
|
+
const adapter = makeAdapter();
|
|
157
|
+
let callCount = 0;
|
|
158
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
159
|
+
callCount++;
|
|
160
|
+
if (callCount === 1)
|
|
161
|
+
return okAsync([ownRequest]);
|
|
162
|
+
return okAsync([]); // no peer response
|
|
163
|
+
});
|
|
164
|
+
const { session } = makeSession(adapter);
|
|
165
|
+
await flushPromises();
|
|
166
|
+
// If outgoingRequest was restored, a new message appends to it
|
|
167
|
+
const codec = str;
|
|
168
|
+
void session.submitRequestMessage(codec, 'hello');
|
|
169
|
+
await flushPromises();
|
|
170
|
+
expect(adapter.submitStatement).toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
it('clears outgoingRequest when peer has a matching response', async () => {
|
|
173
|
+
const requestId = 'acked-request';
|
|
174
|
+
const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
175
|
+
const peerResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
|
|
176
|
+
const adapter = makeAdapter();
|
|
177
|
+
let callCount = 0;
|
|
178
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
179
|
+
callCount++;
|
|
180
|
+
if (callCount === 1)
|
|
181
|
+
return okAsync([ownRequest]);
|
|
182
|
+
return okAsync([peerResponse]);
|
|
183
|
+
});
|
|
184
|
+
const { session } = makeSession(adapter);
|
|
185
|
+
await flushPromises();
|
|
186
|
+
// No pending outgoing request — new message creates a brand new request
|
|
187
|
+
const codec = str;
|
|
188
|
+
void session.submitRequestMessage(codec, 'hi');
|
|
189
|
+
await flushPromises();
|
|
190
|
+
// submitStatement called exactly once (for the new message only)
|
|
191
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
|
|
192
|
+
});
|
|
193
|
+
it('restores incomingRequest from peer statements', async () => {
|
|
194
|
+
const requestId = 'peer-request-id';
|
|
195
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
196
|
+
const adapter = makeAdapter();
|
|
197
|
+
let callCount = 0;
|
|
198
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
199
|
+
callCount++;
|
|
200
|
+
if (callCount === 2)
|
|
201
|
+
return okAsync([peerRequest]);
|
|
202
|
+
return okAsync([]);
|
|
203
|
+
});
|
|
204
|
+
const { session } = makeSession(adapter);
|
|
205
|
+
await flushPromises();
|
|
206
|
+
// Calling submitResponseMessage with restored requestId should succeed
|
|
207
|
+
const result = await session.submitResponseMessage(requestId, 'success');
|
|
208
|
+
expect(result.isOk()).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
it('sets respondedIncomingRequest=true when own has a response for the peer request', async () => {
|
|
211
|
+
const requestId = 'peer-request-id';
|
|
212
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
213
|
+
const ownResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
|
|
214
|
+
const adapter = makeAdapter();
|
|
215
|
+
let callCount = 0;
|
|
216
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
217
|
+
callCount++;
|
|
218
|
+
if (callCount === 1)
|
|
219
|
+
return okAsync([ownResponse]);
|
|
220
|
+
return okAsync([peerRequest]);
|
|
221
|
+
});
|
|
222
|
+
const { session } = makeSession(adapter);
|
|
223
|
+
await flushPromises();
|
|
224
|
+
// Already responded — submitResponseMessage should return ok without submitting again
|
|
225
|
+
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
226
|
+
const result = await session.submitResponseMessage(requestId, 'success');
|
|
227
|
+
expect(result.isOk()).toBe(true);
|
|
228
|
+
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // no new submit
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('subscribe', () => {
|
|
232
|
+
const rawCodec = Bytes();
|
|
233
|
+
it('delivers buffered init messages when subscriber registers after init', async () => {
|
|
234
|
+
const requestId = 'incoming-req';
|
|
235
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1, 2, 3])] } });
|
|
236
|
+
const adapter = makeAdapter();
|
|
237
|
+
let callCount = 0;
|
|
238
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
239
|
+
callCount++;
|
|
240
|
+
if (callCount === 2)
|
|
241
|
+
return okAsync([peerRequest]);
|
|
242
|
+
return okAsync([]);
|
|
243
|
+
});
|
|
244
|
+
const { session } = makeSession(adapter);
|
|
245
|
+
await flushPromises(); // init completes
|
|
246
|
+
const callback = vi.fn();
|
|
247
|
+
session.subscribe(rawCodec, callback);
|
|
248
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
249
|
+
const messages = callback.mock.calls[0][0];
|
|
250
|
+
expect(messages[0]?.type).toBe('request');
|
|
251
|
+
expect(messages[0]?.requestId).toBe(requestId);
|
|
252
|
+
});
|
|
253
|
+
it('delivers init messages via subscribe when subscriber is registered before init completes', async () => {
|
|
254
|
+
const requestId = 'early-subscribe';
|
|
255
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
|
|
256
|
+
const adapter = makeAdapter();
|
|
257
|
+
let callCount = 0;
|
|
258
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
259
|
+
callCount++;
|
|
260
|
+
if (callCount === 2)
|
|
261
|
+
return okAsync([peerRequest]);
|
|
262
|
+
return okAsync([]);
|
|
263
|
+
});
|
|
264
|
+
const { session } = makeSession(adapter);
|
|
265
|
+
const callback = vi.fn();
|
|
266
|
+
session.subscribe(rawCodec, callback); // before init completes
|
|
267
|
+
await flushPromises();
|
|
268
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
269
|
+
const messages2 = callback.mock.calls[0][0];
|
|
270
|
+
expect(messages2[0]?.requestId).toBe(requestId);
|
|
271
|
+
});
|
|
272
|
+
it('does NOT deliver already-seen statements from subscription', async () => {
|
|
273
|
+
const requestId = 'seen-req';
|
|
274
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
|
|
275
|
+
const adapter = makeAdapter();
|
|
276
|
+
let queryCallCount = 0;
|
|
277
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
278
|
+
queryCallCount++;
|
|
279
|
+
if (queryCallCount === 2)
|
|
280
|
+
return okAsync([peerRequest]);
|
|
281
|
+
return okAsync([]);
|
|
282
|
+
});
|
|
283
|
+
let subscribeCallback;
|
|
284
|
+
adapter.subscribeStatements.mockImplementation((_topics, cb) => {
|
|
285
|
+
subscribeCallback = cb;
|
|
286
|
+
return vi.fn();
|
|
287
|
+
});
|
|
288
|
+
const { session } = makeSession(adapter);
|
|
289
|
+
await flushPromises(); // init sees peerRequest, adds to seenStatements
|
|
290
|
+
const appCallback = vi.fn();
|
|
291
|
+
session.subscribe(rawCodec, appCallback);
|
|
292
|
+
// Simulate subscription delivering the same statement again
|
|
293
|
+
subscribeCallback([peerRequest]);
|
|
294
|
+
await flushPromises();
|
|
295
|
+
// Should only be called once (from buffered init message), not again from subscription
|
|
296
|
+
expect(appCallback).toHaveBeenCalledTimes(1);
|
|
297
|
+
});
|
|
298
|
+
it('does NOT auto-send a response when an incoming request arrives', async () => {
|
|
299
|
+
const requestId = 'no-auto-resp';
|
|
300
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
|
|
301
|
+
const adapter = makeAdapter();
|
|
302
|
+
let subscribeCallback;
|
|
303
|
+
adapter.subscribeStatements.mockImplementation((_topics, cb) => {
|
|
304
|
+
subscribeCallback = cb;
|
|
305
|
+
return vi.fn();
|
|
306
|
+
});
|
|
307
|
+
adapter.queryStatements.mockReturnValue(okAsync([]));
|
|
308
|
+
const { session } = makeSession(adapter);
|
|
309
|
+
await flushPromises();
|
|
310
|
+
const callback = vi.fn();
|
|
311
|
+
session.subscribe(rawCodec, callback);
|
|
312
|
+
adapter.submitStatement.mockClear();
|
|
313
|
+
subscribeCallback([peerRequest]);
|
|
314
|
+
await flushPromises();
|
|
315
|
+
// Message delivered to app callback but no automatic response submitted
|
|
316
|
+
expect(callback).toHaveBeenCalled();
|
|
317
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
it('unsubscribing last subscriber tears down the store subscription', () => {
|
|
320
|
+
const { session, adapter } = makeSession();
|
|
321
|
+
const unsub = session.subscribe(rawCodec, vi.fn());
|
|
322
|
+
expect(adapter.subscribeStatements).toHaveBeenCalledTimes(1);
|
|
323
|
+
unsub();
|
|
324
|
+
// subscribeStatements returns a mock unsubscribe fn — verify it was called
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
326
|
+
const storeMockUnsub = adapter.subscribeStatements.mock.results[0].value;
|
|
327
|
+
expect(storeMockUnsub).toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
describe('submitResponseMessage', () => {
|
|
331
|
+
it('is idempotent — second call does not submit again', async () => {
|
|
332
|
+
const requestId = 'req-to-respond';
|
|
333
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
334
|
+
const adapter = makeAdapter();
|
|
335
|
+
let callCount = 0;
|
|
336
|
+
adapter.queryStatements.mockImplementation(() => {
|
|
337
|
+
callCount++;
|
|
338
|
+
if (callCount === 2)
|
|
339
|
+
return okAsync([peerRequest]);
|
|
340
|
+
return okAsync([]);
|
|
341
|
+
});
|
|
342
|
+
const { session } = makeSession(adapter);
|
|
343
|
+
await flushPromises();
|
|
344
|
+
await session.submitResponseMessage(requestId, 'success');
|
|
345
|
+
const submitsAfterFirst = adapter.submitStatement.mock.calls.length;
|
|
346
|
+
await session.submitResponseMessage(requestId, 'success'); // second call
|
|
347
|
+
expect(adapter.submitStatement.mock.calls.length).toBe(submitsAfterFirst);
|
|
348
|
+
});
|
|
349
|
+
it('returns error when requestId does not match incomingRequest', async () => {
|
|
350
|
+
const { session } = makeSession();
|
|
351
|
+
await flushPromises();
|
|
352
|
+
const result = await session.submitResponseMessage('wrong-id', 'success');
|
|
353
|
+
expect(result.isErr()).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
describe('message batching', () => {
|
|
357
|
+
const rawCodec = Bytes();
|
|
358
|
+
it('sends a single statement for the first message', async () => {
|
|
359
|
+
const { session, adapter } = makeSession();
|
|
360
|
+
await flushPromises();
|
|
361
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
362
|
+
await flushPromises();
|
|
363
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
|
|
364
|
+
});
|
|
365
|
+
it('appends second message to existing request (resubmits with new requestId)', async () => {
|
|
366
|
+
const { session, adapter } = makeSession();
|
|
367
|
+
await flushPromises();
|
|
368
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
369
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([2]));
|
|
370
|
+
await flushPromises();
|
|
371
|
+
// Two submits: first for msg1, second for msg1+msg2 batched
|
|
372
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
|
|
373
|
+
});
|
|
374
|
+
it('queues message that exceeds maxRequestSize', async () => {
|
|
375
|
+
const { session, adapter } = makeSession({ maxRequestSize: 5 });
|
|
376
|
+
await flushPromises();
|
|
377
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // 3 bytes — fits
|
|
378
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6, 7])); // 4 bytes — doesn't fit with existing
|
|
379
|
+
await flushPromises();
|
|
380
|
+
// Only first message sent; second is queued
|
|
381
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
|
|
382
|
+
});
|
|
383
|
+
it('drains message queue after response received', async () => {
|
|
384
|
+
let subscribeCallback;
|
|
385
|
+
const subscribeStatements = vi
|
|
386
|
+
.fn()
|
|
387
|
+
.mockImplementation((_topics, cb) => {
|
|
388
|
+
subscribeCallback = cb;
|
|
389
|
+
return vi.fn();
|
|
390
|
+
});
|
|
391
|
+
const { session, adapter } = makeSession({ maxRequestSize: 5, subscribeStatements });
|
|
392
|
+
await flushPromises();
|
|
393
|
+
session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
|
|
394
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
|
|
395
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
|
|
396
|
+
await flushPromises();
|
|
397
|
+
const submitCountBefore = adapter.submitStatement.mock.calls.length;
|
|
398
|
+
// Simulate peer responding to the first request
|
|
399
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
400
|
+
const lastSubmittedStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
|
|
401
|
+
const decoded = StatementData.dec(lastSubmittedStatement.data);
|
|
402
|
+
const respondingRequestId = decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
403
|
+
const responseStatement = makeStatement({
|
|
404
|
+
tag: 'response',
|
|
405
|
+
value: { requestId: respondingRequestId, responseCode: 'success' },
|
|
406
|
+
});
|
|
407
|
+
subscribeCallback([responseStatement]);
|
|
408
|
+
await flushPromises();
|
|
409
|
+
// Queued message should now be submitted
|
|
410
|
+
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitCountBefore);
|
|
411
|
+
});
|
|
412
|
+
it('waitForResponseMessage resolves when response arrives for batch', async () => {
|
|
413
|
+
let subscribeCallback;
|
|
414
|
+
const subscribeStatements = vi
|
|
415
|
+
.fn()
|
|
416
|
+
.mockImplementation((_topics, cb) => {
|
|
417
|
+
subscribeCallback = cb;
|
|
418
|
+
return vi.fn();
|
|
419
|
+
});
|
|
420
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
421
|
+
await flushPromises();
|
|
422
|
+
session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
|
|
423
|
+
const submitResult = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
424
|
+
const token = submitResult.unwrapOr({ requestId: '' }).requestId;
|
|
425
|
+
await flushPromises();
|
|
426
|
+
const responsePromise = session.waitForResponseMessage(token);
|
|
427
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
428
|
+
const lastStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
|
|
429
|
+
const decoded = StatementData.dec(lastStatement.data);
|
|
430
|
+
const respondingId = decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
431
|
+
subscribeCallback([
|
|
432
|
+
makeStatement({ tag: 'response', value: { requestId: respondingId, responseCode: 'success' } }),
|
|
433
|
+
]);
|
|
434
|
+
await flushPromises();
|
|
435
|
+
const result = await responsePromise;
|
|
436
|
+
expect(result.isOk()).toBe(true);
|
|
437
|
+
expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
});
|