arnacon-webrtc-service 0.1.188 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arnacon-webrtc-service",
3
- "version": "0.1.188",
3
+ "version": "0.2.0",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -17,12 +17,13 @@
17
17
  "files": [
18
18
  "webRTCservice/**/*",
19
19
  "services/**/*",
20
- "config.json",
21
20
  "globalserviceconfig.json",
22
21
  "demoAudio/**/*"
23
22
  ],
24
23
  "scripts": {
25
- "test": "node --test webRTCservice/modules/calls/poly/__tests__/*.test.js",
24
+ "test": "npm run test:poly",
25
+ "test:poly": "node --test webRTCservice/modules/calls/poly/__tests__/*.test.js",
26
+ "test:scenarios": "node --test webRTCservice/modules/calls/poly/__tests__/Scenarios.test.js",
26
27
  "build": "node webRTCservice/build.js",
27
28
  "clean": "node webRTCservice/clean.js",
28
29
  "validate:exports": "node webRTCservice/validateExports.js",
@@ -35,16 +35,6 @@ class PolySession {
35
35
  // billing). Kept as a list so callers compose without subclassing.
36
36
  this._teardownHooks = teardownHooks.filter((h) => typeof h === "function");
37
37
  this._teardownRan = false;
38
- // Call-lifecycle hooks, fired on the active<->inactive edge derived from leg
39
- // states (a call is "active" while either leg isActiveCall). This is the
40
- // single robust seam for per-call side-effects that must bracket the call
41
- // itself -- NOT the poly's disposal -- so they fire once per call on EVERY
42
- // end path (hangup either side, mid-call failure, transport drop, reject)
43
- // and again on a reused poly's next call. Used for minute-counter
44
- // start/finish so billing always stops regardless of how the call ends.
45
- this._onCallStart = null;
46
- this._onCallEnd = null;
47
- this._callActive = false;
48
38
  this.logger = logger;
49
39
 
50
40
  this.mediaHandle = null;
@@ -120,38 +110,11 @@ class PolySession {
120
110
  }
121
111
  this._recoverFailedLegs();
122
112
  }
123
- await this._settleCallActivity();
124
113
  } finally {
125
114
  this._running = false;
126
115
  }
127
116
  }
128
117
 
129
- // Detect the in-call edge from the quiesced leg states and fire the lifecycle
130
- // hooks once per transition. "In call" = BOTH legs are IN_CALL, i.e. the actual
131
- // answered conversation (the media-bridged window) -- ring/setup/teardown are
132
- // deliberately excluded. The falling edge is the reliable "conversation is over"
133
- // signal that fires on every end path (hangup either side, mid-call failure,
134
- // transport drop) while the poly itself survives for reuse.
135
- async _settleCallActivity() {
136
- const active = this.legs.a.state === LEG_STATES.IN_CALL && this.legs.b.state === LEG_STATES.IN_CALL;
137
- if (active === this._callActive) return;
138
- this._callActive = active;
139
- const hook = active ? this._onCallStart : this._onCallEnd;
140
- if (typeof hook !== "function") return;
141
- try {
142
- await hook(this);
143
- } catch (err) {
144
- this.logger.error(`[${this.id}] call-${active ? "start" : "end"} hook failed: ${err.message}`);
145
- }
146
- }
147
-
148
- // Inject per-call lifecycle side-effects (e.g. minuteCounter start/finish).
149
- // Idempotent to set; safe to (re)assign on a reused poly before its next call.
150
- setCallActivityHooks({ onCallStart = null, onCallEnd = null } = {}) {
151
- if (onCallStart !== undefined) this._onCallStart = typeof onCallStart === "function" ? onCallStart : null;
152
- if (onCallEnd !== undefined) this._onCallEnd = typeof onCallEnd === "function" ? onCallEnd : null;
153
- }
154
-
155
118
  async _execute(action) {
156
119
  if (action.kind === "media") {
157
120
  return this._executeMedia(action);
@@ -65,3 +65,58 @@ test("unsupported action is ignored without throwing", async () => {
65
65
  const result = await ingress.deliver(parties, "totally-unknown", {});
66
66
  assert.equal(result, null);
67
67
  });
68
+
69
+ test("cancel action routes through registry to caller leg state", async () => {
70
+ const { ingress, registry } = build();
71
+ const { poly } = registry.resolve(parties);
72
+ await poly.onIngress("a", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
73
+ await poly.onIngress("b", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
74
+ await ingress.deliver(parties, "offer", { sdp: "v=0\r\nm=audio 9 UDP\r\na=sendrecv\r\n" });
75
+ assert.equal(poly.legs.a.state, S.CALLING);
76
+ assert.equal(poly.legs.b.state, S.RINGING);
77
+
78
+ await ingress.deliver(parties, "cancel", {});
79
+ assert.equal(poly.legs.a.state, S.CANCELED);
80
+ });
81
+
82
+ test("reject action delivered to callee transitions to rejected and ends caller", async () => {
83
+ const { ingress, registry } = build();
84
+ const { poly } = registry.resolve(parties);
85
+ await poly.onIngress("a", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
86
+ await poly.onIngress("b", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
87
+ await ingress.deliver(parties, "offer", { sdp: "v=0\r\nm=audio 9 UDP\r\na=sendrecv\r\n" });
88
+
89
+ await ingress.deliver({ ...parties, target: "bob" }, "reject", {});
90
+ assert.equal(poly.legs.b.state, S.REJECTED);
91
+ assert.equal(poly.legs.a.state, S.ENDING);
92
+ });
93
+
94
+ test("end-call answer is routed as END_RENEGOTIATION completion", async () => {
95
+ const { ingress, registry } = build();
96
+ const { poly } = registry.resolve(parties);
97
+ await poly.onIngress("a", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
98
+ await poly.onIngress("b", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
99
+ await ingress.deliver(parties, "offer", { sdp: "v=0\r\nm=audio 9 UDP\r\na=sendrecv\r\n" });
100
+ await ingress.deliver({ ...parties, target: "bob" }, "answer", { sdp: "ans" });
101
+ assert.equal(poly.legs.a.state, S.IN_CALL);
102
+ assert.equal(poly.legs.b.state, S.IN_CALL);
103
+
104
+ await ingress.deliver(parties, "end", {});
105
+ assert.equal(poly.legs.a.state, S.CONNECTED);
106
+ assert.equal(poly.legs.b.state, S.ENDING);
107
+
108
+ await ingress.deliver({ ...parties, target: "bob" }, "end-call", { type: "answer", sdp: "end-ans" });
109
+ assert.equal(poly.legs.b.state, S.CONNECTED);
110
+ });
111
+
112
+ test("ice and ice-batch actions reach aux handler without state churn", async () => {
113
+ const { ingress, registry } = build();
114
+ const { poly } = registry.resolve(parties);
115
+ await poly.onIngress("a", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
116
+
117
+ await ingress.deliver(parties, "ice", { candidates: [{ candidate: "cand-1" }] });
118
+ await ingress.deliver(parties, "ice-batch", { candidates: [{ candidate: "cand-2" }] });
119
+
120
+ assert.equal(poly.legs.a.state, S.CONNECTED);
121
+ assert.equal(poly.legs.a.negotiation.named("aux").length, 2);
122
+ });
@@ -275,3 +275,77 @@ test("second call reuse: after end, a fresh offer rings the peer again", async (
275
275
  assert.equal(b.leg.state, S.RINGING);
276
276
  assert.equal(b.negotiation.named("ring").length, 2);
277
277
  });
278
+
279
+ test("caller cancels while callee is still connecting (deferred connect)", async () => {
280
+ const a = makeWebRtcLeg("alice");
281
+ const b = makeWebRtcLeg("bob", { deferConnect: true });
282
+ const { poly, media } = buildPoly(a, b);
283
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
284
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o" }));
285
+ assert.equal(a.leg.state, S.CALLING);
286
+ assert.equal(b.leg.state, S.CONNECTING);
287
+
288
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.CANCEL));
289
+ assert.equal(a.leg.state, S.CANCELED);
290
+ assert.equal(b.leg.state, S.CONNECTING, "callee keeps its connect lifecycle");
291
+ assert.equal(media.connects.length, 0);
292
+ assert.equal(b.negotiation.named("ring").length, 0);
293
+
294
+ // If the callee transport opens later, it must not be auto-rung from stale state.
295
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
296
+ assert.equal(b.leg.state, S.CONNECTED);
297
+ assert.equal(b.negotiation.named("ring").length, 0);
298
+ });
299
+
300
+ test("caller transport closes during callee connect: no media, no duplicate teardown intents", async () => {
301
+ const a = makeWebRtcLeg("alice");
302
+ const b = makeWebRtcLeg("bob", { deferConnect: true });
303
+ const { poly, media } = buildPoly(a, b);
304
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
305
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o" }));
306
+ assert.equal(a.leg.state, S.CALLING);
307
+ assert.equal(b.leg.state, S.CONNECTING);
308
+
309
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_CLOSE));
310
+ assert.equal(a.leg.state, S.DISCONNECTED, "failed caller collapses to idle after teardown settles");
311
+ assert.equal(b.leg.state, S.CONNECTING);
312
+ assert.equal(media.connects.length, 0);
313
+ assert.equal(media.disconnects.length, 0);
314
+ assert.equal(b.negotiation.named("endCall").length, 0);
315
+ });
316
+
317
+ test("simultaneous offers (glare) converges to a single connected call graph", async () => {
318
+ const a = makeWebRtcLeg("alice");
319
+ const b = makeWebRtcLeg("bob");
320
+ const { poly, media } = buildPoly(a, b);
321
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
322
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
323
+
324
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o-a" }));
325
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o-b" }));
326
+ assert.equal(a.leg.state, S.CALLING);
327
+ assert.equal(b.leg.state, S.CALLING);
328
+
329
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "ans-b" }));
330
+ assert.equal(a.leg.state, S.IN_CALL);
331
+ assert.equal(b.leg.state, S.IN_CALL);
332
+ assert.equal(media.connects.length, 1, "glare must not duplicate media bridge");
333
+ assert.equal(media.disconnects.length, 0);
334
+ });
335
+
336
+ test("repeated settle passes do not duplicate media disconnect", async () => {
337
+ const a = makeWebRtcLeg("alice");
338
+ const b = makeWebRtcLeg("bob");
339
+ const { poly, media } = buildPoly(a, b);
340
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
341
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
342
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o" }));
343
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "ans" }));
344
+ assert.equal(media.connects.length, 1);
345
+
346
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.END));
347
+ assert.equal(media.disconnects.length, 1);
348
+ await poly._settle();
349
+ await poly._settle();
350
+ assert.equal(media.disconnects.length, 1, "teardown reconcile remains idempotent");
351
+ });
@@ -3,6 +3,8 @@ const assert = require("node:assert/strict");
3
3
 
