From ebc5db5a0018e373b7997e83be92b00a9835d50e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 03:55:40 +0200 Subject: [PATCH 01/11] Test that the session owns its bind state and keeps it through a reconnect's gap --- interop-tests/smppload.test.ts | 2 +- test/session-extras.test.ts | 10 ++++++++-- test/session.test.ts | 24 +++++++++++++++++++++++- test/tls.test.ts | 2 +- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/interop-tests/smppload.test.ts b/interop-tests/smppload.test.ts index a500eeb..cd1cd9d 100644 --- a/interop-tests/smppload.test.ts +++ b/interop-tests/smppload.test.ts @@ -62,6 +62,6 @@ describe('smppload (blocked)', () => { await waitFor(() => (closed ? true : undefined), 5000); assert.equal(closed, true); assert.ok(bound); - assert.equal(bound.loggedIn, false); + assert.equal(bound.boundAs, undefined); }); }); diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 09c315c..209dfa3 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -628,6 +628,10 @@ describe('reconnect', () => { const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); const halfPdu = once(resolve => { session.on('data', () => { resolve(true); }); }); + const boundBefore = [session.boundAs, session.peerInterfaceVersion]; + const whileDown = once(resolve => { + session.on('disconnected', () => { resolve([session.boundAs, session.peerInterfaceVersion]); }); + }); // A PDU header promising 32 octets and sending 8: the next link must not continue it. peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4])); @@ -640,7 +644,9 @@ describe('reconnect', () => { await reconnected; - assert.ok(session.loggedIn); + // The loop rebinds as before, so the gap keeps answering bindAllows() for the bind to come. + assert.deepEqual(await whileDown, boundBefore); + assert.equal(session.boundAs, 'transceiver'); // The session object survives the drop, so listeners stay attached and it is usable again. const sent = await session.sendSms({ @@ -867,7 +873,7 @@ describe('reconnect from the first bind', () => { assert.equal(err, undefined); assert.ok(session); - assert.equal(session.loggedIn, true); + assert.equal(session.boundAs, 'transceiver'); assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts'); assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay'); assert.ok( diff --git a/test/session.test.ts b/test/session.test.ts index ce6af2c..d50de32 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -229,7 +229,7 @@ describe('bind', () => { assert.equal(err, undefined); assert.ok(session); - assert.ok(session.loggedIn); + assert.equal(session.boundAs, 'transceiver'); assert.deepEqual(await session.unbind(), {}); }); @@ -448,6 +448,28 @@ describe('bind', () => { assert.ok(session.acceptsOptionalParams()); }); + test('records a hand-wired bind through bound(), and nothing else writes it', t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + assert.equal(session.boundAs, undefined); + assert.equal(session.peerInterfaceVersion, undefined); + + session.bound('receiver', 0x34); + + assert.equal(session.boundAs, 'receiver'); + assert.equal(session.peerInterfaceVersion, 0x34); + assert.equal(session.bindAllows('submit_sm'), false); + + session.bound('transmitter', undefined); + + assert.equal(session.peerInterfaceVersion, 0x00, 'no declared version is pre-3.4'); + assert.equal(Reflect.set(session, 'boundAs', 'transceiver'), false); + assert.equal(Reflect.set(session, 'peerInterfaceVersion', 0x50), false); + assert.equal(session.boundAs, 'transmitter'); + assert.equal('loggedIn' in session, false); + }); + // The spec: an absent sc_interface_version means the SMSC supports no optional parameters. test('takes an SMSC that declares no version as older than 3.4', async t => { const peer = await smscPeer(t); diff --git a/test/tls.test.ts b/test/tls.test.ts index 3fadeb3..1e311f4 100644 --- a/test/tls.test.ts +++ b/test/tls.test.ts @@ -135,7 +135,7 @@ describe('tls', () => { assert.equal(err, undefined); assert.ok(session); - assert.ok(session.loggedIn); + assert.equal(session.boundAs, 'transceiver'); closeAfter(t, session); const sock = session.sock; -- 2.52.0 From b70f0d6ade70ea58bd16defdd81ceebb1776a74c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 03:55:40 +0200 Subject: [PATCH 02/11] Let the session own its bind state, written by bound() alone --- AGENTS.md | 1 + CHANGELOG.md | 4 ++++ README.md | 9 +++++++-- docs/decisions.md | 9 +++++++++ src/client.ts | 10 ++-------- src/server.ts | 34 +++++++++++++++++----------------- src/session.ts | 32 ++++++++++++++++++++++---------- todo.md | 5 ----- 8 files changed, 62 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff4389b..593195a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,6 +309,7 @@ the file. - A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, a `data_sm` by whichever it stands in for. - A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. +- The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's gap. - A message id base is merged at most once. - A send that never reached the socket waits for the next link; one that did is counted, not resent. - A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else. diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e45dd..208bcaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,10 @@ `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under. The two alternate names are gone from `tlvs` too, which is now typed by `TlvName`: narrow a `string` with `isTlvName()` before indexing it. - `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it. +- `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is + removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it + accepted or had accepted with `session.bound(bindType, declaredVersion)`; an assignment to either + field now throws a `TypeError`. ## 0.5.0 diff --git a/README.md b/README.md index ba17dc9..4eaed8a 100644 --- a/README.md +++ b/README.md @@ -421,8 +421,12 @@ const { err, pduObj } = await session.send({ - `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which optional parameters may be sent to it. The library's own senders check it before attaching a TLV; a `send()` you build is passed through as written, so check it yourself. -- `peerInterfaceVersion`: the version the peer declared, `0x00` if none. +- `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind. - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). +- Both hold through a reconnect's gap: the loop binds again as before. +- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever + end accepted it. A `declaredVersion` that is not a number is none declared. Both fields are + read-only. ## Receiving in depth @@ -539,7 +543,8 @@ if (err) throw err; - `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop the link. Guard on the command name, as above, and a failing hook costs only its own request. - A `Session` you construct yourself takes the same hook as a session option, and that is where a - peer's bind gets accepted, since a hand-wired session has no bind handling of its own. + peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call + `session.bound(bindType, pduObj.params.interface_version)` before answering it. **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery diff --git a/docs/decisions.md b/docs/decisions.md index b39885f..0cf1ca0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -715,6 +715,15 @@ rule and an index of the titles below. assembled that way. What goes there is traffic already answered, which is why each group reaches `sessionError` like every other one given up on. +- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's + gap.** Maintainer's call, 2026-09-28. `client()` and `server()` call `bound()` as a hand-wired + session does, so one method is the one spelling, and `loggedIn` went as a second spelling of + `boundAs !== undefined`. Clearing the state at `teardown()` was rejected: `bindAllows()` and + `acceptsOptionalParams()` then answer yes to everything while the link is down, so a + receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a + pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind + type to the same peer. + - **A message id base is merged at most once.** A receipt carries nothing but `-`, so a straggler for a message whose group is gone cannot be told from a receipt for a later message the peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic diff --git a/src/client.ts b/src/client.ts index f20e831..8f727eb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -8,7 +8,7 @@ export type { BindType }; import { ReconnectLoop } from './reconnect-loop.ts'; import { Session } from './session.ts'; -import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; +import { checkSessionOptions } from './session-options.ts'; import { connect as netConnect } from 'node:net'; import { connect as tlsConnect } from 'node:tls'; import { defaultInterfaceVersion } from './defs/constants.ts'; @@ -174,13 +174,7 @@ async function bind(session: Session, options: ClientOptions): Promise, ): Promise { - const declared = pduObj.params.interface_version; - - session.boundAs = bindTypeFromCommand(pduObj.cmdName); - session.loggedIn = true; - session.peerInterfaceVersion = typeof declared === 'number' - ? declared - : undeclaredInterfaceVersion; + session.bound(bindType, pduObj.params.interface_version); await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); } -async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise { +async function onBind( + session: Session, + pduObj: PduObject, + bindType: BindType, + options: ServerOptions, +): Promise { const identity = { system_id: options.systemId ?? defaults.systemId }; const systemId = paramText(pduObj.params.system_id); @@ -198,7 +198,7 @@ async function onBind(session: Session, pduObj: PduObject, options: ServerOption return; } - await acceptBind(session, pduObj, options, identity); + await acceptBind(session, pduObj, bindType, options, identity); session.log.verbose('server - bound', { systemId }); } @@ -211,16 +211,16 @@ async function handleRequest( pduObj: PduObject, options: ServerOptions, ): Promise { - const isBind = bindCommands.includes(pduObj.cmdName); + const bindType = bindTypeFromCommand(pduObj.cmdName); - if (session.loggedIn) { - if (isBind || !options.onRequest) return false; + if (session.boundAs !== undefined) { + if (bindType || !options.onRequest) return false; return options.onRequest(session, pduObj); } - if (isBind) { - await onBind(session, pduObj, options); + if (bindType) { + await onBind(session, pduObj, bindType, options); return true; } diff --git a/src/session.ts b/src/session.ts index 614c0b5..64c90a2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -19,7 +19,7 @@ import { ReconnectLoop } from './reconnect-loop.ts'; import { leftOf } from './idle-waiters.ts'; import { errorFrom } from './error-from.ts'; import { optionalParamsMinVersion } from './defs/constants.ts'; -import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts'; +import { bindCarries, bindCommands, defaultSystemId, defaults, undeclaredInterfaceVersion } from './session-options.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts'; import { refusalAnswer } from './pdu-refusal.ts'; import { guardedLog } from './log.ts'; @@ -54,15 +54,12 @@ export class Session extends EventEmitter { readonly log: SmppLog; - /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ - boundAs: BindType | undefined = undefined; /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */ linkEnd: LinkEnd = 'esme'; - loggedIn = false; - /** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */ - peerInterfaceVersion: number | undefined = undefined; userData: unknown = undefined; + private bind: { as: BindType; peerVersion: number } | undefined = undefined; + private readonly concatReference = new ConcatReference(); private readonly dlrMerger: DlrMerger; private readonly incoming: IncomingRequests; @@ -154,6 +151,23 @@ export class Session extends EventEmitter { return this.transport.sock; } + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ + get boundAs(): BindType | undefined { + return this.bind?.as; + } + + /** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */ + get peerInterfaceVersion(): number | undefined { + return this.bind?.peerVersion; + } + + /** Records a bind this link accepted or had accepted, until the next one. A version that is not a number is none. */ + bound(bindType: BindType, declaredVersion: unknown): void { + const peerVersion = typeof declaredVersion === 'number' ? declaredVersion : undeclaredInterfaceVersion; + + this.bind = { as: bindType, peerVersion }; + } + /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ bindAllows(cmdName: string): boolean { return bindCarries(this.boundAs, cmdName, this.linkEnd); @@ -171,15 +185,13 @@ export class Session extends EventEmitter { } /** Answers a request the peer sent us. Responses are never waited on. */ - async sendReturn( + sendReturn( pdu: PduObject, status: ErrorName = 'ESME_ROK', params: Record = {}, tlvs?: TlvInputs, ): Promise { - return Promise.resolve( - this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr), - ); + return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr)); } private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { diff --git a/todo.md b/todo.md index f3dced1..e99834f 100644 --- a/todo.md +++ b/todo.md @@ -204,11 +204,6 @@ and 5. Every seat ranked the session's lifecycle hardest and least wanted to mod - [ ] **Lift Locality to 7, and confirm it with a scoring run.** A run reading 7.0 or above also retires the #30 decision. The sub-items are what the 2026-09-28 run named, most seats first. -- [ ] **Let `Session` own its bind state.** `client.ts` and `server.ts` write `boundAs`, `loggedIn` - and `peerInterfaceVersion` onto the session from outside, and `loggedIn` duplicates - `boundAs !== undefined`. A method such as `session.bound(bindType, declaredVersion)` with the - three fields read-only changes the public surface a hand-wired SMSC uses, so it needs the - maintainer's call first. All four seats. - [ ] **Name what `request()`, `carry()` and `now()` each skip.** Three ways onto the wire differ only in which of the drain, the gate and the window they bypass, and none of the names says which. Three seats. -- 2.52.0 From 3cc38325cc451cc3d4c775bd3b620823b1005732 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:00:13 +0200 Subject: [PATCH 03/11] Test that bound() refuses a bind type or version it cannot record --- test/session.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/session.test.ts b/test/session.test.ts index d50de32..30a1e4e 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -455,15 +455,23 @@ describe('bind', () => { assert.equal(session.boundAs, undefined); assert.equal(session.peerInterfaceVersion, undefined); - session.bound('receiver', 0x34); - + assert.deepEqual(session.bound('receiver', 0x34), {}); assert.equal(session.boundAs, 'receiver'); assert.equal(session.peerInterfaceVersion, 0x34); assert.equal(session.bindAllows('submit_sm'), false); - session.bound('transmitter', undefined); - + assert.deepEqual(session.bound('transmitter', undefined), {}); assert.equal(session.peerInterfaceVersion, 0x00, 'no declared version is pre-3.4'); + + // The mistakes an untyped caller makes: the TLV object for its value, a version past int8, a typo. + for (const [bindType, declared] of [['receiver', { tagValue: 0x34 }], ['receiver', 0x100], ['receiver', 3.4], ['tx', 0x34]]) { + const refused: unknown = Reflect.apply(session.bound, session, [bindType, declared]); + + assert.ok(refused && typeof refused === 'object' && 'err' in refused && refused.err instanceof Error); + } + + assert.equal(session.boundAs, 'transmitter', 'a refused bind leaves the recorded one alone'); + assert.equal(session.peerInterfaceVersion, 0x00); assert.equal(Reflect.set(session, 'boundAs', 'transceiver'), false); assert.equal(Reflect.set(session, 'peerInterfaceVersion', 0x50), false); assert.equal(session.boundAs, 'transmitter'); -- 2.52.0 From 2ac72fb3f4ff7ad1e05bdf793f679598427695d6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:01:51 +0200 Subject: [PATCH 04/11] Refuse a bind bound() cannot record, and document it for a 0.4.0 consumer --- CHANGELOG.md | 4 ++-- MIGRATION.md | 3 +++ README.md | 10 ++++++---- src/client.ts | 5 ++++- src/server.ts | 4 ++-- src/session-options.ts | 23 +++++++++++++++++++++++ src/session.ts | 19 ++++++++++--------- test/session.test.ts | 2 +- 8 files changed, 51 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 208bcaf..c77c870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,8 +76,8 @@ - `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it. - `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it - accepted or had accepted with `session.bound(bindType, declaredVersion)`; an assignment to either - field now throws a `TypeError`. + accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a + bind type or version it cannot record; an assignment to either field now throws a `TypeError`. ## 0.5.0 diff --git a/MIGRATION.md b/MIGRATION.md index 2759f16..a3a4579 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -36,6 +36,9 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from `tlvs` too. +- **`session.loggedIn` is gone**: read `session.boundAs !== undefined`. `boundAs` and + `peerInterfaceVersion` are read-only; a session you construct yourself records a bind with + `session.bound()`. - **The `error` event is `sessionError`**, and `serverError` on the server handle. - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a `larvitutils` one, and is silent by default: [README](README.md#logging). diff --git a/README.md b/README.md index 4eaed8a..f0af027 100644 --- a/README.md +++ b/README.md @@ -423,10 +423,11 @@ const { err, pduObj } = await session.send({ a `send()` you build is passed through as written, so check it yourself. - `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind. - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). -- Both hold through a reconnect's gap: the loop binds again as before. +- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the + next `bound()`. - `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever - end accepted it. A `declaredVersion` that is not a number is none declared. Both fields are - read-only. + end accepted it, on every link it binds. `declaredVersion` is 0-255, or `undefined` where the peer + declared none; anything else, or an unknown `bindType`, returns `err` and records nothing. ## Receiving in depth @@ -544,7 +545,8 @@ if (err) throw err; the link. Guard on the command name, as above, and a failing hook costs only its own request. - A `Session` you construct yourself takes the same hook as a session option, and that is where a peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call - `session.bound(bindType, pduObj.params.interface_version)` before answering it. + `session.bound(bindType, pduObj.params.interface_version)` before answering it, and refuse the bind + with `ESME_RBINDFAIL` where that returns `err`. **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery diff --git a/src/client.ts b/src/client.ts index 8f727eb..6b7299c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -174,7 +174,10 @@ async function bind(session: Session, options: ClientOptions): Promise, ): Promise { - session.bound(bindType, pduObj.params.interface_version); + const recorded = session.bound(bindType, pduObj.params.interface_version); - await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); + await session.sendReturn(pduObj, recorded.err ? 'ESME_RBINDFAIL' : 'ESME_ROK', identity, bindRespTlvs(session, options)); } async function onBind( diff --git a/src/session-options.ts b/src/session-options.ts index 0e2ad3b..dfb3325 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -121,6 +121,29 @@ export const defaultSystemId = ''; /** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ export const undeclaredInterfaceVersion = 0x00; +export type SessionBind = { as: BindType; peerVersion: number }; + +const bindTypes: readonly string[] = ['receiver', 'transceiver', 'transmitter']; + +function isBindType(value: unknown): value is BindType { + return typeof value === 'string' && bindTypes.includes(value); +} + +/** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */ +export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> { + if (!isBindType(bindType)) { + return { err: new Error(`bindType must be ${bindTypes.join(', ')}, got ${namedValue(bindType)}`) }; + } + + if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } }; + + if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) { + return { err: new Error(`declaredVersion must be 0-255, or undefined where the peer declared none, got ${namedValue(declaredVersion)}`) }; + } + + return { bind: { as: bindType, peerVersion: declaredVersion } }; +} + export const defaults = { /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ dlrMergeTimeout: 86_400_000, diff --git a/src/session.ts b/src/session.ts index 64c90a2..e92b7d4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4,7 +4,7 @@ import type { MessageDlr } from './dlr-merger.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; import type { PduRefusedError } from './pdu-refusal.ts'; -import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; +import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts'; import type { Result, VoidResult } from './result.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SmppLog } from './log.ts'; @@ -19,7 +19,7 @@ import { ReconnectLoop } from './reconnect-loop.ts'; import { leftOf } from './idle-waiters.ts'; import { errorFrom } from './error-from.ts'; import { optionalParamsMinVersion } from './defs/constants.ts'; -import { bindCarries, bindCommands, defaultSystemId, defaults, undeclaredInterfaceVersion } from './session-options.ts'; +import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts'; import { refusalAnswer } from './pdu-refusal.ts'; import { guardedLog } from './log.ts'; @@ -58,7 +58,7 @@ export class Session extends EventEmitter { linkEnd: LinkEnd = 'esme'; userData: unknown = undefined; - private bind: { as: BindType; peerVersion: number } | undefined = undefined; + private bind: SessionBind | undefined = undefined; private readonly concatReference = new ConcatReference(); private readonly dlrMerger: DlrMerger; @@ -161,11 +161,13 @@ export class Session extends EventEmitter { return this.bind?.peerVersion; } - /** Records a bind this link accepted or had accepted, until the next one. A version that is not a number is none. */ - bound(bindType: BindType, declaredVersion: unknown): void { - const peerVersion = typeof declaredVersion === 'number' ? declaredVersion : undeclaredInterfaceVersion; + /** Records a bind this link accepted or had accepted, until the next one. */ + bound(bindType: BindType, declaredVersion: unknown): VoidResult { + const checked = checkedBind(bindType, declaredVersion); - this.bind = { as: bindType, peerVersion }; + if (!checked.err) this.bind = checked.bind; + + return checked.err ? { err: checked.err } : {}; } /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ @@ -175,8 +177,7 @@ export class Session extends EventEmitter { /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */ acceptsOptionalParams(): boolean { - return this.peerInterfaceVersion === undefined - || this.peerInterfaceVersion >= optionalParamsMinVersion; + return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion; } /** Sends a request and resolves with the peer's response. */ diff --git a/test/session.test.ts b/test/session.test.ts index 30a1e4e..1c8a5c8 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -465,7 +465,7 @@ describe('bind', () => { // The mistakes an untyped caller makes: the TLV object for its value, a version past int8, a typo. for (const [bindType, declared] of [['receiver', { tagValue: 0x34 }], ['receiver', 0x100], ['receiver', 3.4], ['tx', 0x34]]) { - const refused: unknown = Reflect.apply(session.bound, session, [bindType, declared]); + const refused: unknown = Reflect.apply(Reflect.get(session, 'bound'), session, [bindType, declared]); assert.ok(refused && typeof refused === 'object' && 'err' in refused && refused.err instanceof Error); } -- 2.52.0 From 9a07f75bdfb740f684410b523e55de661552f664 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:05:07 +0200 Subject: [PATCH 05/11] Refuse a bind the server cannot record as it refuses one authenticate turns down --- MIGRATION.md | 5 ++--- src/server.ts | 23 ++++++++++------------- src/session-options.ts | 6 ++---- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index a3a4579..256297a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -36,9 +36,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from `tlvs` too. -- **`session.loggedIn` is gone**: read `session.boundAs !== undefined`. `boundAs` and - `peerInterfaceVersion` are read-only; a session you construct yourself records a bind with - `session.bound()`. +- **`session.loggedIn` is gone**: read `session.boundAs !== undefined`. A session you construct + yourself records a bind with `session.bound()`. - **The `error` event is `sessionError`**, and `serverError` on the server handle. - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a `larvitutils` one, and is silent by default: [README](README.md#logging). diff --git a/src/server.ts b/src/server.ts index c069187..2b9aefe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -170,18 +170,6 @@ function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | und }; } -async function acceptBind( - session: Session, - pduObj: PduObject, - bindType: BindType, - options: ServerOptions, - identity: Record, -): Promise { - const recorded = session.bound(bindType, pduObj.params.interface_version); - - await session.sendReturn(pduObj, recorded.err ? 'ESME_RBINDFAIL' : 'ESME_ROK', identity, bindRespTlvs(session, options)); -} - async function onBind( session: Session, pduObj: PduObject, @@ -198,7 +186,16 @@ async function onBind( return; } - await acceptBind(session, pduObj, bindType, options, identity); + const recorded = session.bound(bindType, pduObj.params.interface_version); + + if (recorded.err) { + session.log.info('server - bind refused', { message: recorded.err.message, systemId }); + await session.sendReturn(pduObj, 'ESME_RBINDFAIL', identity); + + return; + } + + await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); session.log.verbose('server - bound', { systemId }); } diff --git a/src/session-options.ts b/src/session-options.ts index dfb3325..15d8405 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -123,16 +123,14 @@ export const undeclaredInterfaceVersion = 0x00; export type SessionBind = { as: BindType; peerVersion: number }; -const bindTypes: readonly string[] = ['receiver', 'transceiver', 'transmitter']; - function isBindType(value: unknown): value is BindType { - return typeof value === 'string' && bindTypes.includes(value); + return typeof value === 'string' && bindTypeFromCommand(`bind_${value}`) !== undefined; } /** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */ export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> { if (!isBindType(bindType)) { - return { err: new Error(`bindType must be ${bindTypes.join(', ')}, got ${namedValue(bindType)}`) }; + return { err: new Error(`bindType must be receiver, transceiver or transmitter, got ${namedValue(bindType)}`) }; } if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } }; -- 2.52.0 From 9eebd42887ff9a6555be526992d88f273830cdc9 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:07:02 +0200 Subject: [PATCH 06/11] Test that bound() takes a bind type as a string and names the fix for a wrong one --- test/session.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/session.test.ts b/test/session.test.ts index 1c8a5c8..1f31f0b 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -463,12 +463,11 @@ describe('bind', () => { assert.deepEqual(session.bound('transmitter', undefined), {}); assert.equal(session.peerInterfaceVersion, 0x00, 'no declared version is pre-3.4'); - // The mistakes an untyped caller makes: the TLV object for its value, a version past int8, a typo. - for (const [bindType, declared] of [['receiver', { tagValue: 0x34 }], ['receiver', 0x100], ['receiver', 3.4], ['tx', 0x34]]) { - const refused: unknown = Reflect.apply(Reflect.get(session, 'bound'), session, [bindType, declared]); - - assert.ok(refused && typeof refused === 'object' && 'err' in refused && refused.err instanceof Error); - } + // The likeliest mistakes: the TLV object for its value, a version past int8, the command's name. + assert.match(session.bound('receiver', { tagValue: 0x34 }).err?.message ?? '', /tagValue, or undefined .* got object$/); + assert.ok(session.bound('receiver', 0x100).err); + assert.ok(session.bound('receiver', 3.4).err); + assert.match(session.bound('bind_receiver', 0x34).err?.message ?? '', /without "bind_", got "bind_receiver"$/); assert.equal(session.boundAs, 'transmitter', 'a refused bind leaves the recorded one alone'); assert.equal(session.peerInterfaceVersion, 0x00); -- 2.52.0 From 4bdb1e64b045650ea6e5348c648498d1720475ef Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:07:44 +0200 Subject: [PATCH 07/11] Take bound()'s bind type as a string, name the fix in its refusals, and show the ESME's spelling --- CHANGELOG.md | 3 ++- MIGRATION.md | 5 +++-- README.md | 13 ++++++++----- src/session-options.ts | 8 ++++++-- src/session.ts | 2 +- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c77c870..abdd134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,8 @@ - `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a - bind type or version it cannot record; an assignment to either field now throws a `TypeError`. + bind type or version it cannot record. An assignment to either field does not compile in + TypeScript, and throws a `TypeError` in strict-mode JavaScript, which every module is. ## 0.5.0 diff --git a/MIGRATION.md b/MIGRATION.md index 256297a..7d96689 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -36,8 +36,9 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from `tlvs` too. -- **`session.loggedIn` is gone**: read `session.boundAs !== undefined`. A session you construct - yourself records a bind with `session.bound()`. +- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound, + `session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether + the link is up now. A session you construct yourself records a bind with `session.bound()`. - **The `error` event is `sessionError`**, and `serverError` on the server handle. - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a `larvitutils` one, and is silent by default: [README](README.md#logging). diff --git a/README.md b/README.md index f0af027..ffdf4eb 100644 --- a/README.md +++ b/README.md @@ -424,10 +424,13 @@ const { err, pduObj } = await session.send({ - `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind. - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). - `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the - next `bound()`. + link binds again. - `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever - end accepted it, on every link it binds. `declaredVersion` is 0-255, or `undefined` where the peer - declared none; anything else, or an unknown `bindType`, returns `err` and records nothing. + end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`; + `declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err` + and records nothing. +- An ESME records the bind from its `bind_resp`, in `reconnect.onConnected` on every rebind: + `session.bound('transmitter', pduObj.tlvs.sc_interface_version?.tagValue)`. ## Receiving in depth @@ -545,8 +548,8 @@ if (err) throw err; the link. Guard on the command name, as above, and a failing hook costs only its own request. - A `Session` you construct yourself takes the same hook as a session option, and that is where a peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call - `session.bound(bindType, pduObj.params.interface_version)` before answering it, and refuse the bind - with `ESME_RBINDFAIL` where that returns `err`. + `session.bound(pduObj.cmdName.slice('bind_'.length), pduObj.params.interface_version)` before + answering it, and refuse the bind with `ESME_RBINDFAIL` where that returns `err`. **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery diff --git a/src/session-options.ts b/src/session-options.ts index 15d8405..882ffdf 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -123,6 +123,10 @@ export const undeclaredInterfaceVersion = 0x00; export type SessionBind = { as: BindType; peerVersion: number }; +function quoted(value: unknown): string { + return typeof value === 'string' ? JSON.stringify(value) : namedValue(value); +} + function isBindType(value: unknown): value is BindType { return typeof value === 'string' && bindTypeFromCommand(`bind_${value}`) !== undefined; } @@ -130,13 +134,13 @@ function isBindType(value: unknown): value is BindType { /** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */ export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> { if (!isBindType(bindType)) { - return { err: new Error(`bindType must be receiver, transceiver or transmitter, got ${namedValue(bindType)}`) }; + return { err: new Error(`bindType must be receiver, transceiver or transmitter, the bind command's name without "bind_", got ${quoted(bindType)}`) }; } if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } }; if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) { - return { err: new Error(`declaredVersion must be 0-255, or undefined where the peer declared none, got ${namedValue(declaredVersion)}`) }; + return { err: new Error(`declaredVersion must be an integer 0-255, the interface_version param or the sc_interface_version TLV's tagValue, or undefined where the peer declared none, got ${quoted(declaredVersion)}`) }; } return { bind: { as: bindType, peerVersion: declaredVersion } }; diff --git a/src/session.ts b/src/session.ts index e92b7d4..b165697 100644 --- a/src/session.ts +++ b/src/session.ts @@ -162,7 +162,7 @@ export class Session extends EventEmitter { } /** Records a bind this link accepted or had accepted, until the next one. */ - bound(bindType: BindType, declaredVersion: unknown): VoidResult { + bound(bindType: string, declaredVersion: unknown): VoidResult { const checked = checkedBind(bindType, declaredVersion); if (!checked.err) this.bind = checked.bind; -- 2.52.0 From 2142c2e3bb763cd922b2d926ee372daaf22b6942 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:08:53 +0200 Subject: [PATCH 08/11] Spell out the hand-wired ESME's bind, and where a getter-only assignment throws --- CHANGELOG.md | 3 ++- MIGRATION.md | 3 ++- README.md | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abdd134..b7f7a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,8 @@ removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a bind type or version it cannot record. An assignment to either field does not compile in - TypeScript, and throws a `TypeError` in strict-mode JavaScript, which every module is. + TypeScript, throws a `TypeError` in an ES module or strict-mode script, and is ignored in + sloppy-mode CommonJS. ## 0.5.0 diff --git a/MIGRATION.md b/MIGRATION.md index 7d96689..9c0e433 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,7 +13,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re a `session` event. It no longer calls back once per connection. - **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. - Assigning to it throws a `TypeError`, since modules are strict mode. + Assigning to it throws a `TypeError` in an ES module or strict-mode script, and is ignored in + sloppy-mode CommonJS. - **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with `pduObjs`, `undefined` where the SMSC took the segment without naming an id. - **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and diff --git a/README.md b/README.md index ffdf4eb..5906e53 100644 --- a/README.md +++ b/README.md @@ -429,8 +429,10 @@ const { err, pduObj } = await session.send({ end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`; `declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err` and records nothing. -- An ESME records the bind from its `bind_resp`, in `reconnect.onConnected` on every rebind: - `session.bound('transmitter', pduObj.tlvs.sc_interface_version?.tagValue)`. +- An ESME wired by hand sends its own `bind_` through `session.send()`, after it is + constructed and again in `reconnect.onConnected`, and records each accepted one with + `session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one + it sent and `pduObj` the `bind_resp` that `send()` resolved with. ## Receiving in depth -- 2.52.0 From 80dc263915b86e9de1ea93ac4cef6652109fd253 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:09:40 +0200 Subject: [PATCH 09/11] Name strict-mode code as where a getter-only assignment throws --- CHANGELOG.md | 4 ++-- MIGRATION.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f7a54..2847a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,8 +78,8 @@ removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a bind type or version it cannot record. An assignment to either field does not compile in - TypeScript, throws a `TypeError` in an ES module or strict-mode script, and is ignored in - sloppy-mode CommonJS. + TypeScript, throws a `TypeError` in strict-mode code (every ES module, and any file under + `'use strict'`), and is ignored otherwise. ## 0.5.0 diff --git a/MIGRATION.md b/MIGRATION.md index 9c0e433..8f47396 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,8 +13,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re a `session` event. It no longer calls back once per connection. - **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. - Assigning to it throws a `TypeError` in an ES module or strict-mode script, and is ignored in - sloppy-mode CommonJS. + Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under + `'use strict'`), and is ignored otherwise. - **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with `pduObjs`, `undefined` where the SMSC took the segment without naming an id. - **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and -- 2.52.0 From 3f736ff4404353afc3e6b926f307390b3c7792ea Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:11:23 +0200 Subject: [PATCH 10/11] Quote a string connectTimeout through quoted() too --- src/session-options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session-options.ts b/src/session-options.ts index 882ffdf..0b768c9 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -201,7 +201,7 @@ const maxTimerDelay = 2_147_483_647; function checkConnectTimeout(connectTimeout: unknown): VoidResult { if (connectTimeout === undefined || connectTimeout === false) return {}; - const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout); + const got = quoted(connectTimeout); if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) { return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) }; -- 2.52.0 From e4ff71a86ba2d8b033a178003a84bac73bbfff05 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 04:11:55 +0200 Subject: [PATCH 11/11] Tie the bind-state decision to its goal, and drop what MIGRATION holds --- docs/decisions.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 0cf1ca0..425fe66 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -716,9 +716,8 @@ rule and an index of the titles below. `sessionError` like every other one given up on. - **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's - gap.** Maintainer's call, 2026-09-28. `client()` and `server()` call `bound()` as a hand-wired - session does, so one method is the one spelling, and `loggedIn` went as a second spelling of - `boundAs !== undefined`. Clearing the state at `teardown()` was rejected: `bindAllows()` and + gap.** Maintainer's call, 2026-09-28. `client()` and `server()` record their bind through + `bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and `acceptsOptionalParams()` then answer yes to everything while the link is down, so a receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind -- 2.52.0