cairn-ts 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +43 -0
  2. package/dist/index.cjs +1883 -0
  3. package/dist/index.d.cts +572 -0
  4. package/dist/index.d.ts +572 -0
  5. package/dist/index.js +1827 -0
  6. package/eslint.config.js +24 -0
  7. package/package.json +54 -0
  8. package/src/channel.ts +277 -0
  9. package/src/config.ts +161 -0
  10. package/src/crypto/aead.ts +80 -0
  11. package/src/crypto/double-ratchet.ts +355 -0
  12. package/src/crypto/exchange.ts +51 -0
  13. package/src/crypto/hkdf.ts +33 -0
  14. package/src/crypto/identity.ts +84 -0
  15. package/src/crypto/index.ts +20 -0
  16. package/src/crypto/noise.ts +415 -0
  17. package/src/crypto/sas.ts +36 -0
  18. package/src/crypto/spake2.ts +169 -0
  19. package/src/discovery/index.ts +38 -0
  20. package/src/discovery/manager.ts +138 -0
  21. package/src/discovery/rendezvous.ts +189 -0
  22. package/src/discovery/tracker.ts +251 -0
  23. package/src/errors.ts +166 -0
  24. package/src/index.ts +57 -0
  25. package/src/mesh/index.ts +48 -0
  26. package/src/mesh/relay.ts +100 -0
  27. package/src/mesh/routing-table.ts +196 -0
  28. package/src/node.ts +619 -0
  29. package/src/pairing/adapter.ts +51 -0
  30. package/src/pairing/index.ts +40 -0
  31. package/src/pairing/link.ts +127 -0
  32. package/src/pairing/payload.ts +98 -0
  33. package/src/pairing/pin.ts +115 -0
  34. package/src/pairing/psk.ts +49 -0
  35. package/src/pairing/qr.ts +52 -0
  36. package/src/pairing/rate-limit.ts +134 -0
  37. package/src/pairing/sas-flow.ts +45 -0
  38. package/src/pairing/state-machine.ts +438 -0
  39. package/src/pairing/unpairing.ts +50 -0
  40. package/src/protocol/custom-handler.ts +52 -0
  41. package/src/protocol/envelope.ts +138 -0
  42. package/src/protocol/index.ts +36 -0
  43. package/src/protocol/message-types.ts +74 -0
  44. package/src/protocol/version.ts +98 -0
  45. package/src/server/index.ts +67 -0
  46. package/src/server/management.ts +285 -0
  47. package/src/server/store-forward.ts +266 -0
  48. package/src/session/backoff.ts +58 -0
  49. package/src/session/heartbeat.ts +79 -0
  50. package/src/session/index.ts +26 -0
  51. package/src/session/message-queue.ts +133 -0
  52. package/src/session/network-monitor.ts +130 -0
  53. package/src/session/state-machine.ts +122 -0
  54. package/src/session.ts +223 -0
  55. package/src/transport/fallback.ts +475 -0
  56. package/src/transport/index.ts +46 -0
  57. package/src/transport/libp2p-node.ts +158 -0
  58. package/src/transport/nat.ts +348 -0
  59. package/tests/conformance/cbor-vectors.test.ts +250 -0
  60. package/tests/integration/pairing-session.test.ts +317 -0
  61. package/tests/unit/config-api.test.ts +310 -0
  62. package/tests/unit/crypto.test.ts +407 -0
  63. package/tests/unit/discovery.test.ts +618 -0
  64. package/tests/unit/double-ratchet.test.ts +185 -0
  65. package/tests/unit/mesh.test.ts +349 -0
  66. package/tests/unit/noise.test.ts +346 -0
  67. package/tests/unit/pairing-extras.test.ts +402 -0
  68. package/tests/unit/pairing.test.ts +572 -0
  69. package/tests/unit/protocol.test.ts +438 -0
  70. package/tests/unit/reconnection.test.ts +402 -0
  71. package/tests/unit/scaffolding.test.ts +142 -0
  72. package/tests/unit/server.test.ts +492 -0
  73. package/tests/unit/sessions.test.ts +595 -0
  74. package/tests/unit/transport.test.ts +604 -0
  75. package/tsconfig.json +20 -0
  76. package/vitest.config.ts +15 -0