4
4
  const { PolySessionRegistry, pairKey } = require("../PolySessionRegistry");
5
5
  const { LegFactory } = require("../LegFactory");
6
+ const { LEG_EVENTS, makeLegEvent } = require("../ports");
7
+ const { LEG_STATES: S } = require("../states");
6
8
  const { FakeNegotiation, FakeMediaController, silentLogger } = require("./fakes");
7
9
 
8
10
  function buildRegistry() {
@@ -15,6 +17,18 @@ function buildRegistry() {
15
17
  return new PolySessionRegistry({ legFactory, mediaController: new FakeMediaController(), logger: silentLogger });
16
18
  }
17
19
 
20
+ function buildRegistryWithMedia() {
21
+ const negotiationFactory = ({ id }) => new FakeNegotiation({ id });
22
+ const legFactory = new LegFactory({
23
+ webRtcNegotiationFactory: negotiationFactory,
24
+ sipNegotiationFactory: negotiationFactory,
25
+ logger: silentLogger,
26
+ });
27
+ const media = new FakeMediaController();
28
+ const reg = new PolySessionRegistry({ legFactory, mediaController: media, logger: silentLogger });
29
+ return { reg, media };
30
+ }
31
+
18
32
  test("same identity pair resolves to one PolySession regardless of order or label form", () => {
19
33
  const reg = buildRegistry();
20
34
  const r1 = reg.resolve({
@@ -96,3 +110,34 @@ test("getByEndpoint finds the PolySession by either party (label-normalized)", (
96
110
  assert.equal(reg.getByEndpoint("BOB@example.com"), poly);
97
111
  assert.equal(reg.getByEndpoint("nobody"), null);
98
112
  });
113
+
114
+ test("supports two simultaneous calls without cross-wiring state/media", async () => {
115
+ const { reg, media } = buildRegistryWithMedia();
116
+ const first = reg.resolve({
117
+ a: { endpoint: "alice", kind: "webrtc" },
118
+ b: { endpoint: "bob", kind: "webrtc" },
119
+ target: "a",
120
+ });
121
+ const second = reg.resolve({
122
+ a: { endpoint: "carol", kind: "webrtc" },
123
+ b: { endpoint: "dave", kind: "webrtc" },
124
+ target: "a",
125
+ });
126
+ assert.notEqual(first.poly, second.poly);
127
+
128
+ await first.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
129
+ await first.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
130
+ await second.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
131
+ await second.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
132
+
133
+ await first.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "offer-1" }));
134
+ await second.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "offer-2" }));
135
+ await first.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "answer-1" }));
136
+ await second.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "answer-2" }));
137
+
138
+ assert.equal(first.poly.legs.a.state, S.IN_CALL);
139
+ assert.equal(first.poly.legs.b.state, S.IN_CALL);
140
+ assert.equal(second.poly.legs.a.state, S.IN_CALL);
141
+ assert.equal(second.poly.legs.b.state, S.IN_CALL);
142
+ assert.equal(media.connects.length, 2, "media must bridge each call once");
143
+ });
@@ -112,6 +112,27 @@ test("ackConnected fires even when the peer is not yet reachable", () => {
112
112
  assert.deepEqual(intents(actions), [{ kind: "intent", leg: "a", intent: I.ACK_CONNECTED, from: "self" }]);
113
113
  });
