@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.
Files changed (34) hide show
  1. package/dist/helpers.d.ts +0 -1
  2. package/dist/helpers.js +0 -3
  3. package/dist/index.d.ts +7 -0
  4. package/dist/index.js +3 -0
  5. package/dist/session/codec/decoder.d.ts +68 -0
  6. package/dist/session/codec/decoder.js +96 -0
  7. package/dist/session/codec/decoder.spec.d.ts +1 -0
  8. package/dist/session/codec/decoder.spec.js +161 -0
  9. package/dist/session/codec/envelope.d.ts +55 -0
  10. package/dist/session/codec/envelope.js +115 -0
  11. package/dist/session/codec/envelope.spec.d.ts +1 -0
  12. package/dist/session/codec/envelope.spec.js +114 -0
  13. package/dist/session/codec/incomingTopics.d.ts +46 -0
  14. package/dist/session/codec/incomingTopics.js +69 -0
  15. package/dist/session/codec/outgoingBody.d.ts +46 -0
  16. package/dist/session/codec/outgoingBody.js +64 -0
  17. package/dist/session/core.d.ts +58 -0
  18. package/dist/session/core.js +609 -0
  19. package/dist/session/messageMapper.d.ts +3 -3
  20. package/dist/session/messageMapper.js +8 -8
  21. package/dist/session/multiDeviceSession.d.ts +49 -0
  22. package/dist/session/multiDeviceSession.js +62 -0
  23. package/dist/session/multiDeviceSession.spec.d.ts +6 -0
  24. package/dist/session/multiDeviceSession.spec.js +354 -0
  25. package/dist/session/scale/statementData.d.ts +40 -0
  26. package/dist/session/scale/statementData.js +32 -0
  27. package/dist/session/session.d.ts +15 -5
  28. package/dist/session/session.js +31 -633
  29. package/dist/session/session.spec.js +22 -6
  30. package/dist/session/stateMachine.d.ts +135 -0
  31. package/dist/session/stateMachine.js +203 -0
  32. package/dist/session/stateMachine.spec.d.ts +1 -0
  33. package/dist/session/stateMachine.spec.js +276 -0
  34. package/package.json +4 -3
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Multi-device session (mds.md §"Sending P2P Messages"): one statement addressed to every
3
+ * device a peer runs, and one subscription covering every device they publish from.
4
+ *
5
+ * See `session.ts` for the single-device variant, and `core.ts` for the shared driver.
6
+ */
7
+ import type { StatementStoreAdapter } from '../adapter/types.js';
8
+ import type { AccountId } from '../model/sessionAccount.js';
9
+ import type { ExpiryAllocator } from '../submit/allocator.js';
10
+ import type { PeerRoster } from './codec/incomingTopics.js';
11
+ import type { StatementProver } from './statementProver.js';
12
+ import type { Session } from './types.js';
13
+ export type MultiDeviceSessionParams = {
14
+ /** This device: statement account (proof signer identity) and its X25519 encryption key. */
15
+ localDevice: {
16
+ statementAccountId: Uint8Array;
17
+ encryptionPrivateKey: Uint8Array;
18
+ };
19
+ /** This user's identity: account id and the identity chat key shared across own devices. */
20
+ localIdentity: {
21
+ accountId: AccountId;
22
+ chatPrivateKey: Uint8Array;
23
+ pin?: string;
24
+ };
25
+ /** The peer user's identity. */
26
+ remoteIdentity: {
27
+ accountId: AccountId;
28
+ chatPublicKey: Uint8Array;
29
+ pin?: string;
30
+ };
31
+ /** The peer's devices — observable, since `deviceAdded`/`deviceRemoved` change it at runtime. */
32
+ peerRoster: PeerRoster;
33
+ statementStore: StatementStoreAdapter;
34
+ prover: StatementProver;
35
+ allocator?: ExpiryAllocator;
36
+ maxRequestSize?: number;
37
+ };
38
+ /**
39
+ * Multi-device session (mds.md §"Sending P2P Messages").
40
+ *
41
+ * Outgoing: `topic = SessionId(D(A), B)` keyed by `x25519(ownDeviceEncPriv,
42
+ * peerIdentityChatPub)`, carrying a `multiRequest`/`multiResponse` envelope wrapped for
43
+ * every known peer device.
44
+ *
45
+ * Incoming: one topic per peer device, `SessionId(D(B'), A)` keyed by
46
+ * `x25519(ownIdentityChatPriv, D(B').encPub)` — covered by a single `matchAny`
47
+ * subscription that re-opens when the roster changes.
48
+ */
49
+ export declare function createMultiDeviceSession({ localDevice, localIdentity, remoteIdentity, peerRoster, statementStore, prover, allocator, maxRequestSize, }: MultiDeviceSessionParams): Session;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Multi-device session (mds.md §"Sending P2P Messages"): one statement addressed to every
3
+ * device a peer runs, and one subscription covering every device they publish from.
4
+ *
5
+ * See `session.ts` for the single-device variant, and `core.ts` for the shared driver.
6
+ */
7
+ import { x25519 } from '@noble/curves/ed25519.js';
8
+ import { createSessionId } from '../model/session.js';
9
+ import { createAccountId } from '../model/sessionAccount.js';
10
+ import { createExpiryAllocator } from '../submit/allocator.js';
11
+ import { createStatementDecoder } from './codec/decoder.js';
12
+ import { createEnvelope } from './codec/envelope.js';
13
+ import { createRosterTopics } from './codec/incomingTopics.js';
14
+ import { createBodyBuilder } from './codec/outgoingBody.js';
15
+ import { DEFAULT_MAX_REQUEST_SIZE, createSessionCore } from './core.js';
16
+ import { createEncryption } from './encyption.js';
17
+ /**
18
+ * Multi-device session (mds.md §"Sending P2P Messages").
19
+ *
20
+ * Outgoing: `topic = SessionId(D(A), B)` keyed by `x25519(ownDeviceEncPriv,
21
+ * peerIdentityChatPub)`, carrying a `multiRequest`/`multiResponse` envelope wrapped for
22
+ * every known peer device.
23
+ *
24
+ * Incoming: one topic per peer device, `SessionId(D(B'), A)` keyed by
25
+ * `x25519(ownIdentityChatPriv, D(B').encPub)` — covered by a single `matchAny`
26
+ * subscription that re-opens when the roster changes.
27
+ */
28
+ export function createMultiDeviceSession({ localDevice, localIdentity, remoteIdentity, peerRoster, statementStore, prover, allocator = createExpiryAllocator(), maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
29
+ const outgoingSharedSecret = x25519.getSharedSecret(localDevice.encryptionPrivateKey, remoteIdentity.chatPublicKey);
30
+ const outgoingEncryption = createEncryption(outgoingSharedSecret);
31
+ const localDeviceAccount = {
32
+ accountId: createAccountId(localDevice.statementAccountId),
33
+ pin: localIdentity.pin,
34
+ };
35
+ const remoteIdentityAccount = { accountId: remoteIdentity.accountId, pin: remoteIdentity.pin };
36
+ const localIdentityAccount = { accountId: localIdentity.accountId, pin: localIdentity.pin };
37
+ const outgoingTopic = createSessionId(outgoingSharedSecret, localDeviceAccount, remoteIdentityAccount);
38
+ const envelope = createEnvelope({
39
+ ownStatementAccountId: localDevice.statementAccountId,
40
+ ownEncryptionPrivateKey: localDevice.encryptionPrivateKey,
41
+ });
42
+ return createSessionCore({
43
+ statementStore,
44
+ prover,
45
+ allocator,
46
+ maxRequestSize,
47
+ outgoingTopic,
48
+ bodyBuilder: createBodyBuilder({
49
+ topic: outgoingTopic,
50
+ encryption: outgoingEncryption,
51
+ multiDevice: { envelope, recipients: () => peerRoster.current() },
52
+ }),
53
+ incomingTopics: createRosterTopics({
54
+ localIdentity: localIdentityAccount,
55
+ remotePin: remoteIdentity.pin,
56
+ ownIdentityChatPrivateKey: localIdentity.chatPrivateKey,
57
+ peerRoster,
58
+ }),
59
+ decoder: createStatementDecoder({ prover, envelope, ownEncryption: outgoingEncryption }),
60
+ peerDevices: () => peerRoster.current(),
61
+ });
62
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * End-to-end multi-device session: two users, each with their own device(s), talking over
3
+ * one shared in-memory statement store. Exercises the wire path the desktop chat client
4
+ * will use — `multiRequest`/`multiResponse` envelopes on device-derived topics.
5
+ */
6
+ export {};
@@ -0,0 +1,354 @@
1
+ /**
2
+ * End-to-end multi-device session: two users, each with their own device(s), talking over
3
+ * one shared in-memory statement store. Exercises the wire path the desktop chat client
4
+ * will use — `multiRequest`/`multiResponse` envelopes on device-derived topics.
5
+ */
6
+ import { x25519 } from '@noble/curves/ed25519.js';
7
+ import { randomBytes } from '@noble/hashes/utils.js';
8
+ import { Bytes } from '@novasamatech/scale';
9
+ import { okAsync } from 'neverthrow';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { createInMemoryStatementStore } from '../adapter/inMemory.js';
12
+ import { createAccountId } from '../model/sessionAccount.js';
13
+ import { createExpiryAllocator } from '../submit/allocator.js';
14
+ import { createMultiDeviceSession } from './multiDeviceSession.js';
15
+ const rawCodec = Bytes();
16
+ // Proof verification is exercised in statementProver tests; here a stub keeps statements
17
+ // well-formed without pulling in real sr25519 signing.
18
+ const mockProver = {
19
+ generateMessageProof: statement => okAsync({
20
+ ...statement,
21
+ proof: { type: 'sr25519', value: { signature: `0x${'00'.repeat(64)}`, signer: `0x${'00'.repeat(32)}` } },
22
+ }),
23
+ verifyMessageProof: () => okAsync(true),
24
+ };
25
+ const delay = () => new Promise(resolve => setTimeout(resolve, 0));
26
+ function createDevice() {
27
+ const encryptionPrivateKey = x25519.utils.randomSecretKey();
28
+ return {
29
+ statementAccountId: randomBytes(32),
30
+ encryptionPrivateKey,
31
+ encryptionPublicKey: x25519.getPublicKey(encryptionPrivateKey),
32
+ };
33
+ }
34
+ function createIdentity() {
35
+ const chatPrivateKey = x25519.utils.randomSecretKey();
36
+ return {
37
+ accountId: createAccountId(randomBytes(32)),
38
+ chatPrivateKey,
39
+ chatPublicKey: x25519.getPublicKey(chatPrivateKey),
40
+ };
41
+ }
42
+ /** A roster whose contents can be swapped at runtime, like a peer adding a device. */
43
+ function mutableRoster(initial) {
44
+ let devices = initial;
45
+ const listeners = new Set();
46
+ const roster = {
47
+ current: () => devices,
48
+ subscribe(callback) {
49
+ listeners.add(callback);
50
+ return () => listeners.delete(callback);
51
+ },
52
+ };
53
+ return {
54
+ roster,
55
+ set(next) {
56
+ devices = next;
57
+ for (const listener of listeners)
58
+ listener(next);
59
+ },
60
+ };
61
+ }
62
+ const toTarget = (device) => ({
63
+ statementAccountId: device.statementAccountId,
64
+ encryptionPublicKey: device.encryptionPublicKey,
65
+ });
66
+ describe('multi-device session', () => {
67
+ it('completes a request → ACK round trip between two users', async () => {
68
+ const store = createInMemoryStatementStore();
69
+ const alice = createIdentity();
70
+ const bob = createIdentity();
71
+ const aliceDevice = createDevice();
72
+ const bobDevice = createDevice();
73
+ const aliceSession = createMultiDeviceSession({
74
+ localDevice: aliceDevice,
75
+ localIdentity: alice,
76
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey },
77
+ peerRoster: mutableRoster([toTarget(bobDevice)]).roster,
78
+ statementStore: store,
79
+ prover: mockProver,
80
+ allocator: createExpiryAllocator(),
81
+ });
82
+ const bobSession = createMultiDeviceSession({
83
+ localDevice: bobDevice,
84
+ localIdentity: bob,
85
+ remoteIdentity: { accountId: alice.accountId, chatPublicKey: alice.chatPublicKey },
86
+ peerRoster: mutableRoster([toTarget(aliceDevice)]).roster,
87
+ statementStore: store,
88
+ prover: mockProver,
89
+ allocator: createExpiryAllocator(),
90
+ });
91
+ await delay();
92
+ // Bob answers whatever Alice sends, which is what resolves her delivery promise.
93
+ const received = [];
94
+ bobSession.respondToRequests(rawCodec, request => {
95
+ if (request.payload.status === 'parsed')
96
+ received.push(request.payload.value);
97
+ return 'success';
98
+ });
99
+ // A session only opens its store subscription once something subscribes, so a caller
100
+ // awaiting `request()` must also be subscribed or the peer's ACK never arrives.
101
+ aliceSession.subscribe(rawCodec, vi.fn());
102
+ const payload = new TextEncoder().encode('hello bob');
103
+ await expect(aliceSession.request(rawCodec, payload)).toBeOk();
104
+ expect(received).toEqual([payload]);
105
+ aliceSession.dispose();
106
+ bobSession.dispose();
107
+ });
108
+ // A device inherits the pin of the identity it belongs to. If the sender and receiver
109
+ // disagree about which pin goes in the SessionIdParam, they derive different topics and
110
+ // messages silently never arrive — so exercise a round trip with pins set on both sides.
111
+ it('agrees on topics when both identities carry a pin', async () => {
112
+ const store = createInMemoryStatementStore();
113
+ const alice = createIdentity();
114
+ const bob = createIdentity();
115
+ const aliceDevice = createDevice();
116
+ const bobDevice = createDevice();
117
+ const alicePin = 'alice-pin';
118
+ const bobPin = 'bob-pin';
119
+ const aliceSession = createMultiDeviceSession({
120
+ localDevice: aliceDevice,
121
+ localIdentity: { ...alice, pin: alicePin },
122
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey, pin: bobPin },
123
+ peerRoster: mutableRoster([toTarget(bobDevice)]).roster,
124
+ statementStore: store,
125
+ prover: mockProver,
126
+ allocator: createExpiryAllocator(),
127
+ });
128
+ const bobSession = createMultiDeviceSession({
129
+ localDevice: bobDevice,
130
+ localIdentity: { ...bob, pin: bobPin },
131
+ remoteIdentity: { accountId: alice.accountId, chatPublicKey: alice.chatPublicKey, pin: alicePin },
132
+ peerRoster: mutableRoster([toTarget(aliceDevice)]).roster,
133
+ statementStore: store,
134
+ prover: mockProver,
135
+ allocator: createExpiryAllocator(),
136
+ });
137
+ await delay();
138
+ const seen = [];
139
+ bobSession.subscribe(rawCodec, messages => {
140
+ for (const message of messages) {
141
+ if (message.type === 'request' && message.payload.status === 'parsed')
142
+ seen.push(message.payload.value);
143
+ }
144
+ });
145
+ const payload = new TextEncoder().encode('pinned hello');
146
+ await expect(aliceSession.submitRequestMessage(rawCodec, payload)).toBeOk();
147
+ await delay();
148
+ expect(seen).toEqual([payload]);
149
+ aliceSession.dispose();
150
+ bobSession.dispose();
151
+ });
152
+ describe('with no known peer devices', () => {
153
+ const emptyRosterSession = (store, queryStatements = vi.fn()) => {
154
+ const alice = createIdentity();
155
+ const bob = createIdentity();
156
+ return createMultiDeviceSession({
157
+ localDevice: createDevice(),
158
+ localIdentity: alice,
159
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey },
160
+ peerRoster: mutableRoster([]).roster,
161
+ statementStore: { ...store, queryStatements },
162
+ prover: mockProver,
163
+ allocator: createExpiryAllocator(),
164
+ });
165
+ };
166
+ // An empty `matchAny` has no defined meaning at the node; a store reading it as "match
167
+ // everything" would hand the session the entire store to decode.
168
+ it('does not ask the store to match an empty topic set', async () => {
169
+ const store = createInMemoryStatementStore();
170
+ const queryStatements = vi.fn().mockReturnValue(okAsync([]));
171
+ const session = emptyRosterSession(store, queryStatements);
172
+ await delay();
173
+ const filters = queryStatements.mock.calls.map(([f]) => f);
174
+ expect(filters.every(f => f.matchAny === undefined || f.matchAny.length > 0)).toBe(true);
175
+ session.dispose();
176
+ });
177
+ it('reports the real reason a send cannot go out', async () => {
178
+ const store = createInMemoryStatementStore();
179
+ const session = emptyRosterSession(store, vi.fn().mockReturnValue(okAsync([])));
180
+ await delay();
181
+ const result = await session.submitRequestMessage(rawCodec, new TextEncoder().encode('hi'));
182
+ expect(result.isErr()).toBe(true);
183
+ // Not "message too big", which is what an unbuildable statement used to look like.
184
+ expect(result._unsafeUnwrapErr().message).toContain('recipient devices');
185
+ session.dispose();
186
+ });
187
+ });
188
+ it('reaches every device of a multi-device peer', async () => {
189
+ const store = createInMemoryStatementStore();
190
+ const alice = createIdentity();
191
+ const bob = createIdentity();
192
+ const aliceDevice = createDevice();
193
+ const bobLaptop = createDevice();
194
+ const bobPhone = createDevice();
195
+ const aliceSession = createMultiDeviceSession({
196
+ localDevice: aliceDevice,
197
+ localIdentity: alice,
198
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey },
199
+ peerRoster: mutableRoster([toTarget(bobLaptop), toTarget(bobPhone)]).roster,
200
+ statementStore: store,
201
+ prover: mockProver,
202
+ allocator: createExpiryAllocator(),
203
+ });
204
+ // Both of Bob's devices run their own session against the same identity.
205
+ const bobSessions = [bobLaptop, bobPhone].map(device => createMultiDeviceSession({
206
+ localDevice: device,
207
+ localIdentity: bob,
208
+ remoteIdentity: { accountId: alice.accountId, chatPublicKey: alice.chatPublicKey },
209
+ peerRoster: mutableRoster([toTarget(aliceDevice)]).roster,
210
+ statementStore: store,
211
+ prover: mockProver,
212
+ allocator: createExpiryAllocator(),
213
+ }));
214
+ await delay();
215
+ const seen = bobSessions.map(() => []);
216
+ bobSessions.forEach((session, index) => {
217
+ session.subscribe(rawCodec, messages => {
218
+ for (const message of messages) {
219
+ if (message.type === 'request' && message.payload.status === 'parsed') {
220
+ seen[index].push(message.payload.value);
221
+ }
222
+ }
223
+ });
224
+ });
225
+ const payload = new TextEncoder().encode('hello both devices');
226
+ await expect(aliceSession.submitRequestMessage(rawCodec, payload)).toBeOk();
227
+ await delay();
228
+ // One statement, one envelope — decrypted independently by each recipient device.
229
+ expect(seen[0]).toEqual([payload]);
230
+ expect(seen[1]).toEqual([payload]);
231
+ aliceSession.dispose();
232
+ for (const session of bobSessions)
233
+ session.dispose();
234
+ });
235
+ it('addresses a device the peer adds mid-session', async () => {
236
+ const store = createInMemoryStatementStore();
237
+ const alice = createIdentity();
238
+ const bob = createIdentity();
239
+ const aliceDevice = createDevice();
240
+ const bobLaptop = createDevice();
241
+ const bobPhone = createDevice();
242
+ const bobRoster = mutableRoster([toTarget(bobLaptop)]);
243
+ const aliceSession = createMultiDeviceSession({
244
+ localDevice: aliceDevice,
245
+ localIdentity: alice,
246
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey },
247
+ peerRoster: bobRoster.roster,
248
+ statementStore: store,
249
+ prover: mockProver,
250
+ allocator: createExpiryAllocator(),
251
+ });
252
+ await delay();
253
+ // The new device comes online and Alice learns about it (deviceAdded, in production).
254
+ bobRoster.set([toTarget(bobLaptop), toTarget(bobPhone)]);
255
+ const phoneSession = createMultiDeviceSession({
256
+ localDevice: bobPhone,
257
+ localIdentity: bob,
258
+ remoteIdentity: { accountId: alice.accountId, chatPublicKey: alice.chatPublicKey },
259
+ peerRoster: mutableRoster([toTarget(aliceDevice)]).roster,
260
+ statementStore: store,
261
+ prover: mockProver,
262
+ allocator: createExpiryAllocator(),
263
+ });
264
+ await delay();
265
+ const seen = [];
266
+ phoneSession.subscribe(rawCodec, messages => {
267
+ for (const message of messages) {
268
+ if (message.type === 'request' && message.payload.status === 'parsed')
269
+ seen.push(message.payload.value);
270
+ }
271
+ });
272
+ const payload = new TextEncoder().encode('now includes the phone');
273
+ await expect(aliceSession.submitRequestMessage(rawCodec, payload)).toBeOk();
274
+ await delay();
275
+ expect(seen).toEqual([payload]);
276
+ aliceSession.dispose();
277
+ phoneSession.dispose();
278
+ });
279
+ it('opens ONE subscription regardless of how many devices the peer has', async () => {
280
+ const store = createInMemoryStatementStore();
281
+ const alice = createIdentity();
282
+ const bob = createIdentity();
283
+ const peerDevices = [createDevice(), createDevice(), createDevice()].map(toTarget);
284
+ const session = createMultiDeviceSession({
285
+ localDevice: createDevice(),
286
+ localIdentity: alice,
287
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey },
288
+ peerRoster: mutableRoster(peerDevices).roster,
289
+ statementStore: store,
290
+ prover: mockProver,
291
+ allocator: createExpiryAllocator(),
292
+ });
293
+ await delay();
294
+ session.subscribe(rawCodec, vi.fn());
295
+ expect(store.activeSubscriptions()).toBe(1);
296
+ session.dispose();
297
+ });
298
+ // The initialization-phase read-back that removes the need for a client-side outbox:
299
+ // a fresh session recovers its unacknowledged batch from the store alone.
300
+ it('restores its unacknowledged outgoing batch from the store after a restart', async () => {
301
+ const store = createInMemoryStatementStore();
302
+ const alice = createIdentity();
303
+ const bob = createIdentity();
304
+ const aliceDevice = createDevice();
305
+ const bobDevice = createDevice();
306
+ const allocator = createExpiryAllocator();
307
+ const params = {
308
+ localDevice: aliceDevice,
309
+ localIdentity: alice,
310
+ remoteIdentity: { accountId: bob.accountId, chatPublicKey: bob.chatPublicKey },
311
+ peerRoster: mutableRoster([toTarget(bobDevice)]).roster,
312
+ statementStore: store,
313
+ prover: mockProver,
314
+ allocator,
315
+ };
316
+ const first = createMultiDeviceSession(params);
317
+ await delay();
318
+ const payload = new TextEncoder().encode('unacked message');
319
+ await expect(first.submitRequestMessage(rawCodec, payload)).toBeOk();
320
+ await delay();
321
+ first.dispose(); // Bob never answered.
322
+ // A brand-new session over the same store must recover the pending batch, so the next
323
+ // message extends it rather than silently dropping the earlier one.
324
+ const restored = createMultiDeviceSession(params);
325
+ await delay();
326
+ const bobSeen = [];
327
+ const bobSession = createMultiDeviceSession({
328
+ localDevice: bobDevice,
329
+ localIdentity: bob,
330
+ remoteIdentity: { accountId: alice.accountId, chatPublicKey: alice.chatPublicKey },
331
+ peerRoster: mutableRoster([toTarget(aliceDevice)]).roster,
332
+ statementStore: store,
333
+ prover: mockProver,
334
+ allocator: createExpiryAllocator(),
335
+ });
336
+ bobSession.subscribe(rawCodec, messages => {
337
+ for (const message of messages) {
338
+ if (message.type === 'request' && message.payload.status === 'parsed')
339
+ bobSeen.push(message.payload.value);
340
+ }
341
+ });
342
+ await delay();
343
+ const second = new TextEncoder().encode('second message');
344
+ await expect(restored.submitRequestMessage(rawCodec, second)).toBeOk();
345
+ await delay();
346
+ // The replacing statement carries BOTH messages, so a peer that only ever sees the
347
+ // surviving statement still gets the un-acked one. (Bob also saw the pre-replacement
348
+ // statement during his own init, hence the trailing-pair assertion — de-duplicating by
349
+ // message id is the application layer's job, not the transport's.)
350
+ expect(bobSeen.slice(-2)).toEqual([payload, second]);
351
+ restored.dispose();
352
+ bobSession.dispose();
353
+ });
354
+ });
@@ -8,6 +8,28 @@ export declare const Response: import("scale-ts").Codec<{
8
8
  requestId: string;
9
9
  responseCode: ResponseStatus;
10
10
  }>;
