@floegence/flowersec-core 2.4.1 → 2.4.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.
@@ -2,7 +2,7 @@ import { readU32be, u32be } from "../utils/bin.js";
2
2
  import { SDK_DEFAULTS } from "../defaults.js";
3
3
  export const DEFAULT_MAX_JSON_FRAME_BYTES = SDK_DEFAULTS.rpc.maxJsonFrameBytes;
4
4
  const te = new TextEncoder();
5
- const td = new TextDecoder();
5
+ const td = new TextDecoder("utf-8", { fatal: true });
6
6
  // JsonFramingError marks malformed or oversized frames.
7
7
  export class JsonFramingError extends Error {
8
8
  }
@@ -29,5 +29,12 @@ export async function readJsonFrame(readExactly, maxBytes) {
29
29
  if (maxBytes > 0 && n > maxBytes)
30
30
  throw new JsonFramingError("frame too large");
31
31
  const payload = await read(n);
32
- return JSON.parse(td.decode(payload));
32
+ let text;
33
+ try {
34
+ text = td.decode(payload);
35
+ }
36
+ catch {
37
+ throw new JsonFramingError("invalid UTF-8");
38
+ }
39
+ return JSON.parse(text);
33
40
  }
@@ -1,4 +1,5 @@
1
1
  import { RpcRouter } from "../rpc/server.js";
2
+ import { assertRpcError } from "../rpc/validate.js";
2
3
  import { acceptReceivedSessionV2, receiveSessionAdmissionV2, rejectSessionAdmissionV2, } from "../connector/sessionAcceptor.js";
3
4
  import { nodeSessionRuntimeV2 } from "./sessionRuntime.js";
4
5
  import { startNodeWebSocketServer, } from "./webSocketServer.js";
@@ -264,11 +265,27 @@ export class Acceptor {
264
265
  }
265
266
  async close() {
266
267
  const state = acceptorState(this);
267
- state.abort.abort();
268
- state.accepted.close();
269
- await Promise.all(state.listeners.map(async (listener) => await listener.close()));
270
- await boundedCleanup(state.tasks, state.cleanupTimeoutMs);
271
- state.tasks.clear();
268
+ if (state.lifecycle.completion === undefined) {
269
+ state.lifecycle.completion = closeAcceptor(state);
270
+ }
271
+ await withCleanupTimeout(state.lifecycle.completion, state.cleanupTimeoutMs);
272
+ }
273
+ }
274
+ const MAX_CONCURRENT_ADMISSIONS = 64;
275
+ const MAX_PENDING_ACCEPTED_SESSIONS = 64;
276
+ async function closeAcceptor(state) {
277
+ state.abort.abort();
278
+ const queued = state.accepted.close();
279
+ const cleanup = await Promise.allSettled([
280
+ ...state.listeners.map(async (listener) => await listener.close()),
281
+ ...queued.map(async (accepted) => await accepted.close()),
282
+ ]);
283
+ while (state.tasks.size > 0) {
284
+ await Promise.allSettled([...state.tasks]);
285
+ }
286
+ if (cleanup.some((result) => result.status === "rejected") ||
287
+ state.lifecycle.cleanupFailed) {
288
+ throw new SessionError("operation_failed");
272
289
  }
273
290
  }
