@floegence/flowersec-core 2.3.10 → 2.4.1

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 CHANGED
@@ -29,10 +29,69 @@ The root type exports are:
29
29
 
30
30
  Retry ownership belongs to `ConnectionController`; applications do not classify error text or run a parallel retry scheduler. Public failures remain redacted and reveal no carrier, candidate, URL, credential, stage, key, or diagnostic details.
31
31
 
32
- `RpcResult<Response>` is a discriminated union. `RpcPeer.call(...)` requires a decoder for successful payloads, so the typed success value has passed application validation before it is returned. Check `result.ok` before reading either the typed success `payload` or bounded application `error`; a result cannot contain both. RPC call and notify are portable across SDKs. TypeScript `RpcPeer.onNotify(...)` receives peer outbound notifications through the local Session's inbound reserved RPC stream.
32
+ `RpcResult<Response>` is a discriminated union. `RpcPeer.call(...)` requires a decoder for successful payloads, so the typed success value has passed application validation before it is returned. Check `result.ok` before reading either the typed success `payload` or bounded application `error`; a result cannot contain both. RPC call and notify accept only `JsonValue` payloads and reject values that cannot be represented on the wire before sending. TypeScript `RpcPeer.onNotify(typeId, decoder, handler)` receives peer outbound notifications through the local Session's inbound reserved RPC stream. A notification reaches the handler only after its decoder succeeds; decoder and handler failures are isolated from RPC serving.
33
33
 
34
34
  When connector options omit a connection timeout, browser and Node.js connectors use the shared ten-second default.
35
35
 
36
+ ### One-shot Node client
37
+
38
+ ```ts
39
+ import { RPCHandlers, connect } from "@floegence/flowersec-core/node";
40
+
41
+ const rpcHandlers = new RPCHandlers();
42
+ rpcHandlers.handleRPC(7, async (payload) => ({ payload }));
43
+ rpcHandlers.handleNotification(8, (payload) => onNotice(payload));
44
+ const session = await connect(lease, {
45
+ origin: "https://app.example",
46
+ rpcHandlers,
47
+ });
48
+ ```
49
+
50
+ ### Long-lived Node client
51
+
52
+ ```ts
53
+ import { RPCHandlers, createConnectionController } from "@floegence/flowersec-core/node";
54
+
55
+ const rpcHandlers = new RPCHandlers();
56
+ rpcHandlers.handleRPC(7, async (payload) => ({ payload }));
57
+ const controller = createConnectionController(source, {
58
+ origin: "https://app.example",
59
+ rpcHandlers,
60
+ });
61
+ controller.start();
62
+ const session = await controller.waitForSession();
63
+ ```
64
+
65
+ The immutable callback definition applies to every generation, while each
66
+ Session gets a fresh router. Terminated Session work is never replayed.
67
+
68
+ For the complete durable `ArtifactLease` spend workflow, see the
69
+ [TypeScript cookbook](../examples/ts/README.md). Node raw-QUIC-only artifacts
70
+ may omit `origin`; providing an absolute HTTP(S) origin enables WebSocket
71
+ candidates. Secure raw QUIC still requires an explicit `tls.ca` trust root.
72
+
73
+ ### Accepted Node server Session
74
+
75
+ ```ts
76
+ import { SessionHandlers, createAcceptor } from "@floegence/flowersec-core/node";
77
+
78
+ const handlers = new SessionHandlers({ maxConcurrentStreams: 32 });
79
+ handlers.handleRPC(7, async (payload) => ({ payload }));
80
+ handlers.handleNotification(8, (payload) => onNotice(payload));
81
+ handlers.handleStream("files/read", async (incoming) => serveFile(incoming));
82
+ const acceptor = await createAcceptor({
83
+ listeners,
84
+ maxInboundStreams: 32,
85
+ authorize,
86
+ resolveHandlers: () => handlers,
87
+ });
88
+ const accepted = await acceptor.accept();
89
+ await accepted.serve();
90
+ ```
91
+
92
+ `RPCHandlers` is available only from the Node entrypoint and cannot register
93
+ application streams. `SessionHandlers` is accepted-server-only.
94
+
36
95
  ## Connection Lifecycle
37
96
 
38
97
  The Browser and Node `connect(...)` operations are one-shot and never reconnect. Long-lived applications can create the runtime-specific `ConnectionController` with a refreshable `ArtifactSource`. Every attempt must return a fresh `ArtifactLease`; a one-time artifact or lease is not a controller source.
@@ -91,9 +150,10 @@ Cold-connection diagnostics require every independent carrier to meet the declar
91
150
 
92
151
  Node.js applications receive the same `Session` contract from `connect(...)`.
93
152
  The Node connector supports WSS, restricted plaintext loopback WebSocket
94
- direct connections, and raw QUIC through the optional native package. It
95
- requires an absolute HTTP(S) `origin`; custom certificate authorities can be
96
- supplied through `tls.ca`, and raw QUIC requires explicit trust roots.
153
+ direct connections, and raw QUIC through the optional native package. WebSocket
154
+ candidates require an absolute HTTP(S) `origin`; raw-QUIC-only artifacts may
155
+ omit it. Custom certificate authorities can be supplied through `tls.ca`, and
156
+ secure raw QUIC requires an explicit trust root.
97
157
 
98
158
  The connectors choose an eligible connection path from the invitation. They do
99
159
  not expose transport selectors, candidate lists, or native carrier objects to application code.
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { createPrivateKey, createPublicKey, X509Certificate } from "node:crypto";
3
- import { createAcceptor, createArtifactLease, createEndpointSet, createStreamMetadata, createTunnelRuntime, connect, Issuer, parseArtifact, SessionError, SessionHandlers, } from "../node/index.js";
3
+ import { createAcceptor, createArtifactLease, createEndpointSet, createStreamMetadata, createTunnelRuntime, connect, Issuer, parseArtifact, RPCHandlers, SessionError, SessionHandlers, } from "../node/index.js";
4
4
  const RUNTIME = "node-typescript";
