@novasamatech/statement-store 0.9.0 → 0.9.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/helpers.d.ts +0 -1
- package/dist/helpers.js +0 -3
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/session/codec/decoder.d.ts +68 -0
- package/dist/session/codec/decoder.js +96 -0
- package/dist/session/codec/decoder.spec.d.ts +1 -0
- package/dist/session/codec/decoder.spec.js +161 -0
- package/dist/session/codec/envelope.d.ts +55 -0
- package/dist/session/codec/envelope.js +115 -0
- package/dist/session/codec/envelope.spec.d.ts +1 -0
- package/dist/session/codec/envelope.spec.js +114 -0
- package/dist/session/codec/incomingTopics.d.ts +46 -0
- package/dist/session/codec/incomingTopics.js +69 -0
- package/dist/session/codec/outgoingBody.d.ts +46 -0
- package/dist/session/codec/outgoingBody.js +64 -0
- package/dist/session/core.d.ts +58 -0
- package/dist/session/core.js +609 -0
- package/dist/session/messageMapper.d.ts +3 -3
- package/dist/session/messageMapper.js +8 -8
- package/dist/session/multiDeviceSession.d.ts +49 -0
- package/dist/session/multiDeviceSession.js +62 -0
- package/dist/session/multiDeviceSession.spec.d.ts +6 -0
- package/dist/session/multiDeviceSession.spec.js +354 -0
- package/dist/session/scale/statementData.d.ts +40 -0
- package/dist/session/scale/statementData.js +32 -0
- package/dist/session/session.d.ts +15 -5
- package/dist/session/session.js +31 -633
- package/dist/session/session.spec.js +22 -6
- package/dist/session/stateMachine.d.ts +135 -0
- package/dist/session/stateMachine.js +203 -0
- package/dist/session/stateMachine.spec.d.ts +1 -0
- package/dist/session/stateMachine.spec.js +276 -0
- package/package.json +4 -3
package/dist/session/session.js
CHANGED
|
@@ -1,639 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Single-device session (base-spec.md): the pairwise Request/Response transport used by
|
|
3
|
+
* SSO and any peer known to run exactly one device.
|
|
4
|
+
*
|
|
5
|
+
* See `multiDeviceSession.ts` for the mds.md variant, and `core.ts` for the shared driver.
|
|
6
|
+
*/
|
|
7
7
|
import { createSessionId } from '../model/session.js';
|
|
8
8
|
import { createExpiryAllocator } from '../submit/allocator.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { StatementData } from './scale/statementData.js';
|
|
14
|
-
const DEFAULT_MAX_REQUEST_SIZE = 4096;
|
|
15
|
-
// Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
|
|
16
|
-
// always fails new and in-flight work the same way.
|
|
17
|
-
const SESSION_DISPOSED = 'Session disposed';
|
|
18
|
-
// Bounded retry for transient transport failures (the spec mandates retrying queries
|
|
19
|
-
// and submit_statement on connection failure). The TS adapter doesn't expose connection
|
|
20
|
-
// state, so we approximate with a short fixed backoff and an attempt cap.
|
|
21
|
-
const MAX_INIT_RETRIES = 3;
|
|
22
|
-
const MAX_SUBMIT_RETRIES = 3;
|
|
23
|
-
const RETRY_DELAY_MS = 25;
|
|
9
|
+
import { createStatementDecoder } from './codec/decoder.js';
|
|
10
|
+
import { createStaticTopics } from './codec/incomingTopics.js';
|
|
11
|
+
import { createBodyBuilder } from './codec/outgoingBody.js';
|
|
12
|
+
import { DEFAULT_MAX_REQUEST_SIZE, createSessionCore } from './core.js';
|
|
24
13
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
14
|
+
* Single-device session (base-spec.md). Wire output is unchanged from before the
|
|
15
|
+
* multi-device seams existed: `StatementData.request`/`.response` on
|
|
16
|
+
* `SessionId(A, B)`, listening on `SessionId(B, A)`.
|
|
29
17
|
*/
|
|
30
|
-
export const STATEMENT_OVERHEAD = 32 + 32 + 8 + 64 + 32; // 168 bytes
|
|
31
|
-
// Encode/decode a StatementData envelope, surfacing scale-ts throws as a Result.
|
|
32
|
-
const encodeStatementData = fromThrowable(StatementData.enc, toError);
|
|
33
|
-
const decodeStatementData = fromThrowable(StatementData.dec, toError);
|
|
34
|
-
// Best-effort recovery of the requestId from a decrypted-but-undecodable payload. The requestId
|
|
35
|
-
// is the first field after the enum tag, so it usually survives a corrupt message body. Only
|
|
36
|
-
// requests (tag 0) carry an id we should answer; responses (tag 1) and unrecoverable payloads
|
|
37
|
-
// return null and are dropped rather than NACKed.
|
|
38
|
-
const RequestIdPrefix = Struct({ requestId: str });
|
|
39
|
-
const decodeRequestIdPrefix = fromThrowable(
|
|
40
|
-
// slice (a copy), not subarray: scale-ts decodes from the backing buffer start and ignores
|
|
41
|
-
// a view's byteOffset, so a subarray would be read from the wrong position.
|
|
42
|
-
(decrypted) => RequestIdPrefix.dec(decrypted.slice(1)).requestId, () => null);
|
|
43
|
-
function recoverRequestId(decrypted) {
|
|
44
|
-
if (decrypted.length < 1 || decrypted[0] !== 0)
|
|
45
|
-
return null;
|
|
46
|
-
return decodeRequestIdPrefix(decrypted).unwrapOr(null);
|
|
47
|
-
}
|
|
48
|
-
// nanoid() is fixed-length, so the requestId contributes a constant size; any
|
|
49
|
-
// placeholder of that length yields the real encoded size.
|
|
50
|
-
const SIZING_REQUEST_ID = 'x'.repeat(21);
|
|
51
|
-
// Encoded size of the request payload these messages would occupy in a statement's `data`
|
|
52
|
-
// field — the full SCALE envelope (requestId + vector framing), not just the raw bytes. This
|
|
53
|
-
// is what must fit the per-statement budget, matching iOS/Android (which size the full payload).
|
|
54
|
-
function requestPayloadSize(messages) {
|
|
55
|
-
return encodeStatementData({ tag: 'request', value: { requestId: SIZING_REQUEST_ID, data: messages } })
|
|
56
|
-
.map(d => d.length)
|
|
57
|
-
.unwrapOr(Number.MAX_SAFE_INTEGER); // unencodable → treat as "doesn't fit"
|
|
58
|
-
}
|
|
59
|
-
// A response promise paired with its resolver/rejecter. The pre-attached catch
|
|
60
|
-
// keeps a clearOutgoingStatement()/dispose() rejection from surfacing as an
|
|
61
|
-
// unhandled rejection when no caller awaited it via waitForResponseMessage().
|
|
62
|
-
function makeDeferred() {
|
|
63
|
-
let resolve;
|
|
64
|
-
let reject;
|
|
65
|
-
const promise = new Promise((res, rej) => {
|
|
66
|
-
resolve = res;
|
|
67
|
-
reject = rej;
|
|
68
|
-
});
|
|
69
|
-
promise.catch(() => undefined);
|
|
70
|
-
return { resolve, reject, promise };
|
|
71
|
-
}
|
|
72
18
|
export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, allocator = createExpiryAllocator(), maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// can settle them instead of leaving them to hang forever.
|
|
92
|
-
const requestWaiters = new Set();
|
|
93
|
-
const bufferedMessages = [];
|
|
94
|
-
let storeUnsub = null;
|
|
95
|
-
let initRetries = 0;
|
|
96
|
-
let initRetryTimer = null;
|
|
97
|
-
let disposed = false;
|
|
98
|
-
// Id of the most recent response we initiated (responses share one channel, so only the
|
|
99
|
-
// latest is live — a retry for an older one must not resurrect it).
|
|
100
|
-
let lastResponseRequestId = null;
|
|
101
|
-
// Encrypt, then submit on `channel`/`topicSessionId` at the allocator's next (strictly
|
|
102
|
-
// increasing) expiry. A priority rejection does NOT resync the allocator here — each caller
|
|
103
|
-
// raises the floor to the chain-reported minimum (the submitWithRetry `onPriorityError` hooks
|
|
104
|
-
// below, and the one-shot clear in clearOutgoingStatement), so the retry — and every later
|
|
105
|
-
// submit — clears it.
|
|
106
|
-
function submitStatementData(channel, topicSessionId, data) {
|
|
107
|
-
return encryption.encrypt(data).asyncAndThen(encrypted => submitStatementOnce({
|
|
108
|
-
statementStore,
|
|
109
|
-
prover,
|
|
110
|
-
allocator,
|
|
111
|
-
channel,
|
|
112
|
-
topics: [topicSessionId],
|
|
113
|
-
data: encrypted,
|
|
114
|
-
}));
|
|
115
|
-
}
|
|
116
|
-
// Settle and remove the pending-delivery entries for the given tokens.
|
|
117
|
-
function settleTokens(tokens, settle) {
|
|
118
|
-
for (const token of tokens) {
|
|
119
|
-
const deferred = state.pendingDelivery.get(token);
|
|
120
|
-
if (deferred) {
|
|
121
|
-
settle(deferred);
|
|
122
|
-
state.pendingDelivery.delete(token);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Session retry policy (this and every submitWithRetry call below): priority errors
|
|
127
|
-
// (ExpiryTooLow / AccountFull) are retried with `priorityAttempts: 'unbounded'` — they never
|
|
128
|
-
// consume the transient-failure budget, because the `onPriorityError` hook raises the allocator
|
|
129
|
-
// floor above the chain-reported minimum, so the next attempt submits higher. We keep at it
|
|
130
|
-
// until the statement lands or the submission is superseded; once superseded, a priority
|
|
131
|
-
// rejection is swallowed as success (it merely lost the channel race to a newer, higher-priority
|
|
132
|
-
// statement). The upshot: priority errors never surface to session callers. Other errors keep
|
|
133
|
-
// the bounded retry and propagate when exhausted. `shouldRetry` is re-checked before each retry:
|
|
134
|
-
// once the submission is superseded, aborted, or the session is disposed it returns false, so a
|
|
135
|
-
// stale retry can never resurrect an old statement.
|
|
136
|
-
function encodeAndSubmitRequest(requestId, messages) {
|
|
137
|
-
encodeStatementData({ tag: 'request', value: { requestId, data: messages } })
|
|
138
|
-
.asyncAndThen(data => submitWithRetry(() => submitStatementData(requestChannel, outgoingSessionId, data), {
|
|
139
|
-
attempts: MAX_SUBMIT_RETRIES,
|
|
140
|
-
priorityAttempts: 'unbounded',
|
|
141
|
-
delaysMs: RETRY_DELAY_MS,
|
|
142
|
-
// Adopt the chain-reported floor so the next attempt submits strictly above it.
|
|
143
|
-
onPriorityError: error => allocator.raiseFloor(error.min),
|
|
144
|
-
// Only keep retrying while this is still the live submission (not superseded by a
|
|
145
|
-
// newer retransmit, aborted via clearOutgoingStatement, or disposed).
|
|
146
|
-
shouldRetry: () => !disposed && state.outgoingRequest?.requestIds.at(-1) === requestId,
|
|
147
|
-
}))
|
|
148
|
-
.mapErr(e => {
|
|
149
|
-
// Priority errors never reach here (see the policy note above), so this is a genuine
|
|
150
|
-
// failure. If this submission was already superseded by a newer retransmit (same tokens)
|
|
151
|
-
// it is not the live request's concern — drop it silently; the newer one carries the
|
|
152
|
-
// waiters. Otherwise the bounded retries are exhausted on the LIVE submission: the
|
|
153
|
-
// request never landed, so fail its waiters rather than let them hang.
|
|
154
|
-
const outgoing = state.outgoingRequest;
|
|
155
|
-
if (disposed || !outgoing || outgoing.requestIds.at(-1) !== requestId)
|
|
156
|
-
return;
|
|
157
|
-
console.error('submitRequest failed:', e);
|
|
158
|
-
settleTokens(outgoing.tokens, deferred => deferred.reject(e));
|
|
159
|
-
state.outgoingRequest = null;
|
|
160
|
-
processMessageQueue();
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
function deliverStatementData(statementData) {
|
|
164
|
-
// Buffer 'request' statements unconditionally so that waitForRequestMessage
|
|
165
|
-
// registered after delivery (race condition) still receives them via subscribe() replay.
|
|
166
|
-
// Buffer everything else during initialization when there are no subscribers yet.
|
|
167
|
-
if (statementData.tag === 'request' || (subscribers.length === 0 && state.phase === 'initialization')) {
|
|
168
|
-
bufferedMessages.push(statementData);
|
|
169
|
-
}
|
|
170
|
-
if (subscribers.length === 0)
|
|
171
|
-
return;
|
|
172
|
-
for (const sub of subscribers) {
|
|
173
|
-
const messages = toMessage(statementData, sub.codec);
|
|
174
|
-
if (messages.length > 0)
|
|
175
|
-
sub.callback(messages);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
function tryDecodeStatement(statement) {
|
|
179
|
-
if (!statement.data)
|
|
180
|
-
return okAsync({ kind: 'undecodable', requestId: null });
|
|
181
|
-
const data = statement.data;
|
|
182
|
-
return (prover
|
|
183
|
-
.verifyMessageProof(statement)
|
|
184
|
-
.andThen(verified => (verified ? ok() : err(new Error('Invalid proof'))))
|
|
185
|
-
.andThen(() => encryption.decrypt(data))
|
|
186
|
-
.map(decrypted => {
|
|
187
|
-
const decoded = decodeStatementData(decrypted);
|
|
188
|
-
return decoded.isOk()
|
|
189
|
-
? { kind: 'decoded', data: decoded.value }
|
|
190
|
-
: { kind: 'undecodable', requestId: recoverRequestId(decrypted) };
|
|
191
|
-
})
|
|
192
|
-
// Proof or decryption failure: the payload (incl. the requestId) is unreadable → drop.
|
|
193
|
-
.orElse(() => okAsync({ kind: 'undecodable', requestId: null })));
|
|
194
|
-
}
|
|
195
|
-
function processIncomingStatement(statement) {
|
|
196
|
-
if (!statement.data)
|
|
197
|
-
return;
|
|
198
|
-
const key = toHex(statement.data);
|
|
199
|
-
if (state.seenStatements.has(key))
|
|
200
|
-
return;
|
|
201
|
-
state.seenStatements.add(key);
|
|
202
|
-
void tryDecodeStatement(statement).andTee(outcome => {
|
|
203
|
-
if (outcome.kind === 'undecodable') {
|
|
204
|
-
if (outcome.requestId === null) {
|
|
205
|
-
// Proof/decryption failed, or no requestId was recoverable — nothing to NACK.
|
|
206
|
-
console.warn('statement-store: dropping an undecodable incoming statement (no recoverable requestId)');
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
// Only NACK a genuinely new id. If we already know this request, a valid copy is being
|
|
210
|
-
// handled (or was already answered) — NACKing now would mask the real response, since the
|
|
211
|
-
// `responded` flag is sticky.
|
|
212
|
-
if (state.incomingRequests.has(outcome.requestId))
|
|
213
|
-
return;
|
|
214
|
-
// Decrypted but the message body is malformed — NACK so the sender stops waiting.
|
|
215
|
-
state.incomingRequests.set(outcome.requestId, { responded: false });
|
|
216
|
-
void session
|
|
217
|
-
.submitResponseMessage(outcome.requestId, 'decodingFailed')
|
|
218
|
-
.mapErr(e => console.error('statement-store: failed to NACK an undecodable request:', e));
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
const statementData = outcome.data;
|
|
222
|
-
if (statementData.tag === 'request') {
|
|
223
|
-
const requestId = statementData.value.requestId;
|
|
224
|
-
if (state.incomingRequests.has(requestId))
|
|
225
|
-
return;
|
|
226
|
-
state.incomingRequests.set(requestId, { responded: false });
|
|
227
|
-
deliverStatementData(statementData);
|
|
228
|
-
}
|
|
229
|
-
else if (statementData.tag === 'response') {
|
|
230
|
-
const outgoing = state.outgoingRequest;
|
|
231
|
-
if (!outgoing?.requestIds.includes(statementData.value.requestId))
|
|
232
|
-
return;
|
|
233
|
-
const responseMessage = {
|
|
234
|
-
type: 'response',
|
|
235
|
-
localId: statementData.value.requestId,
|
|
236
|
-
requestId: statementData.value.requestId,
|
|
237
|
-
responseCode: statementData.value.responseCode,
|
|
238
|
-
};
|
|
239
|
-
settleTokens(outgoing.tokens, deferred => deferred.resolve(responseMessage));
|
|
240
|
-
state.outgoingRequest = null;
|
|
241
|
-
deliverStatementData(statementData);
|
|
242
|
-
processMessageQueue();
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
// Returns true if `encoded` matches a message already in flight or queued, after
|
|
247
|
-
// attaching `token` to it so the caller resolves on that message's response
|
|
248
|
-
// instead of the bytes being submitted a second time.
|
|
249
|
-
function attachToDuplicate(encoded, token) {
|
|
250
|
-
const encodedHex = toHex(encoded);
|
|
251
|
-
const sameBytes = (m) => m.length === encoded.length && toHex(m) === encodedHex;
|
|
252
|
-
const outgoing = state.outgoingRequest;
|
|
253
|
-
if (outgoing && outgoing.messages.some(sameBytes)) {
|
|
254
|
-
outgoing.tokens.push(token);
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
const queued = state.messageQueue.find(entry => sameBytes(entry.encoded));
|
|
258
|
-
if (queued) {
|
|
259
|
-
queued.tokens.push(token);
|
|
260
|
-
return true;
|
|
261
|
-
}
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
function processNewMessage(encoded, tokens) {
|
|
265
|
-
if (state.outgoingRequest === null) {
|
|
266
|
-
const requestId = nanoid();
|
|
267
|
-
state.outgoingRequest = { requestIds: [requestId], messages: [encoded], tokens: [...tokens] };
|
|
268
|
-
encodeAndSubmitRequest(requestId, state.outgoingRequest.messages);
|
|
269
|
-
}
|
|
270
|
-
else if (requestPayloadSize([...state.outgoingRequest.messages, encoded]) <= maxPayloadSize) {
|
|
271
|
-
state.outgoingRequest.messages.push(encoded);
|
|
272
|
-
state.outgoingRequest.tokens.push(...tokens);
|
|
273
|
-
const newRequestId = nanoid();
|
|
274
|
-
state.outgoingRequest.requestIds.push(newRequestId);
|
|
275
|
-
encodeAndSubmitRequest(newRequestId, state.outgoingRequest.messages);
|
|
276
|
-
}
|
|
277
|
-
else {
|
|
278
|
-
state.messageQueue.push({ encoded, tokens });
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
function processMessageQueue() {
|
|
282
|
-
while (state.messageQueue.length > 0) {
|
|
283
|
-
const head = state.messageQueue[0];
|
|
284
|
-
// Recompute per iteration; `processNewMessage` mutates outgoingRequest.messages in place.
|
|
285
|
-
if (state.outgoingRequest !== null &&
|
|
286
|
-
requestPayloadSize([...state.outgoingRequest.messages, head.encoded]) > maxPayloadSize) {
|
|
287
|
-
break;
|
|
288
|
-
}
|
|
289
|
-
state.messageQueue.shift();
|
|
290
|
-
processNewMessage(head.encoded, head.tokens);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
function ensureStoreSubscription() {
|
|
294
|
-
if (storeUnsub)
|
|
295
|
-
return;
|
|
296
|
-
// A single subscription on the incoming topic carries BOTH the peer's requests
|
|
297
|
-
// and the peer's responses to our requests (the peer publishes everything on its
|
|
298
|
-
// outgoing topic = our incoming topic). We publish on the outgoing topic, which
|
|
299
|
-
// we don't subscribe to, so our own statements are never echoed back.
|
|
300
|
-
storeUnsub = statementStore.subscribeStatements({ matchAll: [incomingSessionId] }, page => {
|
|
301
|
-
for (const statement of page.statements) {
|
|
302
|
-
processIncomingStatement(statement);
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
// Once a request is answered it no longer needs to be replayed to future subscribers (and a
|
|
307
|
-
// late waitForRequestMessage must not re-receive an already-handled request). Dropping it also
|
|
308
|
-
// keeps bufferedMessages from growing unboundedly with every incoming request.
|
|
309
|
-
function pruneBufferedRequest(requestId) {
|
|
310
|
-
for (let i = bufferedMessages.length - 1; i >= 0; i--) {
|
|
311
|
-
const sd = bufferedMessages[i];
|
|
312
|
-
if (sd && sd.tag === 'request' && sd.value.requestId === requestId)
|
|
313
|
-
bufferedMessages.splice(i, 1);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
function rejectAllPending(error) {
|
|
317
|
-
for (const [, deferred] of state.pendingDelivery) {
|
|
318
|
-
deferred.reject(error);
|
|
319
|
-
}
|
|
320
|
-
state.pendingDelivery.clear();
|
|
321
|
-
}
|
|
322
|
-
function failInit(error) {
|
|
323
|
-
state.phase = 'failed';
|
|
324
|
-
state.initError = error;
|
|
325
|
-
state.messageQueue = [];
|
|
326
|
-
rejectAllPending(error);
|
|
327
|
-
}
|
|
328
|
-
async function init() {
|
|
329
|
-
const result = await ResultAsync.combine([
|
|
330
|
-
statementStore.queryStatements({ matchAll: [outgoingSessionId] }),
|
|
331
|
-
statementStore.queryStatements({ matchAll: [incomingSessionId] }),
|
|
332
|
-
]);
|
|
333
|
-
if (result.isErr()) {
|
|
334
|
-
if (disposed)
|
|
335
|
-
return;
|
|
336
|
-
// Transient transport failure: retry init (preserving the message queue) before
|
|
337
|
-
// giving up. Only after the cap is reached do we fail terminally.
|
|
338
|
-
if (initRetries < MAX_INIT_RETRIES) {
|
|
339
|
-
initRetries++;
|
|
340
|
-
// Store the handle so dispose() can cancel it — otherwise a disposed session keeps
|
|
341
|
-
// querying and can re-activate itself if a late retry succeeds.
|
|
342
|
-
initRetryTimer = setTimeout(() => {
|
|
343
|
-
initRetryTimer = null;
|
|
344
|
-
void init();
|
|
345
|
-
}, RETRY_DELAY_MS);
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
failInit(result.error);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
initRetries = 0;
|
|
352
|
-
const [ownStatements, peerStatements] = result.value;
|
|
353
|
-
let maxExpiry = 0n;
|
|
354
|
-
for (const s of ownStatements) {
|
|
355
|
-
if (s.expiry !== undefined && s.expiry > maxExpiry)
|
|
356
|
-
maxExpiry = s.expiry;
|
|
357
|
-
}
|
|
358
|
-
// Adopt the snapshot's maximum as the allocator floor. raiseFloor is monotonic — the floor
|
|
359
|
-
// never regresses — so a statement submitted while init was in flight (e.g. an auto-ACK for a
|
|
360
|
-
// peer request that arrived during the query) keeps the counter ahead of this snapshot, the
|
|
361
|
-
// same guarantee the old conditional seeding gave. The next submit then draws strictly above
|
|
362
|
-
// the seen on-chain maximum and at least the wall-clock priority, so it cannot collide at an equal expiry.
|
|
363
|
-
allocator.raiseFloor(maxExpiry);
|
|
364
|
-
for (const s of [...ownStatements, ...peerStatements]) {
|
|
365
|
-
if (s.data)
|
|
366
|
-
state.seenStatements.add(toHex(s.data));
|
|
367
|
-
}
|
|
368
|
-
const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).unwrapOr({ kind: 'undecodable', requestId: null }))).then(outcomes => outcomes.map(o => (o.kind === 'decoded' ? o.data : null)).filter(nonNullable));
|
|
369
|
-
const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
|
|
370
|
-
if (disposed)
|
|
371
|
-
return;
|
|
372
|
-
// Both parties publish on their own outgoing topic, so the OUTGOING query returns our
|
|
373
|
-
// requests + OUR responses, and the INCOMING query returns the peer's requests + the
|
|
374
|
-
// PEER's responses. Hence: our request is answered by a PEER response (incoming), and we
|
|
375
|
-
// have answered a peer request iff OUR response (outgoing) carries its id.
|
|
376
|
-
const ownRequest = ownDecoded.find(d => d.tag === 'request');
|
|
377
|
-
const ownResponse = ownDecoded.find(d => d.tag === 'response');
|
|
378
|
-
const peerRequest = peerDecoded.find(d => d.tag === 'request');
|
|
379
|
-
const peerResponse = peerDecoded.find(d => d.tag === 'response');
|
|
380
|
-
if (ownRequest?.tag === 'request') {
|
|
381
|
-
const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
|
|
382
|
-
if (!hasResponse) {
|
|
383
|
-
state.outgoingRequest = {
|
|
384
|
-
requestIds: [ownRequest.value.requestId],
|
|
385
|
-
messages: ownRequest.value.data,
|
|
386
|
-
tokens: [], // tokens from previous session cannot be restored
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
if (peerRequest?.tag === 'request') {
|
|
391
|
-
const requestId = peerRequest.value.requestId;
|
|
392
|
-
// Don't clobber an entry a live delivery may have created during the awaits
|
|
393
|
-
// above (the live one is newer/authoritative).
|
|
394
|
-
if (!state.incomingRequests.has(requestId)) {
|
|
395
|
-
const responded = ownResponse?.tag === 'response' && ownResponse.value.requestId === requestId;
|
|
396
|
-
state.incomingRequests.set(requestId, { responded });
|
|
397
|
-
// Notify app of an unresponded incoming request. Delivered while phase is
|
|
398
|
-
// still 'initialization' so deliverStatementData buffers it for replay if
|
|
399
|
-
// no subscriber is registered yet.
|
|
400
|
-
if (!responded)
|
|
401
|
-
deliverStatementData(peerRequest);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
state.phase = 'active';
|
|
405
|
-
processMessageQueue();
|
|
406
|
-
}
|
|
407
|
-
const session = {
|
|
408
|
-
request(codec, data) {
|
|
409
|
-
return session
|
|
410
|
-
.submitRequestMessage(codec, data)
|
|
411
|
-
.andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
|
|
412
|
-
},
|
|
413
|
-
submitRequestMessage(codec, message) {
|
|
414
|
-
if (disposed)
|
|
415
|
-
return errAsync(new Error(SESSION_DISPOSED));
|
|
416
|
-
const encode = fromThrowable(codec.enc, toError);
|
|
417
|
-
const encodedResult = encode(message);
|
|
418
|
-
if (encodedResult.isErr())
|
|
419
|
-
return errAsync(encodedResult.error);
|
|
420
|
-
const encoded = encodedResult.value;
|
|
421
|
-
if (requestPayloadSize([encoded]) > maxPayloadSize)
|
|
422
|
-
return errAsync(new Error('message too big'));
|
|
423
|
-
if (state.phase === 'failed') {
|
|
424
|
-
return errAsync(state.initError ?? new Error('Session initialization failed'));
|
|
425
|
-
}
|
|
426
|
-
const token = nanoid();
|
|
427
|
-
state.pendingDelivery.set(token, makeDeferred());
|
|
428
|
-
// Dedup: an identical message already in flight or queued is not re-sent — the
|
|
429
|
-
// new caller is attached to it and resolves on the same response.
|
|
430
|
-
if (!attachToDuplicate(encoded, token)) {
|
|
431
|
-
// FIFO: never let a later (fitting) message overtake queued ones; only append
|
|
432
|
-
// to the live batch when nothing is waiting behind it.
|
|
433
|
-
if (state.phase === 'initialization' || state.messageQueue.length > 0) {
|
|
434
|
-
state.messageQueue.push({ encoded, tokens: [token] });
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
processNewMessage(encoded, [token]);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
return okAsync({ requestId: token });
|
|
441
|
-
},
|
|
442
|
-
submitResponseMessage(requestId, responseCode) {
|
|
443
|
-
if (disposed)
|
|
444
|
-
return errAsync(new Error(SESSION_DISPOSED));
|
|
445
|
-
const incoming = state.incomingRequests.get(requestId);
|
|
446
|
-
if (!incoming)
|
|
447
|
-
return errAsync(new Error(`No incoming request with id ${requestId}`));
|
|
448
|
-
if (incoming.responded) {
|
|
449
|
-
pruneBufferedRequest(requestId);
|
|
450
|
-
return okAsync(undefined);
|
|
451
|
-
}
|
|
452
|
-
const encoded = encodeStatementData({ tag: 'response', value: { requestId, responseCode } });
|
|
453
|
-
if (encoded.isErr())
|
|
454
|
-
return errAsync(encoded.error);
|
|
455
|
-
// Mark responded up-front so concurrent callers dedupe, but roll back if the
|
|
456
|
-
// submission fails — otherwise the ACK is lost forever (and a peer retransmit
|
|
457
|
-
// with a fresh id could never be answered either).
|
|
458
|
-
incoming.responded = true;
|
|
459
|
-
lastResponseRequestId = requestId;
|
|
460
|
-
// Responses go on OUR outgoing topic/response-channel (per spec: the responder
|
|
461
|
-
// publishes on SessionId(self, peer)); the requester reads them from its incoming topic.
|
|
462
|
-
return (submitWithRetry(() => submitStatementData(responseChannel, outgoingSessionId, encoded.value), {
|
|
463
|
-
attempts: MAX_SUBMIT_RETRIES,
|
|
464
|
-
priorityAttempts: 'unbounded',
|
|
465
|
-
delaysMs: RETRY_DELAY_MS,
|
|
466
|
-
// Adopt the chain-reported floor so the next attempt submits strictly above it.
|
|
467
|
-
onPriorityError: error => allocator.raiseFloor(error.min),
|
|
468
|
-
// Stop retrying once a newer response supersedes this one (shared response channel) or disposed.
|
|
469
|
-
shouldRetry: () => !disposed && lastResponseRequestId === requestId,
|
|
470
|
-
})
|
|
471
|
-
.orElse(error => {
|
|
472
|
-
// Priority errors never reach here (see the policy note above), so this is a genuine
|
|
473
|
-
// failure. If this is no longer the latest response (superseded) or the session is
|
|
474
|
-
// disposed, keep the request marked answered — re-answering would only clobber the
|
|
475
|
-
// newer response — and absorb the error. NOTE: the shared response channel still only
|
|
476
|
-
// exposes the latest response to the peer, so reliably ACKing several outstanding
|
|
477
|
-
// requests needs the protocol-level fix tracked separately.
|
|
478
|
-
if (disposed || lastResponseRequestId !== requestId)
|
|
479
|
-
return okAsync(undefined);
|
|
480
|
-
// The live response genuinely failed after exhausting retries — roll back so a later
|
|
481
|
-
// peer retransmit can still be answered, and surface the error.
|
|
482
|
-
incoming.responded = false;
|
|
483
|
-
return errAsync(error);
|
|
484
|
-
})
|
|
485
|
-
// Answered (or absorbed as such): it no longer needs replaying to future subscribers.
|
|
486
|
-
.andTee(() => pruneBufferedRequest(requestId)));
|
|
487
|
-
},
|
|
488
|
-
waitForRequestMessage(codec, filter) {
|
|
489
|
-
const promise = new Promise((resolve, reject) => {
|
|
490
|
-
let settled = false;
|
|
491
|
-
// Initialised to a no-op so a synchronous buffered-replay match during
|
|
492
|
-
// subscribe() can call it without hitting the temporal dead zone; the
|
|
493
|
-
// real unsubscribe is wired in once subscribe() returns.
|
|
494
|
-
let unsubscribe = () => undefined;
|
|
495
|
-
const finish = (run) => {
|
|
496
|
-
if (settled)
|
|
497
|
-
return;
|
|
498
|
-
settled = true;
|
|
499
|
-
requestWaiters.delete(rejectWaiter);
|
|
500
|
-
unsubscribe();
|
|
501
|
-
run();
|
|
502
|
-
};
|
|
503
|
-
const rejectWaiter = (error) => finish(() => reject(error));
|
|
504
|
-
requestWaiters.add(rejectWaiter);
|
|
505
|
-
unsubscribe = session.subscribe(codec, messages => {
|
|
506
|
-
for (const message of messages) {
|
|
507
|
-
if (message.type !== 'request')
|
|
508
|
-
continue;
|
|
509
|
-
if (message.payload.status !== 'parsed')
|
|
510
|
-
continue;
|
|
511
|
-
const filtered = filter(message.payload.value);
|
|
512
|
-
if (filtered !== undefined) {
|
|
513
|
-
finish(() => resolve(filtered));
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
// subscribe() may have matched synchronously (buffered replay) while
|
|
519
|
-
// `unsubscribe` was still the no-op above — tear down the real one now.
|
|
520
|
-
if (settled)
|
|
521
|
-
unsubscribe();
|
|
522
|
-
});
|
|
523
|
-
return fromPromise(promise, toError);
|
|
524
|
-
},
|
|
525
|
-
respondToRequests(codec, handler) {
|
|
526
|
-
return session.subscribe(codec, messages => {
|
|
527
|
-
for (const message of messages) {
|
|
528
|
-
if (message.type !== 'request')
|
|
529
|
-
continue;
|
|
530
|
-
const handled = handler(message);
|
|
531
|
-
const statusResult = handled instanceof ResultAsync ? handled : okAsync(handled);
|
|
532
|
-
void statusResult
|
|
533
|
-
.orElse(() => okAsync('unknown'))
|
|
534
|
-
.andThen(code => session.submitResponseMessage(message.requestId, code))
|
|
535
|
-
.mapErr(e => {
|
|
536
|
-
console.error('respondToRequests: failed to submit response:', e);
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
});
|
|
540
|
-
},
|
|
541
|
-
waitForResponseMessage(token) {
|
|
542
|
-
const deferred = state.pendingDelivery.get(token);
|
|
543
|
-
if (!deferred)
|
|
544
|
-
return errAsync(new Error(`No pending delivery for token ${token}`));
|
|
545
|
-
return fromPromise(deferred.promise, toError);
|
|
546
|
-
},
|
|
547
|
-
subscribe(codec, callback) {
|
|
548
|
-
const sub = {
|
|
549
|
-
codec: codec,
|
|
550
|
-
callback: callback,
|
|
551
|
-
};
|
|
552
|
-
subscribers.push(sub);
|
|
553
|
-
ensureStoreSubscription();
|
|
554
|
-
// Deliver buffered init messages to this subscriber
|
|
555
|
-
if (bufferedMessages.length > 0) {
|
|
556
|
-
const messages = bufferedMessages.flatMap(sd => toMessage(sd, codec));
|
|
557
|
-
if (messages.length > 0)
|
|
558
|
-
callback(messages);
|
|
559
|
-
}
|
|
560
|
-
return () => {
|
|
561
|
-
subscribers = subscribers.filter(s => s !== sub);
|
|
562
|
-
if (subscribers.length === 0 && storeUnsub) {
|
|
563
|
-
storeUnsub();
|
|
564
|
-
storeUnsub = null;
|
|
565
|
-
}
|
|
566
|
-
};
|
|
567
|
-
},
|
|
568
|
-
clearOutgoingStatement() {
|
|
569
|
-
const outgoing = state.outgoingRequest;
|
|
570
|
-
// Always drop local outgoing state and reject pending waiters up-front,
|
|
571
|
-
// regardless of which path follows. This covers messages queued before the
|
|
572
|
-
// batch went out (e.g. during init, while outgoingRequest is still null) and
|
|
573
|
-
// guarantees cleanup even if the superseding submission below fails — the
|
|
574
|
-
// caller still receives any submission error.
|
|
575
|
-
state.outgoingRequest = null;
|
|
576
|
-
state.messageQueue = [];
|
|
577
|
-
rejectAllPending(new Error('Outgoing batch aborted'));
|
|
578
|
-
if (outgoing === null)
|
|
579
|
-
return okAsync(undefined);
|
|
580
|
-
const requestId = outgoing.requestIds[outgoing.requestIds.length - 1];
|
|
581
|
-
const encoded = encodeStatementData({ tag: 'request', value: { requestId, data: [] } });
|
|
582
|
-
if (encoded.isErr())
|
|
583
|
-
return errAsync(encoded.error);
|
|
584
|
-
// Supersede the live batch with an empty one. Use submitStatementData so the
|
|
585
|
-
// empty statement goes out at a STRICTLY higher expiry — the store rejects an
|
|
586
|
-
// equal-or-lower expiry on the same channel, so reusing the last allocated expiry
|
|
587
|
-
// would leave the original request live on-chain. One shot, no retry (clearing is a
|
|
588
|
-
// supersede, not a request that must land); a priority rejection (ExpiryTooLow /
|
|
589
|
-
// AccountFull) means the channel already advanced past us, so the clear already
|
|
590
|
-
// happened → absorb it as success. No retry loop here means no onPriorityError hook, so
|
|
591
|
-
// resync the allocator inline: adopt the chain floor before absorbing, so later submits stay above it.
|
|
592
|
-
return submitStatementData(requestChannel, outgoingSessionId, encoded.value).orElse(error => {
|
|
593
|
-
if (!isPriorityTooLow(error))
|
|
594
|
-
return errAsync(error);
|
|
595
|
-
allocator.raiseFloor(error.min);
|
|
596
|
-
return okAsync(undefined);
|
|
597
|
-
});
|
|
598
|
-
},
|
|
599
|
-
dispose() {
|
|
600
|
-
disposed = true;
|
|
601
|
-
if (initRetryTimer) {
|
|
602
|
-
clearTimeout(initRetryTimer);
|
|
603
|
-
initRetryTimer = null;
|
|
604
|
-
}
|
|
605
|
-
storeUnsub?.();
|
|
606
|
-
storeUnsub = null;
|
|
607
|
-
subscribers = [];
|
|
608
|
-
// Drop pending work so no in-flight retry or queue drain acts on a disposed session.
|
|
609
|
-
state.outgoingRequest = null;
|
|
610
|
-
state.messageQueue = [];
|
|
611
|
-
// Settle any waitForRequestMessage() promises so callers unwind instead of
|
|
612
|
-
// hanging forever. Snapshot first — rejecting mutates the set.
|
|
613
|
-
for (const rejectWaiter of [...requestWaiters])
|
|
614
|
-
rejectWaiter(new Error(SESSION_DISPOSED));
|
|
615
|
-
requestWaiters.clear();
|
|
616
|
-
rejectAllPending(new Error(SESSION_DISPOSED));
|
|
617
|
-
},
|
|
618
|
-
};
|
|
619
|
-
void init();
|
|
620
|
-
return session;
|
|
621
|
-
}
|
|
622
|
-
function mapResponseCode(responseCode) {
|
|
623
|
-
switch (responseCode) {
|
|
624
|
-
case 'success':
|
|
625
|
-
return ok();
|
|
626
|
-
case 'decodingFailed':
|
|
627
|
-
return err(new DecodingError());
|
|
628
|
-
case 'decryptionFailed':
|
|
629
|
-
return err(new DecryptionError());
|
|
630
|
-
case 'unknown':
|
|
631
|
-
return err(new UnknownError());
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
function createRequestChannel(sessionId) {
|
|
635
|
-
return khash(sessionId, stringToBytes('request'));
|
|
636
|
-
}
|
|
637
|
-
function createResponseChannel(sessionId) {
|
|
638
|
-
return khash(sessionId, stringToBytes('response'));
|
|
19
|
+
const outgoingTopic = createSessionId(sessionKey, localAccount, remoteAccount);
|
|
20
|
+
const incomingTopic = createSessionId(sessionKey, remoteAccount, localAccount);
|
|
21
|
+
return createSessionCore({
|
|
22
|
+
statementStore,
|
|
23
|
+
prover,
|
|
24
|
+
allocator,
|
|
25
|
+
maxRequestSize,
|
|
26
|
+
outgoingTopic,
|
|
27
|
+
bodyBuilder: createBodyBuilder({ topic: outgoingTopic, encryption }),
|
|
28
|
+
incomingTopics: createStaticTopics({
|
|
29
|
+
topic: incomingTopic,
|
|
30
|
+
senderEncryptionPublicKey: remoteAccount.publicKey,
|
|
31
|
+
encryption,
|
|
32
|
+
}),
|
|
33
|
+
// No envelope: a single-device session neither emits nor accepts multi-device variants.
|
|
34
|
+
decoder: createStatementDecoder({ prover, ownEncryption: encryption }),
|
|
35
|
+
peerDevices: () => [],
|
|
36
|
+
});
|
|
639
37
|
}
|