114
114
 
115
+ test("connecting peer with no fresh ring event does not re-ack or ring yet", () => {
116
+ const actions = reconcile(snap(S.CALLING, S.CONNECTING, false), { state: S.CONNECTING });
117
+ assert.deepEqual(intents(actions), []);
118
+ assert.deepEqual(mediaOps(actions), []);
119
+ });
120
+
121
+ test("re-ring transition: ringing caller and reusable peer issues ring intent", () => {
122
+ const actions = reconcile(snap(S.RINGING, S.CONNECTED, false), { state: S.RINGING });
123
+ assert.deepEqual(intents(actions), [
124
+ { kind: "intent", leg: "b", intent: I.RING, from: "a" },
125
+ ]);
126
+ });
127
+
128
+ test("reverse re-ring transition: ended side can be rung by calling peer", () => {
129
+ const actions = reconcile(snap(S.ENDED, S.CALLING, false), ringEvent);
130
+ assert.deepEqual(intents(actions), [
131
+ { kind: "intent", leg: "b", intent: I.ACK_CONNECTED, from: "self" },
132
+ { kind: "intent", leg: "a", intent: I.RING, from: "b" },
133
+ ]);
134
+ });
135
+
115
136
  test("progress: ringing vs ended (reusable) rings the reusable side", () => {
116
137
  const actions = reconcile(snap(S.ENDED, S.RINGING, false));
117
138
  assert.deepEqual(intents(actions), [{ kind: "intent", leg: "a", intent: I.RING, from: "b" }]);
@@ -0,0 +1,94 @@
1
+ const { test } = require("node:test");
2
+
3
+ const {
4
+ S,
5
+ buildWebRtcScenario,
6
+ transportOpenA,
7
+ transportOpenB,
8
+ aOffers,
9
+ bAnswers,
10
+ bRejects,
11
+ aEnds,
12
+ bEnds,
13
+ aCompletesEnd,
14
+ bCompletesEnd,
15
+ expectLegStates,
16
+ expectInCall,
17
+ expectRinging,
18
+ expectEnded,
19
+ expectReusable,
20
+ expectMediaConnectedOnce,
21
+ expectMediaDisconnected,
22
+ expectNoMedia,
23
+ } = require("./helpers/scenarioHelpers");
24
+
25
+ test("A calls B -> B ringing, A calling", async () => {
26
+ const ctx = buildWebRtcScenario();
27
+ await transportOpenA(ctx);
28
+ await transportOpenB(ctx);
29
+
30
+ await aOffers(ctx, "offer-1");
31
+
32
+ expectRinging(ctx);
33
+ expectNoMedia(ctx);
34
+ });
35
+
36
+ test("A calls B -> B answers -> both inCall", async () => {
37
+ const ctx = buildWebRtcScenario();
38
+ await transportOpenA(ctx);
39
+ await transportOpenB(ctx);
40
+
41
+ await aOffers(ctx, "offer-1");
42
+ await bAnswers(ctx, "answer-1");
43
+
44
+ expectInCall(ctx);
45
+ expectMediaConnectedOnce(ctx);
46
+ });
47
+
48
+ test("A calls B -> B declines -> A calls again -> B answers -> both inCall", async () => {
49
+ const ctx = buildWebRtcScenario();
50
+ await transportOpenA(ctx);
51
+ await transportOpenB(ctx);
52
+
53
+ await aOffers(ctx, "offer-1");
54
+ await bRejects(ctx);
55
+ expectLegStates(ctx, S.ENDING, S.REJECTED);
56
+ await aCompletesEnd(ctx, "end-answer-1");
57
+ expectReusable(ctx);
58
+ expectNoMedia(ctx);
59
+
60
+ await aOffers(ctx, "offer-2");
61
+ await bAnswers(ctx, "answer-2");
62
+ expectInCall(ctx);
63
+ expectMediaConnectedOnce(ctx);
64
+ });
65
+
66
+ test("A ends -> clean teardown -> both reusable", async () => {
67
+ const ctx = buildWebRtcScenario();
68
+ await transportOpenA(ctx);
69
+ await transportOpenB(ctx);
70
+ await aOffers(ctx, "offer-1");
71
+ await bAnswers(ctx, "answer-1");
72
+ expectInCall(ctx);
73
+
74
+ await aEnds(ctx);
75
+ expectLegStates(ctx, S.CONNECTED, S.ENDING);
76
+ expectMediaDisconnected(ctx, 1);
77
+ await bCompletesEnd(ctx, "end-answer-b");
78
+ expectEnded(ctx);
79
+ });
80
+
81
+ test("B ends -> clean teardown -> both reusable", async () => {
82
+ const ctx = buildWebRtcScenario();
83
+ await transportOpenA(ctx);
84
+ await transportOpenB(ctx);
85
+ await aOffers(ctx, "offer-1");
86
+ await bAnswers(ctx, "answer-1");
87
+ expectInCall(ctx);
88
+
89
+ await bEnds(ctx);
90
+ expectLegStates(ctx, S.ENDING, S.CONNECTED);
91
+ expectMediaDisconnected(ctx, 1);
92
+ await aCompletesEnd(ctx, "end-answer-a");
93
+ expectEnded(ctx);
94
+ });
@@ -24,6 +24,36 @@ function build({ offerSdp, answerSdp } = {}) {
24
24
  return { neg, signaling, session };
25
25
  }
26
26
 
27
+ class MidStrictPeerConnection extends FakePeerConnection {
28
+ constructor(opts = {}) {
29
+ super(opts);
30
+ // Model werift's transceiver map keyed by negotiated mids.
31
+ this.transceivers = [{ kind: "application", mid: "0", setDirection() {}, sender: { replaceTrack: async () => {} } }];
32
+ }
33
+
34
+ addTrack(track) {
35
+ if (track?.kind === "audio" && !this.transceivers.find((t) => t.kind === "audio")) {
36
+ this.transceivers.push({
37
+ kind: "audio",
38
+ mid: "1",
39
+ setDirection() {},
40
+ sender: { replaceTrack: async () => {} },
41
+ });
42
+ }
43
+ return { kind: track?.kind || "audio" };
44
+ }
45
+
46
+ async setRemoteDescription(d) {
47
+ // Reproduces prod failure:
48
+ // "Transceiver with mid=1 not found" on redial offer after end/reuse.
49
+ const hasAudioTransceiver = this.transceivers.some((t) => t.kind === "audio" || t.mid === "1");
50
+ if (d?.type === "offer" && /a=mid:1/.test(d?.sdp || "") && !hasAudioTransceiver) {
51
+ throw new Error("Transceiver with mid=1 not found");
52
+ }
53
+ this.remoteDescription = d;
54
+ }
55
+ }
56
+
27
57
  test("ring sends a signaling offer with the caller's bare label", async () => {
28
58
  const { neg, signaling } = build();
29
59
  await neg.ring({ from: "alice.secnum.global" });
@@ -253,3 +283,37 @@ test("callee leg connect without inviteCallee transport throws", async () => {
253
283
  });
254
284
  await assert.rejects(() => neg.connect({}), /no inviteCallee transport/);
255
285
  });
286
+
287
+ test("BUG REPRO: redial offer after end/reuse must not fail on mid mismatch", async () => {
288
+ const signaling = new FakeSignaling();
289
+ const session = {
290
+ sessionId: "alice|bob",
291
+ callerEns: "alice.secnum.global",
292
+ toIdentity: "bob.secnum.global",
293
+ peerConnection: new MidStrictPeerConnection(),
294
+ localAudioTrack: null,
295
+ };
296
+ const neg = new WebRtcNegotiation({
297
+ id: "alice",
298
+ endpoint: "alice.secnum.global",
299
+ session,
300
+ signaling,
301
+ primitives: fakePrimitives(),
302
+ logger: silentLogger,
303
+ });
304
+
305
+ const redialOfferWithAudioMid =
306
+ "v=0\r\n" +
307
+ "a=group:BUNDLE 0 1\r\n" +
308
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
309
+ "a=mid:0\r\n" +
310
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
311
+ "a=mid:1\r\n" +
312
+ "a=sendrecv\r\n";
313
+
314
+ // RED test first: today this throws exactly like prod logs.
315
+ await assert.doesNotReject(
316
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
317
+ "redial offer should recover from transceiver mismatch instead of wedging",
318
+ );
319
+ });
@@ -3,7 +3,7 @@ const assert = require("node:assert/strict");
3
3
 
4
4
  const { createPolyCore } = require("../createPolyCore");
5
5
  const { LEG_EVENTS, LEG_STATES: S } = require("../index");
6
- const { fakePrimitives, silentLogger } = require("./fakes");
6
+ const { FakePeerConnection, fakePrimitives, silentLogger } = require("./fakes");
7
7
 
8
8
  // A fake MediaGraphFactory + per-leg transport store, to prove createPolyCore
9
9
  // assembles a working core end-to-end (resolve -> ingress -> reconcile -> media).
@@ -57,6 +57,79 @@ test("createPolyCore validates required deps", () => {
57
57
  assert.throws(() => createPolyCore({}), /mediaGraphFactory/);
58
58
  });
59
59
 
60
+ test("createPolyCore callee connect chain: offer -> deferred connect -> transport-open rings callee", async () => {
61
+ const graphs = [];
62
+ const sent = [];
63
+ const outboundInvites = [];
64
+ const mediaGraphFactory = {
65
+ async startGraph({ id }) {
66
+ const g = { id, stopped: false, stop: async () => { g.stopped = true; } };
67
+ graphs.push(g);
68
+ return g;
69
+ },
70
+ };
71
+ const outboundInvite = async ({ callerSessionId, destination }) => {
72
+ outboundInvites.push({ callerSessionId, destination });
73
+ return {
74
+ sessionId: "bob-leg",
75
+ signalingSessionId: "alice|bob",
76
+ callerEns: "alice",
77
+ toIdentity: "bob",
78
+ peerConnection: new FakePeerConnection(),
79
+ dataChannel: { readyState: "open", send() {} },
80
+ iceCandidates: [],
81
+ };
82
+ };
83
+ const core = createPolyCore({
84
+ mediaGraphFactory,
85
+ webrtcPrimitives: fakePrimitives(),
86
+ makeSignalingTransport: ({ session, id }) => ({
87
+ send: (m) => sent.push({ session: session?.sessionId || id, m }),
88
+ isOpen: () => true,
89
+ }),
90
+ sipPort: { openOutbound: async () => {}, close: async () => {} },
91
+ outboundInvite,
92
+ logger: silentLogger,
93
+ });
94
+ const parties = {
95
+ a: {
96
+ endpoint: "alice",
97
+ kind: "webrtc",
98
+ session: {
99
+ sessionId: "alice",
100
+ callerEns: "alice",
101
+ toIdentity: "bob",
102
+ peerConnection: new FakePeerConnection(),
103
+ dataChannel: { readyState: "open", send() {} },
104
+ iceCandidates: [],
105
+ },
106
+ },
107
+ b: {
108
+ endpoint: "bob",
109
+ kind: "webrtc",
110
+ role: "callee",
111
+ destination: { wallet: "0x1234", ensName: "bob.global" },
112
+ callerSessionId: "alice",
113
+ session: { sessionId: "bob-leg" },
114
+ },
115
+ target: "alice",
116
+ };
117
+ const { poly } = core.registry.resolve(parties);
118
+
119
+ await poly.onIngress("a", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
120
+ await poly.onIngress("a", { type: LEG_EVENTS.OFFER, payload: { sdp: "offer" } });
121
+ assert.equal(poly.legs.a.state, S.CALLING);
122
+ assert.equal(poly.legs.b.state, S.CONNECTING);
123
+ assert.equal(outboundInvites.length, 1, "callee connect triggers outbound invite once");
124
+
125
+ await poly.onIngress("b", { type: LEG_EVENTS.TRANSPORT_OPEN, payload: {} });
126
+ assert.equal(poly.legs.b.state, S.RINGING, "transport-open advances connecting callee to ringing");
127
+ assert.ok(
128
+ sent.some((x) => x.session === "bob-leg" && x.m?.msgType === "signaling" && x.m?.payload?.type === "offer"),
129
+ "callee ring offer is emitted on the callee signaling transport",
130
+ );
131
+ });
132
+
60
133
  test("poly module index loads every public export", () => {
61
134
  const poly = require("../index");
62
135
  for (const name of [
@@ -0,0 +1,138 @@
1
+ const assert = require("node:assert/strict");
2
+
3
+ const { PolySession } = require("../../PolySession");
4
+ const { LEG_EVENTS, makeLegEvent } = require("../../ports");
5
+ const { LEG_STATES: S, canBeRung } = require("../../states");
6
+ const { FakeMediaController, makeWebRtcLeg, silentLogger } = require("../fakes");
7
+
8
+ function buildWebRtcScenario({ deferConnectA = false, deferConnectB = false } = {}) {
9
+ const a = makeWebRtcLeg("alice", { deferConnect: deferConnectA });
10
+ const b = makeWebRtcLeg("bob", { deferConnect: deferConnectB });
11
+ const media = new FakeMediaController();
12
+ const poly = new PolySession({
13
+ id: "alice<->bob",
14
+ legA: a.leg,
15
+ legB: b.leg,
16
+ mediaController: media,
17
+ logger: silentLogger,
18
+ });
19
+ return { poly, media, a, b };
20
+ }
21
+
22
+ async function transportOpenA(ctx) {
23
+ await ctx.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
24
+ }
25
+
26
+ async function transportOpenB(ctx) {
27
+ await ctx.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
28
+ }
29
+
30
+ async function aOffers(ctx, sdp = "offer-a") {
31
+ await ctx.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp }));
32
+ }
33
+
34
+ async function bOffers(ctx, sdp = "offer-b") {
35
+ await ctx.poly.onIngress("b", makeLegEvent(LEG_EVENTS.OFFER, { sdp }));
36
+ }
37
+
38
+ async function aAnswers(ctx, sdp = "answer-a") {
39
+ await ctx.poly.onIngress("a", makeLegEvent(LEG_EVENTS.ANSWER, { sdp }));
40
+ }
41
+
42
+ async function bAnswers(ctx, sdp = "answer-b") {
43
+ await ctx.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp }));
44
+ }
45
+
46
+ async function aRejects(ctx) {
47
+ await ctx.poly.onIngress("a", makeLegEvent(LEG_EVENTS.REJECT));
48
+ }
49
+
50
+ async function bRejects(ctx) {
51
+ await ctx.poly.onIngress("b", makeLegEvent(LEG_EVENTS.REJECT));
52
+ }
53
+
54
+ async function aEnds(ctx) {
55
+ await ctx.poly.onIngress("a", makeLegEvent(LEG_EVENTS.END));
56
+ }
57
+
58
+ async function bEnds(ctx) {
59
+ await ctx.poly.onIngress("b", makeLegEvent(LEG_EVENTS.END));
60
+ }
61
+
62
+ async function aCompletesEnd(ctx, sdp = "end-answer-a") {
63
+ await ctx.poly.onIngress("a", makeLegEvent(LEG_EVENTS.END_RENEGOTIATION, { type: "answer", sdp }));
64
+ }
65
+
66
+ async function bCompletesEnd(ctx, sdp = "end-answer-b") {
67
+ await ctx.poly.onIngress("b", makeLegEvent(LEG_EVENTS.END_RENEGOTIATION, { type: "answer", sdp }));
68
+ }
69
+
70
+ function expectLegStates(ctx, expectedA, expectedB) {
71
+ assert.equal(ctx.a.leg.state, expectedA);
72
+ assert.equal(ctx.b.leg.state, expectedB);
73
+ }
74
+
75
+ function expectInCall(ctx) {
76
+ expectLegStates(ctx, S.IN_CALL, S.IN_CALL);
77
+ }
78
+
79
+ function expectRinging(ctx) {
80
+ expectLegStates(ctx, S.CALLING, S.RINGING);
81
+ }
82
+
83
+ function expectEnded(ctx) {
84
+ expectLegStates(ctx, S.CONNECTED, S.CONNECTED);
85
+ }
86
+
87
+ function expectReusable(ctx) {
88
+ assert.equal(canBeRung(ctx.a.leg.state), true, `leg a is not reusable: ${ctx.a.leg.state}`);
89
+ assert.equal(canBeRung(ctx.b.leg.state), true, `leg b is not reusable: ${ctx.b.leg.state}`);
90
+ }
91
+
92
+ function expectMediaConnectedOnce(ctx) {
93
+ assert.equal(ctx.media.connects.length, 1);
94
+ }
95
+
96
+ function expectMediaDisconnected(ctx, count = 1) {
97
+ assert.equal(ctx.media.disconnects.length, count);
98
+ }
99
+
100
+ function expectNoMedia(ctx) {
101
+ assert.equal(ctx.media.connects.length, 0);
102
+ assert.equal(ctx.media.disconnects.length, 0);
103
+ }
104
+
105
+ function expectRingAck(ctx, count = 1) {
106
+ assert.equal(ctx.a.negotiation.named("ackRing").length, count);
107
+ }
108
+
109
+ function expectHeldAnswerFlush(ctx, count = 1) {
110
+ assert.equal(ctx.a.negotiation.named("answer").length, count);
111
+ }
112
+
113
+ module.exports = {
114
+ S,
115
+ buildWebRtcScenario,
116
+ transportOpenA,
117
+ transportOpenB,
118
+ aOffers,
119
+ bOffers,
120
+ aAnswers,
121
+ bAnswers,
122
+ aRejects,
123
+ bRejects,
124
+ aEnds,
125
+ bEnds,
126
+ aCompletesEnd,
127
+ bCompletesEnd,
128
+ expectLegStates,
129
+ expectInCall,
130
+ expectRinging,
131
+ expectEnded,
132
+ expectReusable,
133
+ expectMediaConnectedOnce,
134
+ expectMediaDisconnected,
135
+ expectNoMedia,
136
+ expectRingAck,
137
+ expectHeldAnswerFlush,
138
+ };
@@ -177,7 +177,18 @@ class WebRtcNegotiation extends CallNegotiationPort {
177
177
  if (audioDirection(offerSdp) === "inactive" && this.p.patchInactiveToSendrecv) {
178
178
  offerSdp = this.p.patchInactiveToSendrecv(offerSdp);
179
179
  }
180
- await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
180
+ if (!isIceRestart) this._ensureAudioReadyForOffer(offerSdp, pc);
181
+ try {
182
+ await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
183
+ } catch (err) {
184
+ if (!isIceRestart && this._isMissingMidError(err) && this._offerHasAudioMLine(offerSdp)) {
185
+ // Recovery for reused sessions that retained DC but lost local m=audio mapping.
186
+ this._ensureAudioReadyForOffer(offerSdp, pc, { force: true });
187
+ await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
188
+ } else {
189
+ throw err;
190
+ }
191
+ }
181
192
  await this.p.addIceCandidates?.(pc, payload.candidates || []);
182
193
  // On ICE restart the track set is unchanged; only the caller-ring path
183
194
  // needs to (re)attach the local audio track.
@@ -210,6 +221,27 @@ class WebRtcNegotiation extends CallNegotiationPort {
210
221
  this._pendingAnswerSdp = answerSdp;
211
222
  }
212
223
 
224
+ _offerHasAudioMLine(sdp = "") {
225
+ return /\bm=audio\b/.test(sdp);
226
+ }
227
+
228
+ _isMissingMidError(err) {
229
+ const msg = String(err?.message || "");
230
+ return /Transceiver with mid=\d+ not found/i.test(msg);
231
+ }
232
+
233
+ _ensureAudioReadyForOffer(sdp, pc, opts = {}) {
234
+ if (!this._offerHasAudioMLine(sdp)) return;
235
+ const force = opts.force === true;
236
+ const audioTransceiver = pc.getTransceivers?.().find((t) => t.kind === "audio");
237
+ if (audioTransceiver && !force) return;
238
+
239
+ if (!this.session.localAudioTrack) {
240
+ this.session.localAudioTrack = new this.MediaStreamTrack({ kind: "audio" });
241
+ }
242
+ pc.addTrack?.(this.session.localAudioTrack);
243
+ }
244
+
213
245
  // The caller's client offered a ring and we are connected: ack it so the
214
246
  // client stops re-offering. The ios client needs an ack-for-ring here even
215
247
  // though, server-side, the peer is only being reached now. No persistent
@@ -61,14 +61,13 @@ function createPublicServer({
61
61
  }
62
62
 
63
63
  function createInternalServer({
64
- tlsOptions,
65
64
  internalHttpPort,
66
65
  internalBindIp,
67
66
  handlers,
68
67
  sendJsonError,
69
68
  logger = console,
70
69
  }) {
71
- const internalHandler = async (req, res) => {
70
+ const internalServer = http.createServer(async (req, res) => {
72
71
  logger.log(`[Internal] Incoming request: ${req.method} ${req.url} from ${req.socket.remoteAddress}`);
73
72
  if (req.method !== "POST") {
74
73
  sendJsonError(res, 404, "Not found");
@@ -79,11 +78,10 @@ function createInternalServer({
79
78
  return;
80
79
  }
81
80
  await handlers.handleInboundCall(req, res);
82
- };
83
- const internalServer = https.createServer(tlsOptions, internalHandler);
81
+ });
84
82
  function start() {
85
83
  internalServer.listen(internalHttpPort, internalBindIp, () => {
86
- logger.log(`WebRTCManager internal HTTPS listening on ${internalBindIp}:${internalHttpPort}`);
84
+ logger.log(`WebRTCManager internal HTTP listening on ${internalBindIp}:${internalHttpPort}`);
87
85
  });
88
86
  }
89
87
  function stop() {
@@ -111,7 +109,6 @@ function createHttpServers({
111
109
  verifyExternalRequest,
112
110
  });
113
111
  const internalServer = createInternalServer({
114
- tlsOptions,
115
112
  internalHttpPort,
116
113
  internalBindIp,
117
114
  handlers,
@@ -159,34 +159,10 @@ const { WebSocket: WsWebSocket } = require("ws");
159
159
 
160
160
  // ─── Load config from config.json + services/*.json ──────────
161
161
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
162
- function resolveFirstExistingPath(candidates) {
163
- for (const p of candidates) {
164
- if (p && fs.existsSync(p)) return p;
165
- }
166
- return "";
167
- }
168
-
169
- function normalizeInputPath(inputPath) {
170
- if (!inputPath) return "";
171
- return path.isAbsolute(inputPath) ? inputPath : path.resolve(process.cwd(), inputPath);
172
- }
173
-
174
- const CONFIG_PATH = resolveFirstExistingPath([
175
- path.resolve(process.cwd(), "config", "config.json"),
176
- path.resolve(process.cwd(), "config.json"),
177
- path.join(PACKAGE_ROOT, "config", "config.json"),
178
- path.join(PACKAGE_ROOT, "config.json"),
179
- ]);
180
- if (!CONFIG_PATH) {
181
- throw new Error(
182
- `WebRTC config not found. Checked: ${[
183
- path.resolve(process.cwd(), "config", "config.json"),
184
- path.resolve(process.cwd(), "config.json"),
185
- path.join(PACKAGE_ROOT, "config", "config.json"),
186
- path.join(PACKAGE_ROOT, "config.json"),
187
- ].filter(Boolean).join(", ")}`
188
- );
189
- }
162
+ const CONFIG_OVERRIDE = process.env.WEBRTC_CONFIG_PATH || process.env.ARNACON_WEBRTC_CONFIG_PATH || "";
163
+ const CONFIG_PATH = CONFIG_OVERRIDE
164
+ ? (path.isAbsolute(CONFIG_OVERRIDE) ? CONFIG_OVERRIDE : path.resolve(process.cwd(), CONFIG_OVERRIDE))
165
+ : path.join(PACKAGE_ROOT, "config.json");
190
166
  const CONFIG_BASE_DIR = path.dirname(CONFIG_PATH);
191
167
  const fullConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
192
168
  const IVR_DEMO_AUDIO_DIR_RAW = process.env.IVR_DEMO_AUDIO_DIR || "demoAudio";
@@ -197,19 +173,9 @@ console.log(`[IVR-AUDIO] Demo audio directory: ${IVR_DEMO_AUDIO_DIR}`);
197
173
  const _deployEnvEarly = process.env.DEPLOY_ENV || "development";
198
174
  const _commonEarly = (fullConfig[_deployEnvEarly] || {}).common || {};
199
175
  const GLOBAL_CONFIG_OVERRIDE = process.env.WEBRTC_GLOBAL_CONFIG_PATH || process.env.ARNACON_WEBRTC_GLOBAL_CONFIG_PATH || "";
200
- const globalOverridePath = normalizeInputPath(GLOBAL_CONFIG_OVERRIDE);
201
- const GLOBAL_CONFIG_PATH = resolveFirstExistingPath([
202
- globalOverridePath,
203
- _commonEarly.globalServiceConfigPath
204
- ? (_commonEarly.globalServiceConfigPath.startsWith("/")
205
- ? _commonEarly.globalServiceConfigPath
206
- : path.resolve(CONFIG_BASE_DIR, _commonEarly.globalServiceConfigPath))
207
- : "",
208
- path.resolve(process.cwd(), "config", "globalserviceconfig.json"),
209
- path.resolve(process.cwd(), "globalserviceconfig.json"),
210
- path.join(PACKAGE_ROOT, "config", "globalserviceconfig.json"),
211
- path.join(PACKAGE_ROOT, "globalserviceconfig.json"),
212
- ]);
176
+ const GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_OVERRIDE
177
+ ? (path.isAbsolute(GLOBAL_CONFIG_OVERRIDE) ? GLOBAL_CONFIG_OVERRIDE : path.resolve(process.cwd(), GLOBAL_CONFIG_OVERRIDE))
178
+ : (_commonEarly.globalServiceConfigPath || path.join(PACKAGE_ROOT, "globalserviceconfig.json"));
213
179
  let fullGlobalConfig = {};
214
180
  if (fs.existsSync(GLOBAL_CONFIG_PATH)) {
215
181
  fullGlobalConfig = JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, "utf8"));
@@ -272,11 +238,10 @@ function pickRuntimeConfig(key, fallback = undefined) {
272
238
  }
273
239
 
274
240
  const config = {
275
- // Source-of-truth is this package config.json (or an on-host file-location override).
241
+ // Source-of-truth stays in Kamailio config.json (no duplication in globalserviceconfig.json).
276
242
  domain: commonConfig.domain,
277
243
  kamailioWssHost: commonConfig.kamailioWssHost,
278
244
  kamailioWssPort: commonConfig.kamailioWssPort,
279
- kamailioWssScheme: commonConfig.kamailioWssScheme,
280
245
  bindIp: commonConfig.bindIp,
281
246
  tlsCertPath: commonConfig.tlsCertPath,
282
247
  roflBaseUrl: pickRuntimeConfig("roflBaseUrl"),
@@ -311,15 +276,9 @@ function getServiceRuntime(serviceId = null) {
311
276
  }
312
277
 
313
278
  // Kamailio SIP config
314
- // Transport scheme is configurable: env > config.json > default "wss".
315
- // Use "ws" only for trusted/co-located hops; "wss" for cross-host.
316
- const KAMAILIO_WSS_SCHEME = process.env.KAMAILIO_WSS_SCHEME || config.kamailioWssScheme || "wss";
317
- const KAMAILIO_WSS_HOST = process.env.KAMAILIO_WSS_HOST || config.kamailioWssHost || config.domain;
318
- const KAMAILIO_WSS_PORT = Number(process.env.KAMAILIO_WSS_PORT || config.kamailioWssPort || 8443);
319
- const KAMAILIO_WSS_URL = `${KAMAILIO_WSS_SCHEME}://${KAMAILIO_WSS_HOST}:${KAMAILIO_WSS_PORT}`;
279
+ const KAMAILIO_WSS_URL = `ws://${config.kamailioWssHost || config.domain}:${config.kamailioWssPort}`;
320
280
  const KAMAILIO_DOMAIN = config.domain;
321
281
  const KAMAILIO_REGISTER_EXPIRES = 300;
322
- console.log(`[Startup] SIP WSS target: ${KAMAILIO_WSS_URL}`);
323
282
 
324
283
  const INTERNAL_BIND_IP = config.bindIp || "127.0.0.1";
325
284
  const OPENAI_SIP_CONFIG = {
@@ -922,7 +881,7 @@ for (const serviceRuntime of activeServiceRuntimes) {
922
881
  console.log(
923
882
  `[Startup] service=${serviceRuntime.id} provider=${serviceRuntime.providerId} deployEnv=${deployEnv} ` +
924
883
  `domain=${config.domain || ""} notifyPort=${serviceRuntime.notifyPort} ` +
925
- `callbackPort=https:${serviceRuntime.callbackPort} domains=${configuredDomains}`,
884
+ `callbackPort=${serviceRuntime.callbackPort} domains=${configuredDomains}`,
926
885
  );
927
886
  const handlers = createHandlers({
928
887
  buildSignalingContextFromNotify,
@@ -1071,90 +1030,6 @@ function polyRefByEndpoint(poly, endpoint) {
1071
1030
  }
1072
1031
 
1073
1032
  // Resolve routing at the audio RING and create the PolySession for the pair.
1074
- // Prepaid minute gate for an SBC/SIP outbound. Resolves the per-service budget
1075
- // and asserts the caller may still start a call. Returns { allowed, settings,
1076
- // identity }: allowed=false means the monthly cap is exhausted (the caller has
1077
- // already been told to end). No active policy => allowed with settings=null.
1078
- function checkSbcMinuteBudget({ session, callerSessionId, parsedFrom, serviceId }) {
1079
- const settings = minuteCounterPolicy.getSettings(serviceId);
1080
- const identity = minuteCounterPolicy.getIdentity(parsedFrom, session);
1081
- if (!minuteCounterApi || !settings?.limitSeconds) return { allowed: true, settings: null, identity };
1082
- try {
1083
- minuteCounterApi.assertCanStart({
1084
- serviceId: settings.serviceId,
1085
- identity,
1086
- limitSeconds: settings.limitSeconds,
1087
- });
1088
- } catch (err) {
1089
- console.log(`[${callerSessionId}] minute limit reached for ${identity}: ${err.message}`);
1090
- sendDataChannelMessage(callerSessionId, { msgType: "call", action: "end", reason: "minute-limit" });
1091
- return { allowed: false, settings, identity };
1092
- }
1093
- return { allowed: true, settings, identity };
1094
- }
1095
-
1096
- // Service-side prepaid per-call cutoff. The minute counter owns the budget, so
1097
- // we enforce the low-balance cap here instead of stamping a "Limit" SIP header
1098
- // for the SIP proxy to enforce (SIPhon no longer carries that header). At answer
1099
- // we arm a timer that BYEs both legs when the caller's remaining balance for THIS
1100
- // call runs out, and we bill via the start/finish hooks. No-op without policy.
1101
- function applySbcMinuteCap({ session, callerSessionId, poly, settings, identity }) {
1102
- if (!minuteCounterApi || !settings?.limitSeconds) return;
1103
-
1104
- const clearCutoff = () => {
1105
- if (session._minuteCutoffTimer) {
1106
- clearTimeout(session._minuteCutoffTimer);
1107
- session._minuteCutoffTimer = null;
1108
- }
1109
- };
1110
-
1111
- // Bill only the answered conversation: PolySession fires onCallStart when both
1112
- // legs reach IN_CALL and onCallEnd when they leave it -- so the counter starts
1113
- // at answer and stops on ANY end (hangup either side, failure, transport drop),
1114
- // independent of poly disposal/reuse. finish() is idempotent.
1115
- poly.setCallActivityHooks({
1116
- onCallStart: () => {
1117
- minuteCounterApi.start(session, {
1118
- serviceId: settings.serviceId,
1119
- identity,
1120
- limitSeconds: settings.limitSeconds,
1121
- });
1122
- // Remaining balance is measured at answer. Only a low-balance caller
1123
- // is capped per-call (mirrors the previous <300s Limit-header gate);
1124
- // higher balances are bounded across calls by assertCanStart().
1125
- const usedSeconds = minuteCounterApi.getUsedSeconds({
1126
- serviceId: settings.serviceId,
1127
- identity,
1128
- });
1129
- const remainingSeconds = settings.limitSeconds - usedSeconds;
1130
- clearCutoff();
1131
- if (remainingSeconds > 0 && remainingSeconds < 300) {
1132
- console.log(`[${callerSessionId}] low balance: capping call at ${remainingSeconds}s for ${identity}`);
1133
- session._minuteCutoffTimer = setTimeout(() => {
1134
- session._minuteCutoffTimer = null;
1135
- // Prepaid is the SIP/SBC leg's concern: the service decides this
1136
- // leg is out of balance and ends the SIP (S) leg of the poly.
1137
- // The S leg sets itself to ENDING (BYEs the SBC) and the poly
1138
- // propagates the teardown to the caller leg.
1139
- const sipRef = polySipRef(poly);
1140
- if (!sipRef) {
1141
- console.warn(`[${callerSessionId}] minute cap reached but poly has no SIP leg -- skipping`);
1142
- return;
1143
- }
1144
- console.log(`[${callerSessionId}] minute cap reached (${remainingSeconds}s) -- ending SIP leg for ${identity}`);
1145
- poly.onIngress(sipRef, makeLegEvent(LEG_EVENTS.END)).catch((err) => {
1146
- console.error(`[${callerSessionId}] minute-cap hangup failed: ${err.message}`);
1147
- });
1148
- }, remainingSeconds * 1000);
1149
- }
1150
- },
1151
- onCallEnd: () => {
1152
- clearCutoff();
1153
- minuteCounterApi.finish(session);
1154
- },
1155
- });
1156
- }
1157
-
1158
1033
  async function onDcRing(callerSessionId, payload) {
1159
1034
  const session = sessions.get(callerSessionId);
1160
1035
  if (!session || !session.peerConnection) return;
@@ -1174,9 +1049,6 @@ async function onDcRing(callerSessionId, payload) {
1174
1049
 
1175
1050
  const a = { endpoint: session.callerEns, kind: "webrtc", session };
1176
1051
  let b;
1177
- // Minute metering is SBC/PSTN-outbound only; resolved in the sip branch below.
1178
- let minuteCounterSettings = null;
1179
- let minuteCounterIdentity = null;
1180
1052
  if (destination.route === "webrtc") {
1181
1053
  b = {
1182
1054
  endpoint: destination.ensName || destination.wallet,
@@ -1214,21 +1086,9 @@ async function onDcRing(callerSessionId, payload) {
1214
1086
  kind: "sip",
1215
1087
  session,
1216
1088
  };
1217
-
1218
- // Minute metering for SBC/PSTN outbound. The poly cutover orphaned the
1219
- // legacy SbcRouteStrategy where start()/assertCanStart() lived, so gate on
1220
- // the per-service monthly cap here at SIP-leg origination. The cap header +
1221
- // billing hooks are applied below once the poly exists.
1222
- const budget = checkSbcMinuteBudget({ session, callerSessionId, parsedFrom, serviceId });
1223
- if (!budget.allowed) return;
1224
- minuteCounterSettings = budget.settings;
1225
- minuteCounterIdentity = budget.identity;
1226
1089
  }
1227
1090
 
1228
1091
  const { poly } = polyRegistry.resolve({ a, b, target: "a" });
1229
- if (b.kind === "sip") {
1230
- applySbcMinuteCap({ session, callerSessionId, poly, settings: minuteCounterSettings, identity: minuteCounterIdentity });
1231
- }
1232
1092
  // Caller transport is already up (HTTP handshake). The SIP leg has no transport
1233
1093
  // to negotiate (openOutbound happens on ring), so mark it usable too. A webrtc
1234
1094
  // callee, by contrast, starts disconnected: reconcile will connect() it (FCM
@@ -1324,19 +1184,6 @@ function onDataChannelMessage(sessionId, rawMessage, meta = {}) {
1324
1184
  });
1325
1185
  return;
1326
1186
  }
1327
- // Reused poly: the caller is redialing over its live transport, so onDcRing
1328
- // (and its minute gate) is bypassed. Re-run the prepaid gate here for SBC/SIP
1329
- // redials so an out-of-balance caller is blocked and the per-call cutoff
1330
- // timer is re-armed every call -- not just the first. Otherwise a stale cap
1331
- // (or none) from a prior call would carry over on the reused poly.
1332
- const sipRef = polySipRef(existing);
1333
- if (sipRef) {
1334
- const serviceId = session.serviceId || null;
1335
- const parsedFrom = parseAddress(session.callerEns, serviceId);
1336
- const budget = checkSbcMinuteBudget({ session, callerSessionId: sessionId, parsedFrom, serviceId });
1337
- if (!budget.allowed) return;
1338
- applySbcMinuteCap({ session, callerSessionId: sessionId, poly: existing, settings: budget.settings, identity: budget.identity });
1339
- }
1340
1187
  }
1341
1188
 
1342
1189
  const poly = polyForSession(session);
package/config.json DELETED
@@ -1,24 +0,0 @@
1
- {
2
- "development": {
3
- "common": {
4
- "domain": "test2.cellact.nl",
5
- "kamailioWssHost": "test2.cellact.nl",
6
- "kamailioWssPort": 8443,
7
- "kamailioWssScheme": "wss",
8
- "bindIp": "0.0.0.0",
9
- "tlsCertPath": "/etc/letsencrypt/live/test2.cellact.nl",
10
- "globalServiceConfigPath": "./globalserviceconfig.json"
11
- }
12
- },
13
- "production": {
14
- "common": {
15
- "domain": "ron1.cellact.nl",
16
- "kamailioWssHost": "ron1.cellact.nl",
17
- "kamailioWssPort": 8443,
18
- "kamailioWssScheme": "wss",
19
- "bindIp": "0.0.0.0",
20
- "tlsCertPath": "/etc/letsencrypt/live/ron1.cellact.nl",
21
- "globalServiceConfigPath": "./globalserviceconfig.json"
22
- }
23
- }
24
- }