5
5
  const ORIGIN = process.env.FLOWERSEC_PARITY_ORIGIN ?? "https://client.example";
6
6
  const ECHO_RPC = 7001;
@@ -56,38 +56,43 @@ class ExecutedCases {
56
56
  }
57
57
  }
58
58
  function createHandlers(path) {
59
- const handlers = new SessionHandlers({ maxConcurrentStreams: 16 });
59
+ const rpcHandlers = new RPCHandlers();
60
+ const sessionHandlers = new SessionHandlers({ maxConcurrentStreams: 16 });
60
61
  const notifications = new SignalQueue();
61
62
  const activeStreams = { value: 0 };
62
63
  const executed = new ExecutedCases();
63
- handlers.handleRPC(ECHO_RPC, async (payload) => {
64
- if (!validValuePayload(payload, "ping"))
65
- return { error: { code: 400, message: "invalid echo payload" } };
66
- executed.record("rpc");
67
- return { payload };
68
- });
69
- handlers.handleRPC(COMPLETE_RPC, async (payload) => {
70
- if (!validValuePayload(payload, "complete"))
71
- return { error: { code: 400, message: "invalid completion payload" } };
72
- executed.record("rekey", "liveness");
73
- notifications.push();
74
- return { payload };
75
- });
76
- handlers.handleRPC(DATAGRAM_READY_RPC, async (payload) => {
77
- if (!validValuePayload(payload, "datagram-ready"))
78
- return {
79
- error: { code: 400, message: "invalid datagram barrier payload" },
80
- };
81
- notifications.push();
82
- return { payload };
83
- });
84
- handlers.handleNotification(NOTIFY_RPC, (payload) => {
85
- if (!validValuePayload(payload, "notify"))
86
- throw new Error("invalid notification payload");
87
- executed.record("notification");
88
- notifications.push();
89
- });
90
- handlers.handleStream(ECHO_KIND, async (incoming) => {
64
+ const registerRPC = (handlers) => {
65
+ handlers.handleRPC(ECHO_RPC, async (payload) => {
66
+ if (!validValuePayload(payload, "ping"))
67
+ return { error: { code: 400, message: "invalid echo payload" } };
68
+ executed.record("rpc");
69
+ return { payload };
70
+ });
71
+ handlers.handleRPC(COMPLETE_RPC, async (payload) => {
72
+ if (!validValuePayload(payload, "complete"))
73
+ return { error: { code: 400, message: "invalid completion payload" } };
74
+ executed.record("rekey", "liveness");
75
+ notifications.push();
76
+ return { payload };
77
+ });
78
+ handlers.handleRPC(DATAGRAM_READY_RPC, async (payload) => {
79
+ if (!validValuePayload(payload, "datagram-ready"))
80
+ return {
81
+ error: { code: 400, message: "invalid datagram barrier payload" },
82
+ };
83
+ notifications.push();
84
+ return { payload };
85
+ });
86
+ handlers.handleNotification(NOTIFY_RPC, (payload) => {
87
+ if (!validValuePayload(payload, "notify"))
88
+ throw new Error("invalid notification payload");
89
+ executed.record("notification");
90
+ notifications.push();
91
+ });
92
+ };
93
+ registerRPC(rpcHandlers);
94
+ registerRPC(sessionHandlers);
95
+ sessionHandlers.handleStream(ECHO_KIND, async (incoming) => {
91
96
  activeStreams.value++;
92
97
  try {
93
98
  if (incoming.metadata.values.cell !== path)
@@ -102,13 +107,13 @@ function createHandlers(path) {
102
107
  activeStreams.value--;
103
108
  }
104
109
  });
105
- handlers.handleStream(RESET_KIND, async (incoming) => {
110
+ sessionHandlers.handleStream(RESET_KIND, async (incoming) => {
106
111
  if (decoder.decode(await readAll(incoming.stream)) !== "reset")
107
112
  throw new Error("invalid reset stream payload");
108
113
  executed.record("stream-reset");
109
114
  throw new Error("intentional parity reset");
110
115
  });
111
- return { handlers, notifications, activeStreams, executed };
116
+ return { rpcHandlers, sessionHandlers, notifications, activeStreams, executed };
112
117
  }
113
118
  function validValuePayload(payload, expected) {
114
119
  if (payload === null || typeof payload !== "object" || Array.isArray(payload))
@@ -315,11 +320,11 @@ function pem(label, encoded) {
315
320
  throw new Error("invalid embedded TLS fixture");
316
321
  return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`;
317
322
  }
318
- async function connectArtifact(artifactJSON, relay, handlers) {
323
+ async function connectArtifact(artifactJSON, relay, rpcHandlers) {
319
324
  return await connect(createArtifactLease(parseArtifact(artifactJSON), async () => undefined), {
320
325
  origin: relay.origin,
321
326
  tls: { ca: relay.trust_pem },
322
- handlers,
327
+ rpcHandlers,
323
328
  });
324
329
  }
325
330
  async function runServer(tls, carrier) {
@@ -334,7 +339,7 @@ async function runServer(tls, carrier) {
334
339
  ? { decision: "reject", reason: "invalid_credential" }
335
340
  : { decision: "allow", artifact };
336
341
  },
337
- resolveHandlers: () => state.handlers,
342
+ resolveHandlers: () => state.sessionHandlers,
338
343
  });
339
344
  try {
340
345
  const address = acceptor.addresses()[0];
@@ -387,7 +392,7 @@ async function runClient(input, carrier) {
387
392
  ready.artifact_json === "")
388
393
  throw new Error("invalid direct ready message");
389
394
  const state = createHandlers("direct");
390
- const session = await connectArtifact(ready.artifact_json, ready, state.handlers);
395
+ const session = await connectArtifact(ready.artifact_json, ready, state.rpcHandlers);
391
396
  state.executed.record("admission");
392
397
  await exerciseClient(session, state, "direct", carrier);
393
398
  await session.close().catch((error) => {
@@ -512,7 +517,7 @@ async function runTunnelEndpointB(input, carrier) {
512
517
  if (command.type !== "connect")
513
518
  throw new Error("endpoint B did not receive connect command");
514
519
  const state = createHandlers("tunnel");
515
- const session = await connectArtifact(secondJSON, relay, state.handlers);
520
+ const session = await connectArtifact(secondJSON, relay, state.rpcHandlers);
516
521
  state.executed.record("admission");
517
522
  if (process.env.FLOWERSEC_PARITY_CLIENT_PROFILE !== undefined) {
518
523
  await externalServer(session, state);
@@ -541,7 +546,7 @@ async function runTunnelEndpointA(input, carrier) {
541
546
  ready.endpoint_a_artifact_json === "")
542
547
  throw new Error("invalid endpoint B ready message");
543
548
  const state = createHandlers("tunnel");
544
- const session = await connectArtifact(ready.endpoint_a_artifact_json, ready.relay, state.handlers);
549
+ const session = await connectArtifact(ready.endpoint_a_artifact_json, ready.relay, state.rpcHandlers);
545
550
  state.executed.record("admission");
546
551
  await exerciseClient(session, state, "tunnel", carrier);
547
552
  await session.close().catch(() => undefined);
@@ -20,16 +20,25 @@ export type StreamHandler = (incoming: IncomingStream, options: OperationOptions
20
20
  export type SessionHandlerOptions = Readonly<{
21
21
  maxConcurrentStreams?: number;
22
22
  }>;
23
- export declare class SessionHandlersError extends Error {
23
+ export declare class HandlerRegistrationError extends Error {
24
24
  readonly code: "invalid_handler" | "already_registered" | "frozen";
25
25
  constructor(code: "invalid_handler" | "already_registered" | "frozen");
26
26
  }
27
+ export declare class RPCHandlers {
28
+ constructor();
29
+ handleRPC(typeId: number, handler: RPCHandler): void;
30
+ handleNotification(typeId: number, handler: NotificationHandler): void;
31
+ }
27
32
  export declare class SessionHandlers {
28
33
  constructor(options?: SessionHandlerOptions);
29
34
  handleRPC(typeId: number, handler: RPCHandler): void;
30
35
  handleNotification(typeId: number, handler: NotificationHandler): void;
31
36
  handleStream(kind: string, handler: StreamHandler): void;
32
37
  }
38
+ export type FrozenRPCHandlers = Readonly<{
39
+ requests: ReadonlyMap<number, RPCHandler>;
40
+ notifications: ReadonlyMap<number, NotificationHandler>;
41
+ }>;
33
42
  export type AcceptorListener = Readonly<{
34
43
  carrier: "websocket";
35
44
  path: "direct";
@@ -12,12 +12,23 @@ const DEFAULT_MAX_CONCURRENT_STREAMS = 64;
12
12
  const MAX_CONCURRENT_STREAMS = 128;
13
13
  const DEFAULT_CLEANUP_TIMEOUT_MS = 2_000;
14
14
  const encoder = new TextEncoder();
15
- export class SessionHandlersError extends Error {
15
+ export class HandlerRegistrationError extends Error {
16
16
  code;
17
17
  constructor(code) {
18
- super(`Flowersec session handler registration failed (code=${code})`);
18
+ super(`Flowersec handler registration failed (code=${code})`);
19
19
  this.code = code;
20
- this.name = "SessionHandlersError";
20
+ this.name = "HandlerRegistrationError";
21
+ }
22
+ }
23
+ export class RPCHandlers {
24
+ constructor() {
25
+ rpcHandlerStates.set(this, createRPCHandlerState());
26
+ }
27
+ handleRPC(typeId, handler) {
28
+ registerRPC(mutableRPCHandlerState(this), typeId, handler);
29
+ }
30
+ handleNotification(typeId, handler) {
31
+ registerNotification(mutableRPCHandlerState(this), typeId, handler);
21
32
  }
22
33
  }
23
34
  export class SessionHandlers {
@@ -26,68 +37,107 @@ export class SessionHandlers {
26
37
  if (!Number.isSafeInteger(maximum) ||
27
38
  maximum < 1 ||
28
39
  maximum > MAX_CONCURRENT_STREAMS) {
29
- throw new SessionHandlersError("invalid_handler");
40
+ throw new HandlerRegistrationError("invalid_handler");
30
41
  }
31
42
  sessionHandlerStates.set(this, {
32
43
  maxConcurrentStreams: maximum,
33
- rpc: new Map(),
34
- notifications: new Map(),
44
+ rpc: createRPCHandlerState(),
35
45
  streams: new Map(),
36
46
  frozen: false,
37
47
  });
38
48
  }
39
49
  handleRPC(typeId, handler) {
40
- const state = mutableHandlerState(this);
41
- if (!Number.isSafeInteger(typeId) ||
42
- typeId < 1 ||
43
- typeId > 0xffff_ffff ||
44
- typeof handler !== "function") {
45
- throw new SessionHandlersError("invalid_handler");
46
- }
47
- if (state.rpc.has(typeId))
48
- throw new SessionHandlersError("already_registered");
49
- if (state.notifications.has(typeId))
50
- throw new SessionHandlersError("already_registered");
51
- state.rpc.set(typeId, handler);
50
+ const state = mutableSessionHandlerState(this);
51
+ registerRPC(state.rpc, typeId, handler);
52
52
  }
53
53
  handleNotification(typeId, handler) {
54
- const state = mutableHandlerState(this);
55
- if (!Number.isSafeInteger(typeId) ||
56
- typeId < 1 ||
57
- typeId > 0xffff_ffff ||
58
- typeof handler !== "function") {
59
- throw new SessionHandlersError("invalid_handler");
60
- }
61
- if (state.rpc.has(typeId) || state.notifications.has(typeId))
62
- throw new SessionHandlersError("already_registered");
63
- state.notifications.set(typeId, handler);
54
+ const state = mutableSessionHandlerState(this);
55
+ registerNotification(state.rpc, typeId, handler);
64
56
  }
65
57
  handleStream(kind, handler) {
66
- const state = mutableHandlerState(this);
58
+ const state = mutableSessionHandlerState(this);
67
59
  if (kind.length < 1 ||
68
60
  encoder.encode(kind).length > 255 ||
69
61
  kind === "flowersec.rpc.v2" ||
70
62
  typeof handler !== "function") {
71
- throw new SessionHandlersError("invalid_handler");
63
+ throw new HandlerRegistrationError("invalid_handler");
72
64
  }
73
65
  if (state.streams.has(kind))
74
- throw new SessionHandlersError("already_registered");
66
+ throw new HandlerRegistrationError("already_registered");
75
67
  state.streams.set(kind, handler);
76
68
  }
77
69
  }
70
+ const rpcHandlerStates = new WeakMap();
78
71
  const sessionHandlerStates = new WeakMap();
79
- function mutableHandlerState(handlers) {
72
+ function createRPCHandlerState() {
73
+ return { requests: new Map(), notifications: new Map(), frozen: false };
74
+ }
75
+ function mutableRPCHandlerState(handlers) {
76
+ const state = rpcHandlerStates.get(handlers);
77
+ if (state === undefined)
78
+ throw new HandlerRegistrationError("invalid_handler");
79
+ if (state.frozen)
80
+ throw new HandlerRegistrationError("frozen");
81
+ return state;
82
+ }
83
+ function mutableSessionHandlerState(handlers) {
80
84
  const state = sessionHandlerStates.get(handlers);
81
85
  if (state === undefined)
82
- throw new SessionHandlersError("invalid_handler");
86
+ throw new HandlerRegistrationError("invalid_handler");
83
87
  if (state.frozen)
84
- throw new SessionHandlersError("frozen");
88
+ throw new HandlerRegistrationError("frozen");
85
89
  return state;
86
90
  }
87
- function freezeHandlers(handlers, router) {
88
- const state = mutableHandlerState(handlers);
91
+ function registerRPC(state, typeId, handler) {
92
+ validateRPCRegistration(typeId, handler);
93
+ if (state.requests.has(typeId) || state.notifications.has(typeId)) {
94
+ throw new HandlerRegistrationError("already_registered");
95
+ }
96
+ state.requests.set(typeId, handler);
97
+ }
98
+ function registerNotification(state, typeId, handler) {
99
+ validateRPCRegistration(typeId, handler);
100
+ if (state.requests.has(typeId) || state.notifications.has(typeId)) {
101
+ throw new HandlerRegistrationError("already_registered");
102
+ }
103
+ state.notifications.set(typeId, handler);
104
+ }
105
+ function validateRPCRegistration(typeId, handler) {
106
+ if (!Number.isSafeInteger(typeId)
107
+ || typeId < 1
108
+ || typeId > 0xffff_ffff
109
+ || typeof handler !== "function") {
110
+ throw new HandlerRegistrationError("invalid_handler");
111
+ }
112
+ }
113
+ /** @internal */
114
+ export function freezeRPCHandlers(handlers) {
115
+ const state = rpcHandlerStates.get(handlers);
116
+ if (state === undefined)
117
+ throw new HandlerRegistrationError("invalid_handler");
118
+ if (state.snapshot !== undefined)
119
+ return state.snapshot;
120
+ state.frozen = true;
121
+ state.snapshot = Object.freeze({
122
+ requests: new Map(state.requests),
123
+ notifications: new Map(state.notifications),
124
+ });
125
+ return state.snapshot;
126
+ }
127
+ function freezeRPCHandlerState(state) {
128
+ if (state.snapshot !== undefined)
129
+ return state.snapshot;
89
130
  state.frozen = true;
90
- for (const [typeId, handler] of state.rpc) {
131
+ state.snapshot = Object.freeze({
132
+ requests: new Map(state.requests),
133
+ notifications: new Map(state.notifications),
134
+ });
135
+ return state.snapshot;
136
+ }
137
+ /** @internal */
138
+ export function createRPCRouter(snapshot) {
139
+ const router = new RpcRouter();
140
+ for (const [typeId, handler] of snapshot.requests) {
91
141
  router.register(typeId, async (payload) => {
92
142
  const result = await handler(payload, Object.freeze({ typeId }));
93
143
  if ("error" in result)
@@ -95,34 +145,39 @@ function freezeHandlers(handlers, router) {
95
145
  return { payload: result.payload };
96
146
  });
97
147
  }
98
- for (const [typeId, handler] of state.notifications) {
148
+ for (const [typeId, handler] of snapshot.notifications) {
99
149
  router.onNotify(typeId, (payload) => {
100
150
  void Promise.resolve(handler(payload, Object.freeze({ typeId }))).catch(() => undefined);
101
151
  });
102
152
  }
103
- return Object.freeze({
153
+ return router;
154
+ }
155
+ function freezeSessionHandlers(handlers) {
156
+ const state = sessionHandlerStates.get(handlers);
157
+ if (state === undefined)
158
+ throw new HandlerRegistrationError("invalid_handler");
159
+ if (state.snapshot !== undefined)
160
+ return state.snapshot;
161
+ state.frozen = true;
162
+ state.snapshot = Object.freeze({
163
+ rpc: freezeRPCHandlerState(state.rpc),
104
164
  maxConcurrentStreams: state.maxConcurrentStreams,
105
165
  streams: new Map(state.streams),
106
166
  });
107
- }
108
- /** @internal */
109
- export function freezeSessionHandlersForConnector(handlers) {
110
- const router = new RpcRouter();
111
- freezeHandlers(handlers, router);
112
- return router;
167
+ return state.snapshot;
113
168
  }
114
169
  /** @internal */
115
170
  export function registerSessionStreamsAtomically(handlers, entries) {
116
- const state = mutableHandlerState(handlers);
171
+ const state = mutableSessionHandlerState(handlers);
117
172
  const pending = new Set();
118
173
  for (const [kind, handler] of entries) {
119
174
  if (kind.length < 1 ||
120
175
  encoder.encode(kind).length > 255 ||
121
176
  kind === "flowersec.rpc.v2" ||
122
177
  typeof handler !== "function")
123
- throw new SessionHandlersError("invalid_handler");
178
+ throw new HandlerRegistrationError("invalid_handler");
124
179
  if (state.streams.has(kind) || pending.has(kind))
125
- throw new SessionHandlersError("already_registered");
180
+ throw new HandlerRegistrationError("already_registered");
126
181
  pending.add(kind);
127
182
  }
128
183
  for (const [kind, handler] of entries)
@@ -237,17 +292,17 @@ async function authorizeCarrier(state, carrier) {
237
292
  ? decision.leaseId
238
293
  : undefined;
239
294
  try {
240
- const router = new RpcRouter();
241
295
  const registry = state.options.resolveHandlers === undefined
242
296
  ? new SessionHandlers()
243
297
  : await abortableCallback(Promise.resolve(state.options.resolveHandlers(request, {
244
298
  signal: state.abort.signal,
245
299
  })), state.abort.signal);
300
+ const handlers = freezeSessionHandlers(registry);
246
301
  const leg = {
247
302
  received,
248
303
  artifact: unwrapArtifact(decision.artifact),
249
- handlers: freezeHandlers(registry, router),
250
- router,
304
+ handlers,
305
+ router: createRPCRouter(handlers.rpc),
251
306
  };
252
307
  if (leaseId !== undefined)
253
308
  leg.leaseId = leaseId;
@@ -1,22 +1,23 @@
1
1
  import type { ArtifactLease } from "../public/artifactLease.js";
2
2
  import type { Session } from "../public/contract.js";
3
3
  import { type ArtifactSource, type ConnectionController } from "../connectionController.js";
4
- import { type SessionHandlers } from "./acceptor.js";
4
+ import { type RPCHandlers } from "./acceptor.js";
5
5
  export type SessionTLSOptions = Readonly<{
6
6
  ca?: string | Uint8Array;
7
7
  }>;
8
8
  export type SessionOptions = Readonly<{
9
- origin: string;
9
+ origin?: string;
10
10
  signal?: AbortSignal;
11
11
  connectTimeoutMs?: number;
12
12
  tls?: SessionTLSOptions;
13
- handlers?: SessionHandlers;
13
+ rpcHandlers?: RPCHandlers;
14
14
  }>;
15
15
  export type ConnectionControllerOptions = Readonly<{
16
- origin: string;
16
+ origin?: string;
17
17
  connectTimeoutMs?: number;
18
18
  tls?: SessionTLSOptions;
19
19
  maximumAttempts?: number;
20
+ rpcHandlers?: RPCHandlers;
20
21
  }>;
21
22
  export declare function createConnectionController(source: ArtifactSource, options: ConnectionControllerOptions): ConnectionController;
22
23
  export declare function connect(lease: ArtifactLease, options: SessionOptions): Promise<Session>;
@@ -7,33 +7,24 @@ import { projectSessionV2 } from "../v2/publicSession.js";
7
7
  import { ConnectError } from "../public/connectError.js";
8
8
  import { nodeSessionRuntimeV2 } from "./sessionRuntime.js";
9
9
  import { createConnectionControllerV2, } from "../connectionController.js";
10
- import { freezeSessionHandlersForConnector, } from "./acceptor.js";
10
+ import { createRPCRouter, freezeRPCHandlers, } from "./acceptor.js";
11
11
  import { createNativeRawQuicDriver, NativeTransportUnavailableError, tryLoadNativeTransportAddon, } from "./nativeTransportAddon.js";
12
- import { createNodeRawQuicClientV2 } from "./rawQuicAdapter.js";
12
+ import { createNodeRawQuicClientV2, normalizeCertificateChain } from "./rawQuicAdapter.js";
13
13
  export function createConnectionController(source, options) {
14
+ const normalized = normalizeNodeOptions(options);
14
15
  const controllerOptions = options.maximumAttempts === undefined
15
16
  ? {}
16
17
  : { maximumAttempts: options.maximumAttempts };
17
- return createConnectionControllerV2(source, async (lease, signal) => await connect(lease, {
18
- origin: options.origin,
19
- signal,
20
- ...(options.connectTimeoutMs === undefined ? {} : { connectTimeoutMs: options.connectTimeoutMs }),
21
- ...(options.tls === undefined ? {} : { tls: options.tls }),
22
- }), controllerOptions);
18
+ return createConnectionControllerV2(source, async (lease, signal) => await connectWithRPCSnapshot(lease, normalized, signal), controllerOptions);
23
19
  }
24
20
  export async function connect(lease, options) {
25
- let origin;
26
- let wsFactory;
27
- try {
28
- origin = normalizeOrigin(options.origin);
29
- wsFactory = createNodeWsFactory(options.tls);
30
- }
31
- catch {
32
- throw new ConnectError("invalid_options");
33
- }
34
- const rpcRouter = options.handlers === undefined
21
+ const normalized = normalizeNodeOptions(options);
22
+ return await connectWithRPCSnapshot(lease, normalized, options.signal);
23
+ }
24
+ async function connectWithRPCSnapshot(lease, options, signal) {
25
+ const rpcRouter = options.rpcSnapshot === undefined
35
26
  ? undefined
36
- : freezeSessionHandlersForConnector(options.handlers);
27
+ : createRPCRouter(options.rpcSnapshot);
37
28
  // WebSocket sessions do not require the optional native raw QUIC addon. Load
38
29
  // it only when a raw QUIC candidate is actually selected by the connector.
39
30
  const nativeAddon = tryLoadNativeTransportAddon();
@@ -45,8 +36,14 @@ export async function connect(lease, options) {
45
36
  }
46
37
  return await createNodeRawQuicClientV2(createNativeRawQuicDriver(nativeAddon), candidate, artifact, { ca: options.tls.ca }, signal, options.connectTimeoutMs);
47
38
  });
39
+ const origin = options.origin;
40
+ let websocketFactory;
41
+ if (origin !== undefined) {
42
+ const wsFactory = createNodeWsFactory(options.tls);
43
+ websocketFactory = createWebSocketCandidateFactoryV2((url, subprotocol) => wsFactory(url, origin, subprotocol));
44
+ }
48
45
  const connector = new SessionConnectorV2(lease, composeCandidateAttemptFactoryV2({
49
- websocket: createWebSocketCandidateFactoryV2((url, subprotocol) => wsFactory(url, origin, subprotocol)),
46
+ ...(websocketFactory === undefined ? {} : { websocket: websocketFactory }),
50
47
  raw_quic: rawQuicFactory,
51
48
  }), {
52
49
  capability: detectNodeRuntimeCapabilityV2(nativeAddon !== undefined),
@@ -54,10 +51,34 @@ export async function connect(lease, options) {
54
51
  ...(rpcRouter === undefined ? {} : { rpcRouter }),
55
52
  ...(options.connectTimeoutMs === undefined ? {} : { connectTimeoutMs: options.connectTimeoutMs }),
56
53
  });
57
- const result = await connector.connect(options.signal === undefined ? {} : { signal: options.signal });
54
+ const result = await connector.connect(signal === undefined ? {} : { signal });
58
55
  return projectSessionV2(result.session);
59
56
  }
57
+ function normalizeNodeOptions(options) {
58
+ try {
59
+ const origin = normalizeOrigin(options.origin);
60
+ if (options.connectTimeoutMs !== undefined &&
61
+ (!Number.isSafeInteger(options.connectTimeoutMs) || options.connectTimeoutMs < 1)) {
62
+ throw new RangeError("connectTimeoutMs must be a positive safe integer");
63
+ }
64
+ if (origin !== undefined)
65
+ createNodeWsFactory(options.tls);
66
+ if (options.tls?.ca !== undefined)
67
+ normalizeCertificateChain(options.tls.ca);
68
+ return Object.freeze({
69
+ ...(origin === undefined ? {} : { origin }),
70
+ ...(options.connectTimeoutMs === undefined ? {} : { connectTimeoutMs: options.connectTimeoutMs }),
71
+ ...(options.tls === undefined ? {} : { tls: options.tls }),
72
+ ...(options.rpcHandlers === undefined ? {} : { rpcSnapshot: freezeRPCHandlers(options.rpcHandlers) }),
73
+ });
74
+ }
75
+ catch {
76
+ throw new ConnectError("invalid_options");
77
+ }
78
+ }
60
79
  function normalizeOrigin(input) {
80
+ if (input === undefined)
81
+ return undefined;
61
82
  let parsed;
62
83
  try {
63
84
  parsed = new URL(input);
@@ -1,5 +1,5 @@
1
1
  export { connect, createConnectionController } from "./connectSession.js";
2
- export { AcceptedSession, Acceptor, SessionHandlers, SessionHandlersError, createAcceptor, } from "./acceptor.js";
2
+ export { AcceptedSession, Acceptor, HandlerRegistrationError, RPCHandlers, SessionHandlers, createAcceptor, } from "./acceptor.js";
3
3
  export type { AcceptorListener, AcceptorOptions, AuthorizationDecision, RPCHandler, RPCHandlerResult, NotificationHandler, SessionHandlerOptions, StreamHandler, } from "./acceptor.js";
4
4
  export { TunnelRuntime, createTunnelRuntime } from "./tunnelRuntime.js";
5
5
  export type { TunnelAuthorizationDecision, TunnelRuntimeListener, TunnelRuntimeOptions, } from "./tunnelRuntime.js";
@@ -1,5 +1,5 @@
1
1
  export { connect, createConnectionController } from "./connectSession.js";
2
- export { AcceptedSession, Acceptor, SessionHandlers, SessionHandlersError, createAcceptor, } from "./acceptor.js";
2
+ export { AcceptedSession, Acceptor, HandlerRegistrationError, RPCHandlers, SessionHandlers, createAcceptor, } from "./acceptor.js";
3
3
  export { TunnelRuntime, createTunnelRuntime } from "./tunnelRuntime.js";
4
4
  export { AuthorizationRecord, AuthorizationResponse, TunnelAuthorizationResponse, ControlPlaneError, EndpointSet, IssuedArtifact, Issuer, RuntimeAuthorizationRequest, authorizeRuntime, authorizeTunnelRuntime, createEndpointSet, parseAuthorizationRecord, parseRuntimeAuthorizationRequest, rejectRuntime, rejectTunnelRuntime, } from "./controlplane.js";
5
5
  export { ProxyServer, ProxyServerError } from "./proxyServer.js";
@@ -42,9 +42,9 @@ export type RpcResult<Response = unknown> = Readonly<{
42
42
  }>;
43
43
  }>;
44
44
  export interface RpcPeer {
45
- call<Request = unknown, Response = unknown>(typeId: number, payload: Request, decodeResponse: (payload: JsonValue) => Response, options?: OperationOptions): Promise<RpcResult<Response>>;
46
- notify<Payload = unknown>(typeId: number, payload: Payload, options?: OperationOptions): Promise<void>;
47
- onNotify<Payload = unknown>(typeId: number, handler: (payload: Payload) => void): () => void;
45
+ call<Request extends JsonValue = JsonValue, Response = unknown>(typeId: number, payload: Request, decodeResponse: (payload: JsonValue) => Response, options?: OperationOptions): Promise<RpcResult<Response>>;
46
+ notify<Payload extends JsonValue = JsonValue>(typeId: number, payload: Payload, options?: OperationOptions): Promise<void>;
47
+ onNotify<Payload>(typeId: number, decodePayload: (payload: JsonValue) => Payload, handler: (payload: Payload) => void | Promise<void>): () => void;
48
48
  }
49
49
  export interface ByteStream {
50
50
  readonly kind: string;
@@ -2,8 +2,18 @@ import { SessionError } from "./contract.js";
2
2
  import { createStreamMetadataV2, streamMetadataValuesV2 } from "./streamMetadata.js";
3
3
  /** @internal */
4
4
  export function projectSessionV2(session) {
5
- const rpc = projectRpcPeerV2(session.rpc);
5
+ const notificationOwner = {
6
+ subscriptions: new Set(),
7
+ closed: false,
8
+ };
9
+ const clearNotificationSubscriptions = () => {
10
+ notificationOwner.closed = true;
11
+ for (const unsubscribe of [...notificationOwner.subscriptions])
12
+ unsubscribe();
13
+ };
14
+ const rpc = projectRpcPeerV2(session.rpc, notificationOwner);
6
15
  const unreliable = session.unreliableMessages;
16
+ void session.termination.then(clearNotificationSubscriptions, clearNotificationSubscriptions);
7
17
  return Object.freeze({
8
18
  rpc,
9
19
  ...(unreliable === undefined ? {} : { unreliableMessages: unreliable }),
@@ -64,6 +74,9 @@ export function projectSessionV2(session) {
64
74
  catch (error) {
65
75
  throw redactSessionError(error);
66
76
  }
77
+ finally {
78
+ clearNotificationSubscriptions();
79
+ }
67
80
  },
68
81
  });
69
82
  }
@@ -115,14 +128,16 @@ function projectByteStreamV2(stream) {
115
128
  },
116
129
  });
117
130
  }
118
- function projectRpcPeerV2(peer) {
131
+ function projectRpcPeerV2(peer, notificationOwner) {
119
132
  return Object.freeze({
120
133
  async call(typeId, payload, decodeResponse, options) {
121
134
  try {
135
+ assertJsonValue(payload);
122
136
  const result = await peer.call(typeId, payload, options?.signal);
123
137
  if (result.error !== undefined) {
124
138
  return Object.freeze({ ok: false, error: Object.freeze({ ...result.error }) });
125
139
  }
140
+ assertJsonValue(result.payload);
126
141
  return Object.freeze({ ok: true, payload: decodeResponse(result.payload) });
127
142
  }
128
143
  catch (error) {
@@ -131,6 +146,7 @@ function projectRpcPeerV2(peer) {
131
146
  },
132
147
  async notify(typeId, payload, options) {
133
148
  try {
149
+ assertJsonValue(payload);
134
150
  if (options?.signal?.aborted)
135
151
  throw options.signal.reason ?? new DOMException("The operation was aborted", "AbortError");
136
152
  await raceWithSignal(peer.notify(typeId, payload), options?.signal);
@@ -139,11 +155,75 @@ function projectRpcPeerV2(peer) {
139
155
  throw redactSessionError(error);
140
156
  }
141
157
  },
142
- onNotify(typeId, handler) {
143
- return peer.onNotify(typeId, (payload) => handler(payload));
158
+ onNotify(typeId, decodePayload, handler) {
159
+ if (notificationOwner.closed)
160
+ return () => undefined;
161
+ const unsubscribe = peer.onNotify(typeId, (payload) => {
162
+ let decoded;
163
+ try {
164
+ assertJsonValue(payload);
165
+ decoded = decodePayload(payload);
166
+ }
167
+ catch {
168
+ return;
169
+ }
170
+ try {
171
+ void Promise.resolve(handler(decoded)).catch(() => undefined);
172
+ }
173
+ catch {
174
+ // Notification handlers are isolated from RPC serving.
175
+ }
176
+ });
177
+ let subscribed = true;
178
+ const cancel = () => {
179
+ if (!subscribed)
180
+ return;
181
+ subscribed = false;
182
+ notificationOwner.subscriptions.delete(cancel);
183
+ unsubscribe();
184
+ };
185
+ notificationOwner.subscriptions.add(cancel);
186
+ return cancel;
144
187
  },
145
188
  });
146
189
  }
190
+ function assertJsonValue(value) {
191
+ validateJsonValue(value, new Set());
192
+ }
193
+ function validateJsonValue(value, ancestors) {
194
+ if (value === null || typeof value === "string" || typeof value === "boolean")
195
+ return;
196
+ if (typeof value === "number") {
197
+ if (Number.isFinite(value))
198
+ return;
199
+ throw new TypeError("RPC payload contains a non-finite number");
200
+ }
201
+ if (typeof value !== "object")
202
+ throw new TypeError("RPC payload is not a JSON value");
203
+ if (ancestors.has(value))
204
+ throw new TypeError("RPC payload contains a cycle");
205
+ ancestors.add(value);
206
+ try {
207
+ if (Array.isArray(value)) {
208
+ for (let index = 0; index < value.length; index += 1) {
209
+ if (!(index in value))
210
+ throw new TypeError("RPC payload contains a sparse array");
211
+ validateJsonValue(value[index], ancestors);
212
+ }
213
+ return;
214
+ }
215
+ const prototype = Object.getPrototypeOf(value);
216
+ if (prototype !== Object.prototype && prototype !== null) {
217
+ throw new TypeError("RPC payload contains a non-JSON object");
218
+ }
219
+ for (const key of Object.keys(value)) {
220
+ validateJsonValue(value[key], ancestors);
221
+ }
222
+ }
223
+ finally {
224
+ ancestors.delete(value);
225
+ }
226
+ }
147
227
  async function raceWithSignal(operation, signal) {
148
228
  if (signal === undefined)
149
229
  return await operation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "2.3.10",
3
+ "version": "2.4.1",
4
4
  "description": "Flowersec core TypeScript library for carrier-neutral encrypted sessions and multiplexed streams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,7 +77,7 @@
77
77
  "ws": "^8.21.2"
78
78
  },
79
79
  "optionalDependencies": {
80
- "@floegence/flowersec-node-native": "2.3.10"
80
+ "@floegence/flowersec-node-native": "2.4.1"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@playwright/test": "1.62.1",
@@ -95,5 +95,5 @@
95
95
  "vite": "^8.2.1",
96
96
  "vitest": "4.1.10"
97
97
  },
98
- "flowersecSourceCommit": "67e5ff521e77982e4f57e8ba5eb858df3e48c886"
98
+ "flowersecSourceCommit": "3605cb0c32551ec5bc2f799cd2e086c5c3102177"
99
99
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:2ff806a3-4e32-5ae7-867b-3dc94826ed55",
4
+ "serialNumber": "urn:uuid:1b2d0f50-da64-544b-8761-aaf9dd5a7c79",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
9
  "name": "@floegence/flowersec-core",
10
- "version": "2.3.10",
11
- "purl": "pkg:npm/%40floegence/flowersec-core@2.3.10",
12
- "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.3.10"
10
+ "version": "2.4.1",
11
+ "purl": "pkg:npm/%40floegence/flowersec-core@2.4.1",
12
+ "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.4.1"
13
13
  },
14
14
  "properties": [
15
15
  {
16
16
  "name": "flowersec:source-inventory-sha256",
17
- "value": "36475d4f06355a764285462713f086777546ed34fe2ea5911961d9bb9a3037f1"
17
+ "value": "661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941"
18
18
  }
19
19
  ]
20
20
  },
@@ -190,7 +190,7 @@
190
190
  ],
191
191
  "dependencies": [
192
192
  {
193
- "ref": "pkg:npm/%40floegence/flowersec-core@2.3.10",
193
+ "ref": "pkg:npm/%40floegence/flowersec-core@2.4.1",
194
194
  "dependsOn": [
195
195
  "pkg:npm/%40noble/ciphers@2.3.0",
196
196
  "pkg:npm/%40noble/curves@2.3.0",
package/sbom/spdx.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
5
  "name": "flowersec-ts",
6
- "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/36475d4f06355a764285462713f086777546ed34fe2ea5911961d9bb9a3037f1",
6
+ "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941",
7
7
  "creationInfo": {
8
8
  "created": "1970-01-01T00:00:00Z",
9
9
  "creators": [
@@ -13,19 +13,19 @@
13
13
  "packages": [
14
14
  {
15
15
  "name": "@floegence/flowersec-core",
16
- "SPDXID": "SPDXRef-Package-bd5ff7d2005890830d32",
17
- "versionInfo": "2.3.10",
16
+ "SPDXID": "SPDXRef-Package-4eeba6fa52c374d21b02",
17
+ "versionInfo": "2.4.1",
18
18
  "downloadLocation": "NOASSERTION",
19
19
  "filesAnalyzed": false,
20
20
  "licenseConcluded": "NOASSERTION",
21
21
  "licenseDeclared": "NOASSERTION",
22
22
  "copyrightText": "NOASSERTION",
23
- "comment": "Flowersec source inventory SHA-256: 36475d4f06355a764285462713f086777546ed34fe2ea5911961d9bb9a3037f1",
23
+ "comment": "Flowersec source inventory SHA-256: 661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941",
24
24
  "externalRefs": [
25
25
  {
26
26
  "referenceCategory": "PACKAGE-MANAGER",
27
27
  "referenceType": "purl",
28
- "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.3.10"
28
+ "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.4.1"
29
29
  }
30
30
  ]
31
31
  },
@@ -136,30 +136,30 @@
136
136
  {
137
137
  "spdxElementId": "SPDXRef-DOCUMENT",
138
138
  "relationshipType": "DESCRIBES",
139
- "relatedSpdxElement": "SPDXRef-Package-bd5ff7d2005890830d32"
139
+ "relatedSpdxElement": "SPDXRef-Package-4eeba6fa52c374d21b02"
140
140
  },
141
141
  {
142
- "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
142
+ "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
143
143
  "relationshipType": "DEPENDS_ON",
144
144
  "relatedSpdxElement": "SPDXRef-Package-5ce913a03239b02770fb"
145
145
  },
146
146
  {
147
- "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
147
+ "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
148
148
  "relationshipType": "DEPENDS_ON",
149
149
  "relatedSpdxElement": "SPDXRef-Package-01009adf60db02c13634"
150
150
  },
151
151
  {
152
- "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
152
+ "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
153
153
  "relationshipType": "DEPENDS_ON",
154
154
  "relatedSpdxElement": "SPDXRef-Package-8815b117c8a1d5f7eeb0"
155
155
  },
156
156
  {
157
- "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
157
+ "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
158
158
  "relationshipType": "DEPENDS_ON",
159
159
  "relatedSpdxElement": "SPDXRef-Package-f8b45a289df643042b94"
160
160
  },
161
161
  {
162
- "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
162
+ "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
163
163
  "relationshipType": "DEPENDS_ON",
164
164
  "relatedSpdxElement": "SPDXRef-Package-b779412f685822663496"
165
165
  },