274
291
  async function releaseLease(state, leaseId) {
@@ -280,7 +297,7 @@ async function authorizeCarrier(state, carrier) {
280
297
  const received = await receiveSessionAdmissionV2(carrier, state.abort.signal);
281
298
  const decoded = received.decoded;
282
299
  const request = runtimeAuthorizationRequestFromDecoded(decoded);
283
- const decision = await abortableCallback(state.options.authorize(request, { signal: state.abort.signal }), state.abort.signal);
300
+ const decision = await state.options.authorize(request, { signal: state.abort.signal });
284
301
  if (decision.decision !== "allow") {
285
302
  return await rejectSessionAdmissionV2(received, {
286
303
  accepted: false,
@@ -294,9 +311,7 @@ async function authorizeCarrier(state, carrier) {
294
311
  try {
295
312
  const registry = state.options.resolveHandlers === undefined
296
313
  ? new SessionHandlers()
297
- : await abortableCallback(Promise.resolve(state.options.resolveHandlers(request, {
298
- signal: state.abort.signal,
299
- })), state.abort.signal);
314
+ : await state.options.resolveHandlers(request, { signal: state.abort.signal });
300
315
  const handlers = freezeSessionHandlers(registry);
301
316
  const leg = {
302
317
  received,
@@ -326,28 +341,50 @@ async function processCarrier(state, carrier) {
326
341
  const leg = await authorizeCarrier(state, carrier);
327
342
  if (leg === undefined)
328
343
  return;
344
+ let releaseOwnedByAcceptedSession = false;
329
345
  try {
330
346
  if (leg.received.decoded.request.pathKind !== "direct" ||
331
347
  leg.artifact.path.kind !== "direct") {
332
348
  await leg.received.carrier.close().catch(() => undefined);
333
349
  throw new SessionError("operation_failed");
334
350
  }
335
- state.accepted.push(await establishDirect(leg, state.abort.signal, leg.leaseId === undefined ? undefined : () => releaseLease(state, leg.leaseId)));
351
+ const accepted = await establishDirect(leg, state.abort.signal, leg.leaseId === undefined ? undefined : () => releaseLease(state, leg.leaseId));
352
+ releaseOwnedByAcceptedSession = true;
353
+ if (state.accepted.push(accepted) === "rejected") {
354
+ try {
355
+ await accepted.close();
356
+ }
357
+ catch {
358
+ state.lifecycle.cleanupFailed = true;
359
+ throw new SessionError("operation_failed");
360
+ }
361
+ }
336
362
  }
337
363
  catch (error) {
338
- await releaseLease(state, leg.leaseId).catch(() => undefined);
364
+ if (!releaseOwnedByAcceptedSession) {
365
+ await releaseLease(state, leg.leaseId).catch(() => undefined);
366
+ }
339
367
  throw error;
340
368
  }
341
369
  }
342
370
  async function runAcceptLoop(state) {
343
371
  while (!state.abort.signal.aborted) {
344
372
  try {
373
+ while (state.admissions.size >= MAX_CONCURRENT_ADMISSIONS) {
374
+ await Promise.race(state.admissions);
375
+ if (state.abort.signal.aborted)
376
+ return;
377
+ }
345
378
  const carrier = await state.accept({ signal: state.abort.signal });
346
379
  const task = processCarrier(state, carrier)
347
380
  .catch(async () => {
348
381
  await carrier.close().catch(() => undefined);
349
382
  })
350
- .finally(() => state.tasks.delete(task));
383
+ .finally(() => {
384
+ state.admissions.delete(task);
385
+ state.tasks.delete(task);
386
+ });
387
+ state.admissions.add(task);
351
388
  state.tasks.add(task);
352
389
  }
353
390
  catch (error) {
@@ -362,16 +399,19 @@ class AcceptedQueue {
362
399
  waiters = new Set();
363
400
  failure;
364
401
  push(value) {
365
- if (this.failure !== undefined) {
366
- void value.close();
367
- return;
368
- }
402
+ if (this.failure !== undefined)
403
+ return "rejected";
369
404
  const waiter = this.waiters.values().next().value;
370
- if (waiter === undefined)
405
+ if (waiter === undefined) {
406
+ if (this.values.length >= MAX_PENDING_ACCEPTED_SESSIONS)
407
+ return "rejected";
371
408
  this.values.push(value);
409
+ return "queued";
410
+ }
372
411
  else {
373
412
  this.waiters.delete(waiter);
374
413
  waiter.resolve(value);
414
+ return "delivered";
375
415
  }
376
416
  }
377
417
  async shift(signal) {
@@ -412,6 +452,7 @@ class AcceptedQueue {
412
452
  }
413
453
  close() {
414
454
  this.fail(new SessionError("closed"));
455
+ return this.values.splice(0);
415
456
  }
416
457
  }
417
458
  const acceptorStates = new WeakMap();
@@ -482,6 +523,7 @@ export async function createAcceptor(options) {
482
523
  const accepted = new AcceptedQueue();
483
524
  const abort = new AbortController();
484
525
  const tasks = new Set();
526
+ const admissions = new Set();
485
527
  let started = false;
486
528
  const state = {
487
529
  listeners,
@@ -490,7 +532,9 @@ export async function createAcceptor(options) {
490
532
  accepted,
491
533
  abort,
492
534
  tasks,
535
+ admissions,
493
536
  cleanupTimeoutMs,
537
+ lifecycle: { cleanupFailed: false },
494
538
  start() {
495
539
  if (started)
496
540
  return;
@@ -502,52 +546,26 @@ export async function createAcceptor(options) {
502
546
  acceptorStates.set(acceptor, state);
503
547
  return Object.freeze(acceptor);
504
548
  }
505
- async function boundedCleanup(tasks, timeoutMs) {
506
- await Promise.race([
507
- Promise.allSettled([...tasks]).then(() => undefined),
508
- new Promise((resolve) => setTimeout(resolve, timeoutMs)),
509
- ]);
510
- }
511
- async function abortableCallback(promise, signal) {
512
- if (signal.aborted)
513
- throw signal.reason instanceof Error ? signal.reason : new SessionError("closed");
514
- return await new Promise((resolve, reject) => {
515
- let settled = false;
516
- const cleanup = () => signal.removeEventListener("abort", abort);
517
- const abort = () => {
518
- if (settled)
519
- return;
520
- settled = true;
521
- cleanup();
522
- reject(signal.reason instanceof Error ? signal.reason : new SessionError("closed"));
523
- };
524
- signal.addEventListener("abort", abort, { once: true });
525
- void promise.then((value) => {
526
- if (settled)
527
- return;
528
- settled = true;
529
- cleanup();
530
- resolve(value);
531
- }, (error) => {
532
- if (settled)
533
- return;
534
- settled = true;
535
- cleanup();
536
- reject(error);
537
- });
538
- });
549
+ async function withCleanupTimeout(completion, timeoutMs) {
550
+ let timer;
551
+ try {
552
+ await Promise.race([
553
+ completion,
554
+ new Promise((_, reject) => {
555
+ timer = setTimeout(() => reject(new SessionError("timeout")), timeoutMs);
556
+ }),
557
+ ]);
558
+ }
559
+ finally {
560
+ if (timer !== undefined)
561
+ clearTimeout(timer);
562
+ }
539
563
  }
540
564
  function validRPCError(error) {
541
- if (!Number.isSafeInteger(error.code) ||
542
- error.code < 1 ||
543
- error.code > 0xffff_ffff) {
544
- return { code: 500, message: "handler failed" };
565
+ try {
566
+ return assertRpcError(error);
545
567
  }
546
- if (error.message !== undefined &&
547
- encoder.encode(error.message).length > 1024) {
568
+ catch {
548
569
  return { code: 500, message: "handler failed" };
549
570
  }
550
- return error.message === undefined
551
- ? { code: error.code }
552
- : { code: error.code, message: error.message };
553
571
  }
@@ -1,4 +1,4 @@
1
- import { randomBytes as nodeRandomBytes } from "node:crypto";
1
+ import { randomBytes as nodeRandomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { buildFSB2RequestV2, canonicalizeCandidatesV2, computeSessionContractHashV2, decodeArtifactV2JSON, decodeFSB2RequestV2, encodeArtifactV2JSON, encodeFSB2RequestV2, } from "../v2/artifact.js";
3
3
  import { wrapArtifact } from "../public/artifact.js";
4
4
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
@@ -413,7 +413,11 @@ function validTCPAddress(value) {
413
413
  return port >= 1 && port <= 65_535;
414
414
  }
415
415
  function validObservedText(value) { return value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/u.test(value); }
416
- function bytesEqual(a, b) { return a.length === b.length && a.every((value, index) => value === b[index]); }
416
+ function bytesEqual(a, b) {
417
+ if (a.length !== b.length)
418
+ return false;
419
+ return timingSafeEqual(a, b);
420
+ }
417
421
  function strictObject(bytes) {
418
422
  let value;
419
423
  try {
@@ -36,13 +36,6 @@ export async function commitArtifactLeaseSpend(lease, signal) {
36
36
  const leaseState = artifactLeaseStates.get(lease);
37
37
  if (leaseState === undefined || leaseState.state !== "idle")
38
38
  throw new ArtifactLeaseError();
39
- leaseState.state = "spending";
40
- try {
41
- await leaseState.commitSpend(signal);
42
- leaseState.state = "consumed";
43
- }
44
- catch (error) {
45
- leaseState.state = "idle";
46
- throw error;
47
- }
39
+ leaseState.state = "consumed";
40
+ await leaseState.commitSpend(signal);
48
41
  }
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
- import { assertRpcEnvelope } from "./validate.js";
2
+ import { assertRpcEnvelope, assertRpcError } from "./validate.js";
3
3
  import { SDK_DEFAULTS } from "../defaults.js";
4
4
  const DEFAULT_RPC_SERVER_OPTIONS = Object.freeze({
5
5
  maxConcurrentRequests: SDK_DEFAULTS.rpc.maxConcurrentRequests,
@@ -217,12 +217,21 @@ export class RpcServer {
217
217
  waiters.shift()?.();
218
218
  }
219
219
  async writeResponse(request, out) {
220
+ let error;
221
+ if (out.error != null) {
222
+ try {
223
+ error = assertRpcError(out.error);
224
+ }
225
+ catch {
226
+ error = { code: 500, message: "internal error" };
227
+ }
228
+ }
220
229
  const resp = {
221
230
  type_id: request.type_id,
222
231
  request_id: 0,
223
232
  response_to: request.request_id,
224
233
  payload: out.payload,
225
- ...(out.error != null ? { error: out.error } : {}),
234
+ ...(error != null ? { error } : {}),
226
235
  };
227
236
  await this.writeEnvelope(resp);
228
237
  }
@@ -1,2 +1,3 @@
1
- import type { RpcEnvelope } from "./wire.js";
1
+ import type { RpcEnvelope, RpcError } from "./wire.js";
2
2
  export declare function assertRpcEnvelope(v: unknown): RpcEnvelope;
3
+ export declare function assertRpcError(value: unknown): RpcError;
@@ -1,4 +1,6 @@
1
1
  import { isSafeU32Number, isSafeU64Number } from "../utils/number.js";
2
+ const encoder = new TextEncoder();
3
+ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
2
4
  // assertRpcEnvelope validates numeric fields that are u32/u64 in the IDL.
3
5
  //
4
6
  // The wire format is JSON, so JS numbers are used. For u64 we enforce the safe integer range
@@ -15,13 +17,29 @@ export function assertRpcEnvelope(v) {
15
17
  throw new Error("bad rpc envelope: response_to");
16
18
  // payload: unknown (JSON)
17
19
  if (o.error != null) {
18
- if (typeof o.error !== "object" || o.error == null)
19
- throw new Error("bad rpc envelope: error");
20
- if (!isSafeU32Number(o.error.code))
21
- throw new Error("bad rpc envelope: error.code");
22
- const msg = o.error.message;
23
- if (msg !== undefined && typeof msg !== "string")
24
- throw new Error("bad rpc envelope: error.message");
20
+ assertRpcError(o.error);
25
21
  }
26
22
  return o;
27
23
  }
24
+ export function assertRpcError(value) {
25
+ if (typeof value !== "object" || value == null)
26
+ throw new Error("bad rpc envelope: error");
27
+ const error = value;
28
+ if (Object.keys(error).some((key) => key !== "code" && key !== "message")) {
29
+ throw new Error("bad rpc envelope: error shape");
30
+ }
31
+ if (!isSafeU32Number(error.code) || error.code === 0) {
32
+ throw new Error("bad rpc envelope: error.code");
33
+ }
34
+ const message = error.message;
35
+ if (message !== undefined && typeof message !== "string") {
36
+ throw new Error("bad rpc envelope: error.message");
37
+ }
38
+ if (typeof message === "string") {
39
+ const encoded = encoder.encode(message);
40
+ if (encoded.byteLength > 1_024 || strictDecoder.decode(encoded) !== message) {
41
+ throw new Error("bad rpc envelope: error.message");
42
+ }
43
+ }
44
+ return message === undefined ? { code: error.code } : { code: error.code, message };
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
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.4.1"
80
+ "@floegence/flowersec-node-native": "2.4.2"
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": "3605cb0c32551ec5bc2f799cd2e086c5c3102177"
98
+ "flowersecSourceCommit": "1393acdb891a6c935a435bee9068f65b68268795"
99
99
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:1b2d0f50-da64-544b-8761-aaf9dd5a7c79",
4
+ "serialNumber": "urn:uuid:a7cb7385-1123-5452-8424-bbcb7904bbbb",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
9
  "name": "@floegence/flowersec-core",
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"
10
+ "version": "2.4.2",
11
+ "purl": "pkg:npm/%40floegence/flowersec-core@2.4.2",
12
+ "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.4.2"
13
13
  },
14
14
  "properties": [
15
15
  {
16
16
  "name": "flowersec:source-inventory-sha256",
17
- "value": "661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941"
17
+ "value": "59d233df0e12f2c371ab542c83edf97a2968a75c7d17c057e453b97f530a90e4"
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.4.1",
193
+ "ref": "pkg:npm/%40floegence/flowersec-core@2.4.2",
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/661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941",
6
+ "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/59d233df0e12f2c371ab542c83edf97a2968a75c7d17c057e453b97f530a90e4",
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-4eeba6fa52c374d21b02",
17
- "versionInfo": "2.4.1",
16
+ "SPDXID": "SPDXRef-Package-9eec732cc29715c69e1e",
17
+ "versionInfo": "2.4.2",
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: 661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941",
23
+ "comment": "Flowersec source inventory SHA-256: 59d233df0e12f2c371ab542c83edf97a2968a75c7d17c057e453b97f530a90e4",
24
24
  "externalRefs": [
25
25
  {
26
26
  "referenceCategory": "PACKAGE-MANAGER",
27
27
  "referenceType": "purl",
28
- "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.4.1"
28
+ "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.4.2"
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-4eeba6fa52c374d21b02"
139
+ "relatedSpdxElement": "SPDXRef-Package-9eec732cc29715c69e1e"
140
140
  },
141
141
  {
142
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
142
+ "spdxElementId": "SPDXRef-Package-9eec732cc29715c69e1e",
143
143
  "relationshipType": "DEPENDS_ON",
144
144
  "relatedSpdxElement": "SPDXRef-Package-5ce913a03239b02770fb"
145
145
  },
146
146
  {
147
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
147
+ "spdxElementId": "SPDXRef-Package-9eec732cc29715c69e1e",
148
148
  "relationshipType": "DEPENDS_ON",
149
149
  "relatedSpdxElement": "SPDXRef-Package-01009adf60db02c13634"
150
150
  },
151
151
  {
152
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
152
+ "spdxElementId": "SPDXRef-Package-9eec732cc29715c69e1e",
153
153
  "relationshipType": "DEPENDS_ON",
154
154
  "relatedSpdxElement": "SPDXRef-Package-8815b117c8a1d5f7eeb0"
155
155
  },
156
156
  {
157
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
157
+ "spdxElementId": "SPDXRef-Package-9eec732cc29715c69e1e",
158
158
  "relationshipType": "DEPENDS_ON",
159
159
  "relatedSpdxElement": "SPDXRef-Package-f8b45a289df643042b94"
160
160
  },
161
161
  {
162
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
162
+ "spdxElementId": "SPDXRef-Package-9eec732cc29715c69e1e",
163
163
  "relationshipType": "DEPENDS_ON",
164
164
  "relatedSpdxElement": "SPDXRef-Package-b779412f685822663496"
165
165
  },