11
+ /**
12
+ * One recipient device of a multi-device envelope. `encryptedKey` is the envelope's
13
+ * one-shot symmetric key wrapped for this device (see `session/codec/envelope.ts`).
14
+ *
15
+ * `statementAccountId` is a FIXED 32-byte array, matching Android's `RequestDeviceInfo`
16
+ * (paritytech/polkadot-app-android-v2#605) and desktop. Pre-#605 Android builds emit
17
+ * `Vec<u8>` here and are not interoperable.
18
+ */
19
+ export declare const RequestDeviceInfo: import("scale-ts").Codec<{
20
+ statementAccountId: Uint8Array<ArrayBufferLike>;
21
+ encryptedKey: Uint8Array<ArrayBufferLike>;
22
+ }>;
23
+ /**
24
+ * Statement `data` payload, after the outer pairwise decryption.
25
+ *
26
+ * Variant indices are wire format — scale-ts assigns them by declaration order, so
27
+ * entries may only be APPENDED, never reordered:
28
+ * 0 request — single-device (base-spec.md)
29
+ * 1 response — single-device
30
+ * 2 multiRequest — multi-device envelope (mds.md)
31
+ * 3 multiResponse — multi-device envelope
32
+ */
11
33
  export declare const StatementData: import("scale-ts").Codec<{
12
34
  tag: "request";
13
35
  value: {
@@ -20,4 +42,22 @@ export declare const StatementData: import("scale-ts").Codec<{
20
42
  requestId: string;
21
43
  responseCode: ResponseStatus;
22
44
  };
45
+ } | {
46
+ tag: "multiRequest";
47
+ value: {
48
+ encryptedRequest: Uint8Array<ArrayBufferLike>;
49
+ devicesInfo: {
50
+ statementAccountId: Uint8Array<ArrayBufferLike>;
51
+ encryptedKey: Uint8Array<ArrayBufferLike>;
52
+ }[];
53
+ };
54
+ } | {
55
+ tag: "multiResponse";
56
+ value: {
57
+ encryptedResponse: Uint8Array<ArrayBufferLike>;
58
+ devicesInfo: {
59
+ statementAccountId: Uint8Array<ArrayBufferLike>;
60
+ encryptedKey: Uint8Array<ArrayBufferLike>;
61
+ }[];
62
+ };
23
63
  }>;
@@ -31,7 +31,39 @@ export const Response = Struct({
31
31
  requestId: str,
32
32
  responseCode: ResponseCode,
33
33
  });
34
+ /**
35
+ * One recipient device of a multi-device envelope. `encryptedKey` is the envelope's
36
+ * one-shot symmetric key wrapped for this device (see `session/codec/envelope.ts`).
37
+ *
38
+ * `statementAccountId` is a FIXED 32-byte array, matching Android's `RequestDeviceInfo`
39
+ * (paritytech/polkadot-app-android-v2#605) and desktop. Pre-#605 Android builds emit
40
+ * `Vec<u8>` here and are not interoperable.
41
+ */
42
+ export const RequestDeviceInfo = Struct({
43
+ statementAccountId: Bytes(32),
44
+ encryptedKey: Bytes(),
45
+ });
46
+ const MultiRequest = Struct({
47
+ encryptedRequest: Bytes(),
48
+ devicesInfo: Vector(RequestDeviceInfo),
49
+ });
50
+ const MultiResponse = Struct({
51
+ encryptedResponse: Bytes(),
52
+ devicesInfo: Vector(RequestDeviceInfo),
53
+ });
54
+ /**
55
+ * Statement `data` payload, after the outer pairwise decryption.
56
+ *
57
+ * Variant indices are wire format — scale-ts assigns them by declaration order, so
58
+ * entries may only be APPENDED, never reordered:
59
+ * 0 request — single-device (base-spec.md)
60
+ * 1 response — single-device
61
+ * 2 multiRequest — multi-device envelope (mds.md)
62
+ * 3 multiResponse — multi-device envelope
63
+ */
34
64
  export const StatementData = Enum({
35
65
  request: Request,
36
66
  response: Response,
67
+ multiRequest: MultiRequest,
68
+ multiResponse: MultiResponse,
37
69
  });
@@ -1,3 +1,9 @@
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
+ */
1
7
  import type { StatementStoreAdapter } from '../adapter/types.js';
2
8
  import type { LocalSessionAccount, RemoteSessionAccount } from '../model/sessionAccount.js';
3
9
  import type { ExpiryAllocator } from '../submit/allocator.js';
@@ -31,13 +37,17 @@ export type SessionParams = {
31
37
  * identical to the previous per-session behavior.
32
38
  */
33
39
  allocator?: ExpiryAllocator;
40
+ /**
41
+ * Statement size budget in bytes; the batch is sized against
42
+ * `maxRequestSize - STATEMENT_OVERHEAD`. Defaults to
43
+ * {@link DEFAULT_MAX_REQUEST_SIZE} — override only to be MORE conservative than the
44
+ * chain, never less.
45
+ */
34
46
  maxRequestSize?: number;
35
47
  };
36
48
  /**
37
- * Fixed per-statement wire overhead reserved before sizing the request payload:
38
- * topic (32) + channel (32) + expiry (8) + proof signature (64) + signer (32).
39
- * Mirrors the Android/iOS sessions, which size message batches against
40
- * `maxStatementSize - overhead` rather than the raw statement limit.
49
+ * Single-device session (base-spec.md). Wire output is unchanged from before the
50
+ * multi-device seams existed: `StatementData.request`/`.response` on
51
+ * `SessionId(A, B)`, listening on `SessionId(B, A)`.
41
52
  */
42
- export declare const STATEMENT_OVERHEAD: number;
43
53
  export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, allocator, maxRequestSize, }: SessionParams): Session;