@@ -0,0 +1,122 @@
1
+ import { CairnError } from '../errors.js';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Connection states (spec/07 section 2)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Connection lifecycle states per spec section 2. */
8
+ export type ConnectionState =
9
+ | 'connected'
10
+ | 'unstable'
11
+ | 'disconnected'
12
+ | 'reconnecting'
13
+ | 'suspended'
14
+ | 'reconnected'
15
+ | 'failed';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // State transition validation
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Valid transitions per the spec state diagram. */
22
+ const VALID_TRANSITIONS: ReadonlyArray<[ConnectionState, ConnectionState]> = [
23
+ ['connected', 'unstable'],
24
+ ['connected', 'disconnected'],
25
+ ['unstable', 'disconnected'],
26
+ ['unstable', 'connected'],
27
+ ['disconnected', 'reconnecting'],
28
+ ['reconnecting', 'reconnected'],
29
+ ['reconnecting', 'suspended'],
30
+ ['suspended', 'reconnecting'],
31
+ ['suspended', 'failed'],
32
+ ['reconnected', 'connected'],
33
+ ];
34
+
35
+ /** Check whether a transition from `from` to `to` is valid. */
36
+ export function isValidTransition(from: ConnectionState, to: ConnectionState): boolean {
37
+ return VALID_TRANSITIONS.some(([f, t]) => f === from && t === to);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Session events
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Event emitted on every state transition. */
45
+ export interface StateChangedEvent {
46
+ sessionId: string;
47
+ fromState: ConnectionState;
48
+ toState: ConnectionState;
49
+ timestamp: number;
50
+ reason?: string;
51
+ }
52
+
53
+ export type StateChangedListener = (event: StateChangedEvent) => void;
54
+
55
+ export interface SessionStateMachineEvents {
56
+ state_changed: (event: StateChangedEvent) => void;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // SessionStateMachine
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Validates and executes session state transitions, emitting events on each transition.
65
+ *
66
+ * Enforces the 7-state connection lifecycle from spec/07-reconnection-sessions.md section 2.
67
+ */
68
+ export class SessionStateMachine {
69
+ private readonly _sessionId: string;
70
+ private _state: ConnectionState;
71
+ private readonly _listeners: StateChangedListener[] = [];
72
+
73
+ constructor(sessionId: string, initialState: ConnectionState = 'connected') {
74
+ this._sessionId = sessionId;
75
+ this._state = initialState;
76
+ }
77
+
78
+ /** Get the current state. */
79
+ get state(): ConnectionState {
80
+ return this._state;
81
+ }
82
+
83
+ /** Get the session ID. */
84
+ get sessionId(): string {
85
+ return this._sessionId;
86
+ }
87
+
88
+ /** Subscribe to state change events. */
89
+ onStateChanged(listener: StateChangedListener): void {
90
+ this._listeners.push(listener);
91
+ }
92
+
93
+ /**
94
+ * Attempt a state transition.
95
+ *
96
+ * Throws CairnError if the transition is not allowed by the state diagram.
97
+ * Calls all registered state_changed listeners on success.
98
+ */
99
+ transition(to: ConnectionState, reason?: string): void {
100
+ if (!isValidTransition(this._state, to)) {
101
+ throw new CairnError(
102
+ 'PROTOCOL',
103
+ `invalid session state transition: ${this._state} -> ${to}`,
104
+ );
105
+ }
106
+
107
+ const from = this._state;
108
+ this._state = to;
109
+
110
+ const event: StateChangedEvent = {
111
+ sessionId: this._sessionId,
112
+ fromState: from,
113
+ toState: to,
114
+ timestamp: Date.now(),
115
+ reason,
116
+ };
117
+
118
+ for (const listener of this._listeners) {
119
+ listener(event);
120
+ }
121
+ }
122
+ }
package/src/session.ts ADDED
@@ -0,0 +1,223 @@
1
+ import { v7 as uuidv7 } from 'uuid';
2
+ import { CairnError } from './errors.js';
3
+ import { SessionStateMachine } from './session/state-machine.js';
4
+ import type { ConnectionState, StateChangedEvent } from './session/state-machine.js';
5
+ import {
6
+ Channel,
7
+ ChannelManager,
8
+ validateChannelName,
9
+ createDataMessage,
10
+ } from './channel.js';
11
+ import type { DataMessage } from './channel.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Session constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Default session expiry window (24 hours). */
18
+ export const DEFAULT_SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Session event listeners
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type SessionStateListener = (event: StateChangedEvent) => void;
25
+ export type ChannelOpenedListener = (event: { channelName: string; streamId: number; metadata?: Uint8Array }) => void;
26
+ export type MessageListener = (event: { channel: Channel; data: Uint8Array }) => void;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Session
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * A session that survives transport disruptions.
34
+ *
35
+ * Holds session identity, state, sequence counters, and expiry information.
36
+ * The session layer is the primary abstraction the application interacts with;
37
+ * transport churn is invisible above this layer.
38
+ */
39
+ export class Session {
40
+ /** Unique session identifier (UUID v7). */
41
+ readonly id: string;
42
+ /** The remote peer's identifier. */
43
+ readonly peerId: Uint8Array;
44
+ /** Session state machine. */
45
+ private readonly _sm: SessionStateMachine;
46
+ /** Channel manager. */
47
+ private readonly _channels: ChannelManager;
48
+ /** When this session was created (ms since epoch). */
49
+ readonly createdAt: number;
50
+ /** How long until this session expires (default: 24h). */
51
+ readonly expiryMs: number;
52
+ /** Outbound message sequence counter. */
53
+ private _sequenceTx: number = 0;
54
+ /** Inbound message sequence counter. */
55
+ private _sequenceRx: number = 0;
56
+ /** Ratchet epoch counter, incremented on each reconnection. */
57
+ private _ratchetEpoch: number = 0;
58
+ /** Next stream ID for new channels. */
59
+ private _nextStreamId: number = 1;
60
+
61
+ private readonly _stateListeners: SessionStateListener[] = [];
62
+ private readonly _channelOpenedListeners: ChannelOpenedListener[] = [];
63
+ private readonly _messageListeners: MessageListener[] = [];
64
+
65
+ constructor(peerId: Uint8Array, expiryMs: number = DEFAULT_SESSION_EXPIRY_MS) {
66
+ this.id = uuidv7();
67
+ this.peerId = peerId;
68
+ this.createdAt = Date.now();
69
+ this.expiryMs = expiryMs;
70
+
71
+ this._sm = new SessionStateMachine(this.id);
72
+ this._sm.onStateChanged((event) => {
73
+ for (const listener of this._stateListeners) {
74
+ listener(event);
75
+ }
76
+ });
77
+
78
+ this._channels = new ChannelManager();
79
+ this._channels.onEvent((event) => {
80
+ if (event.type === 'opened') {
81
+ for (const listener of this._channelOpenedListeners) {
82
+ listener({
83
+ channelName: event.channelName,
84
+ streamId: event.streamId,
85
+ metadata: event.metadata,
86
+ });
87
+ }
88
+ }
89
+ });
90
+ }
91
+
92
+ /** Subscribe to state change events. */
93
+ onStateChanged(listener: SessionStateListener): void {
94
+ this._stateListeners.push(listener);
95
+ }
96
+
97
+ /** Subscribe to channel opened events. */
98
+ onChannelOpened(listener: ChannelOpenedListener): void {
99
+ this._channelOpenedListeners.push(listener);
100
+ }
101
+
102
+ /** Subscribe to message events. */
103
+ onMessage(listener: MessageListener): void {
104
+ this._messageListeners.push(listener);
105
+ }
106
+
107
+ /** Get the current connection state. */
108
+ get connectionState(): ConnectionState {
109
+ return this._sm.state;
110
+ }
111
+
112
+ /** Check if the session has expired. */
113
+ get isExpired(): boolean {
114
+ return Date.now() - this.createdAt > this.expiryMs;
115
+ }
116
+
117
+ /** Get the outbound sequence counter. */
118
+ get sequenceTx(): number {
119
+ return this._sequenceTx;
120
+ }
121
+
122
+ /** Get the inbound sequence counter. */
123
+ get sequenceRx(): number {
124
+ return this._sequenceRx;
125
+ }
126
+
127
+ /** Get the ratchet epoch. */
128
+ get ratchetEpoch(): number {
129
+ return this._ratchetEpoch;
130
+ }
131
+
132
+ /**
133
+ * Attempt a state transition.
134
+ * Throws CairnError if the transition is invalid.
135
+ */
136
+ transition(to: ConnectionState, reason?: string): void {
137
+ this._sm.transition(to, reason);
138
+ }
139
+
140
+ /** Increment and return the next outbound sequence number. */
141
+ nextSequenceTx(): number {
142
+ const seq = this._sequenceTx;
143
+ this._sequenceTx++;
144
+ return seq;
145
+ }
146
+
147
+ /** Update the inbound sequence number. */
148
+ setSequenceRx(seq: number): void {
149
+ this._sequenceRx = seq;
150
+ }
151
+
152
+ /** Advance the ratchet epoch (called on reconnection). */
153
+ advanceRatchetEpoch(): void {
154
+ this._ratchetEpoch++;
155
+ }
156
+
157
+ /**
158
+ * Open a new channel.
159
+ *
160
+ * Channel names starting with `__cairn_` are rejected.
161
+ */
162
+ openChannel(name: string, metadata?: Uint8Array): Channel {
163
+ validateChannelName(name);
164
+ const streamId = this._nextStreamId++;
165
+ this._channels.openChannel(name, streamId, metadata);
166
+ return this._channels.getChannel(streamId)!;
167
+ }
168
+
169
+ /** Handle an incoming channel init from a remote peer. */
170
+ handleChannelInit(streamId: number, channelName: string, metadata?: Uint8Array): void {
171
+ this._channels.handleChannelInit(streamId, { channelName, metadata });
172
+ }
173
+
174
+ /** Accept an incoming channel. */
175
+ acceptChannel(streamId: number): void {
176
+ this._channels.acceptChannel(streamId);
177
+ }
178
+
179
+ /** Reject an incoming channel. */
180
+ rejectChannel(streamId: number, reason?: string): void {
181
+ this._channels.rejectChannel(streamId, reason);
182
+ }
183
+
184
+ /** Get a channel by stream ID. */
185
+ getChannel(streamId: number): Channel | undefined {
186
+ return this._channels.getChannel(streamId);
187
+ }
188
+
189
+ /** Close a channel. */
190
+ closeChannel(streamId: number): void {
191
+ this._channels.closeChannel(streamId);
192
+ }
193
+
194
+ /** Get the number of tracked channels. */
195
+ get channelCount(): number {
196
+ return this._channels.channelCount;
197
+ }
198
+
199
+ /**
200
+ * Send data on a channel.
201
+ *
202
+ * Creates a DataMessage with a fresh UUID v7 msg_id.
203
+ * Increments the outbound sequence counter.
204
+ */
205
+ send(channel: Channel, data: Uint8Array): DataMessage {
206
+ if (!channel.isOpen()) {
207
+ throw new CairnError('PROTOCOL', `cannot send on channel '${channel.name}' in state ${channel.state}`);
208
+ }
209
+ this.nextSequenceTx();
210
+ return createDataMessage(data);
211
+ }
212
+
213
+ /** Handle incoming data on a channel. */
214
+ handleData(streamId: number, message: DataMessage): void {
215
+ this._channels.handleData(streamId, message);
216
+ const channel = this._channels.getChannel(streamId);
217
+ if (channel) {
218
+ for (const listener of this._messageListeners) {
219
+ listener({ channel, data: message.payload });
220
+ }
221
+ }
222
+ }
223
+ }