Read the GSM 03.38 message class where the spec puts it (#95)

* Regression tests for the GSM 03.38 message class behind sms.flash

* Read the GSM 03.38 message class where the spec puts it, and refuse a flash message no alphabet can carry

* Keep the code span in the message-class decision on one line

* Stop offering the flash message class as an alphabet, and check the encoding option by name

* Settle every refusable send option in one check, and record what the review left open

* Say what the flash refusal and the encoding option actually do

* Keep the README off the alphabet whose long messages do not fit
This commit is contained in:
2026-09-09 16:40:32 +02:00
committed by GitHub
parent bd466d393a
commit e80b07167c
11 changed files with 422 additions and 31 deletions
+44 -1
View File
@@ -207,7 +207,10 @@ exactly 140.
stays there because that is a different peer rather than a second copy of this one. The waiting
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
footing — it encodes no wire fact `objToPdu()` does not already own.
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`
and `message-class.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to
that type fails to compile in both copies at once.
- `message_id` values the library generates are UUID v7.
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
@@ -421,6 +424,46 @@ Grouped by what each one constrains.
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
but `data_coding` can say how a message was written.
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
03.38, and the one SMPPSim demonstrated
([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17).
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
`sms` event, which pays goal 6 for three classes nothing here acts on, where the boolean the
application already had covers the one it does. Compressed text is out of scope and stays out —
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
spelling 03.38 at all. Send-side: `flash`
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
peer means as immediate display.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
+22 -2
View File
@@ -122,6 +122,16 @@ defines the delivery report away, so pair it with `dlr: true` and the send is re
leaving you waiting for a report that cannot come — as is any value naming no mode, both before a
segment goes out.
`encoding` names the alphabet, and is `ASCII` (GSM 03.38's own 7-bit table), `LATIN1` or `UCS2`;
anything else is refused by name rather than guessed at. Leave it out and a message that fits GSM
7-bit goes as `ASCII` and everything else as `UCS2`; `LATIN1` is only ever used when you name it.
`flash` asks for GSM 03.38 message class 0, the class a handset shows on arrival instead of storing.
It travels in `data_coding` beside the alphabet, so a flash UCS2 message stays UCS2. Pairing it with
`encoding: 'LATIN1'` is the one combination with nowhere to go — a message class carries GSM 7-bit,
8-bit data or UCS2, and 8-bit data is not text a handset will display — so the send is refused
before anything goes out.
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
one id per segment:
@@ -157,6 +167,10 @@ session.on('sms', async sms => {
});
```
`sms.flash` is true where the message's `data_coding` carries GSM 03.38 message class 0, in every
coding group that carries one — so `0x10`, `0x18`, `0x50` and `0xF0` alike. The other three classes
name where the handset stores the message rather than that it displays it, so they are not flash.
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
to tell the two apart. `esm_class` is what tells them apart; where it names no message type a
`receipted_message_id` TLV does, and failing both the message body is read for the standard
@@ -545,7 +559,10 @@ answer to which of the two the peer used, and gives back the octets undecoded:
`decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text and
hands back the UDH where the PDU carries one. `concatOf(pduObj)` is the same for concatenation: the
`part`, `total` and `reference` a PDU declares, and the `spelling``'udh'` or `'sar'` — that
carried them, or `undefined` where the PDU is a whole message.
carried them, or `undefined` where the PDU is a whole message. `messageClassOf(dataCoding)` is the
same for the GSM 03.38 message class: `0` for the flash class `sms.flash` already reports, `1`, `2`
and `3` for the ME-, SIM- and TE-specific ones, and `undefined` where that `data_coding`'s coding
group carries no class at all.
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
`types`, and the matching `*ById` maps) and grouped as `defs`.
@@ -599,7 +616,10 @@ have worked around any of these, remove the workaround:
- Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's
final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the
intermediate delivery notification.
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters.
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters, and put the
GSM alphabet on a Latin-1 message that has no `data_coding` at all — that pair is refused now.
Inbound, only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the
whole 0xF0 coding group arrived as ordinary messages.
- The multipart reference counter was shared by every session in the process.
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
+4
View File
@@ -60,6 +60,10 @@ tests passing, `malformed: 0`, `expert errors: 0` in both.
| C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (not flash), raw UDH+8-bit-binary |
| C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts |
C17's `raw 0xF0 (not flash)` recorded a `@larvit/smpp` defect, fixed in
[#95](https://github.com/larvit/larvitsmpp/pull/95): 0xF0 is GSM 03.38 message class 0, so it reads
as flash now.
## Defects in @larvit/smpp
### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format
+28 -11
View File
@@ -1,4 +1,4 @@
export type EncodingName = 'ASCII' | 'FLASH' | 'LATIN1' | 'UCS2';
export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2';
export type Encoding = {
decode: (buffer: Uint8Array) => string;
@@ -104,11 +104,16 @@ const ucs2: Encoding = {
export const encodings: Record<EncodingName, Encoding> = {
ASCII: ascii,
FLASH: ascii,
LATIN1: latin1,
UCS2: ucs2,
};
export const encodingNames: readonly string[] = Object.keys(encodings);
export function isEncodingName(value: unknown): value is EncodingName {
return typeof value === 'string' && Object.hasOwn(encodings, value);
}
export function detect(value: string): EncodingName {
if (encodings.ASCII.match(value)) return 'ASCII';
if (encodings.LATIN1.match(value)) return 'LATIN1';
@@ -116,21 +121,33 @@ export function detect(value: string): EncodingName {
return 'UCS2';
}
/** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
if ((dataCoding & 0xF0) === 0x10) {
const alphabet = (dataCoding >> 2) & 0x03;
if (alphabet === 0x01) return 'LATIN1';
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
/**
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
*/
export function messageClassOf(dataCoding: number): number | undefined {
if ((dataCoding & 0x80) === 0) {
return (dataCoding & 0x10) === 0x10 ? dataCoding & 0x03 : undefined;
}
return (dataCoding & 0xF0) === 0xF0 ? dataCoding & 0x03 : undefined;
}
// A class is the only evidence a peer below 0x80 is spelling 03.38 rather than SMPP's flat table,
// which contradicts it and wins: 0x03 is Latin-1 here, GSM 7-bit there.
/** A class group puts the alphabet in bits 3-2, or in bit 2 alone above 0xF0. */
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
if (messageClassOf(dataCoding) === undefined) return undefined;
if ((dataCoding & 0xF0) === 0xF0) {
return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
}
return undefined;
const alphabet = (dataCoding >> 2) & 0x03;
if (alphabet === 0x01) return 'LATIN1';
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
}
/**
+1 -1
View File
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
export { consts, constsById } from './defs/constants.ts';
export { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
export { detect, encodingByDataCoding, encodings, messageClassOf } from './defs/encodings.ts';
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
export { tlvs, tlvsById } from './defs/tlvs.ts';
export { types } from './defs/types.ts';
+1 -1
View File
@@ -51,7 +51,7 @@ export function bitCount(message: string, encoding?: EncodingName): number {
const encoded = encodings[resolved].encode(message);
// GSM characters are packed seven bits to a septet; everything else stays octet-aligned.
return resolved === 'ASCII' || resolved === 'FLASH' ? encoded.length * 7 : encoded.length * 8;
return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8;
}
/**
+56 -9
View File
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
import type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './unanswered-error.ts';
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
import { detect } from './defs/encodings.ts';
import { detect, encodingNames, isEncodingName } from './defs/encodings.ts';
import { namedValue } from './error-from.ts';
import { normaliseSmsId } from './sms-id.ts';
import { paramText } from './defs/types.ts';
@@ -32,8 +32,11 @@ export type SendSmsOptions = {
validityPeriod?: Date | number | string;
};
/** The options as they arrive: a caller without types can put anything in the checked field. */
export type SendSmsInput = Omit<SendSmsOptions, 'messagingMode'> & { messagingMode?: unknown };
/** The options as they arrive: a caller without types can put anything in the checked fields. */
export type SendSmsInput = Omit<SendSmsOptions, 'encoding' | 'messagingMode'> & {
encoding?: unknown;
messagingMode?: unknown;
};
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
export type SendSmsResult = {
@@ -57,6 +60,12 @@ export type SendSmsDeps = {
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
/** What the checks below settle, before a segment exists to carry it. */
type CheckedOptions = {
encoding: EncodingName;
messagingMode: SubmitMessagingMode;
};
type SegmentOptions = {
encoding: EncodingName;
messagingMode?: SubmitMessagingMode;
@@ -140,6 +149,45 @@ function checkMessagingMode(
return { err: refusedMode(mode) };
}
function refusedEncoding(encoding: unknown): Error {
if (encoding === 'FLASH') {
return new Error('encoding FLASH is a message class rather than an alphabet; ask for it as flash: true beside the alphabet you want');
}
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
}
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
if (encoding === undefined) return { encoding: detect(message) };
if (isEncodingName(encoding)) return { encoding };
return { err: refusedEncoding(encoding) };
}
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
if (!flash || encoding !== 'LATIN1') return undefined;
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
}
/** Every option a send can be refused for, so nothing is built for a message that will not go. */
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
if (mode.err) return { err: mode.err };
const chosen = checkEncoding(sms.encoding, sms.message);
if (chosen.err) return { err: chosen.err };
const unspellable = checkFlash(chosen.encoding, sms.flash === true);
if (unspellable) return { err: unspellable };
return { encoding: chosen.encoding, messagingMode: mode.messagingMode };
}
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
function checkSegments(allowed: number, segments: number): Error | undefined {
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
@@ -186,14 +234,13 @@ function collectSent(
/** Puts a message on the wire as one submit_sm per segment. */
export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<SendSmsResult> {
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
const options = checkOptions(sms);
if (mode.err) return unsent(mode.err);
if (options.err) return unsent(options.err);
const allowed = sms.maxSegments ?? maxSegments;
const encoding = sms.encoding ?? detect(sms.message);
const encoding = options.encoding;
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
const refused = checkSegments(allowed, segments.length);
const refused = checkSegments(sms.maxSegments ?? maxSegments, segments.length);
if (refused) return unsent(refused);
@@ -205,7 +252,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => deps.send({
cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { encoding, messagingMode: mode.messagingMode, multipart }),
params: submitSmParams(sms, segment, { encoding, messagingMode: options.messagingMode, multipart }),
})));
return collectSent(sent, deps.respIdNotation);
+6 -1
View File
@@ -5,6 +5,7 @@ import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts';
import { consts } from './defs/constants.ts';
import { messageClassOf } from './defs/encodings.ts';
import { receiptCodes, transientStates } from './dlr.ts';
import { smppDate } from './message.ts';
import { respIdParams, segmentId } from './sms-id.ts';
@@ -35,6 +36,7 @@ export type Sms = {
*/
answeredOnArrival: boolean;
dlr: boolean;
/** GSM 03.38 message class 0: shown on arrival and not stored. */
flash: boolean;
from: string;
message: string;
@@ -71,6 +73,9 @@ export type SmsHandlers = {
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */
const immediateDisplayClass = 0;
export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
const first = input.pduObjs[0];
const registered = first?.params.registered_delivery;
@@ -80,7 +85,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
const sms: Sms = {
answeredOnArrival: input.answeredAs !== undefined,
dlr: typeof registered === 'number' && registered !== 0,
flash: typeof dataCoding === 'number' && (dataCoding & 0xF0) === 0x10,
flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass,
from: input.from,
message: input.message,
pduObjs: input.pduObjs,
+13
View File
@@ -122,8 +122,19 @@ describe('encodingByDataCoding()', () => {
test('reads the alphabet bits when a message class is present', () => {
assert.equal(encodingByDataCoding(0x10), 'ASCII');
assert.equal(encodingByDataCoding(0x11), 'ASCII');
assert.equal(encodingByDataCoding(0x18), 'UCS2');
assert.equal(encodingByDataCoding(0x1A), 'UCS2');
assert.equal(encodingByDataCoding(0xF0), 'ASCII');
assert.equal(encodingByDataCoding(0xF1), 'ASCII');
});
// The compressed and automatic-deletion groups put the alphabet where the plain one does.
test('reads them in the compressed and automatic-deletion groups too', () => {
assert.equal(encodingByDataCoding(0x30), 'ASCII');
assert.equal(encodingByDataCoding(0x38), 'UCS2');
assert.equal(encodingByDataCoding(0x54), 'LATIN1');
assert.equal(encodingByDataCoding(0x58), 'UCS2');
});
test('resolves the 8-bit binary codings to the codec that keeps every octet', () => {
@@ -135,5 +146,7 @@ describe('encodingByDataCoding()', () => {
test('falls back to ASCII for alphabets it has no codec for', () => {
assert.equal(encodingByDataCoding(0x05), 'ASCII');
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
// No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table.
assert.equal(encodingByDataCoding(0x48), 'ASCII');
});
});
+225
View File
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { PduObjectInput } from '../src/pdu.ts';
import type { SendSmsDeps } from '../src/send-sms.ts';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import type { TestContext } from 'node:test';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { client } from '../src/client.ts';
import { closeAfter } from './teardown.ts';
import { messageClassOf } from '../src/defs/encodings.ts';
import { paramNumber } from '../src/defs/types.ts';
import { pduToObj } from '../src/pdu.ts';
import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts';
const from = '46701113311';
const to = '46709771337';
type MessagePeer = {
/** Every message the server session was handed, in arrival order. */
received: Sms[];
session: Session;
};
/** A server that answers every message, and a client to write raw submit_sm PDUs at it. */
async function messagesInto(t: TestContext): Promise<MessagePeer> {
const received: Sms[] = [];
const { err, server: smpp } = await server({ port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
smpp.on('session', peer => peer.on('sms', sms => {
received.push(sms);
return sms.sendResp();
}));
const connected = await client({ port: smpp.port, reconnect: false });
assert.equal(connected.err, undefined);
assert.ok(connected.session);
closeAfter(t, connected.session);
return { received, session: connected.session };
}
function dataCodingsOf(octets: Buffer[]): number[] {
return octets.map(pdu => {
const { pduObj } = pduToObj(pdu);
assert.ok(pduObj);
return paramNumber(pduObj.params.data_coding, 0);
});
}
/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */
function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps {
return {
log: silentLog,
reference: 1,
send: input => {
attempts.push(input);
return Promise.resolve({ err: new Error('the recording peer never answers') });
},
};
}
describe('messageClassOf()', () => {
test('reads the class out of both of the coding groups that carry one', () => {
const classes: [number, number | undefined][] = [
[0x10, 0], [0x11, 1], [0x12, 2], [0x13, 3],
[0x14, 0], [0x18, 0], [0x1B, 3],
[0xF0, 0], [0xF1, 1], [0xF2, 2], [0xF3, 3],
[0xF4, 0], [0xF7, 3],
];
for (const [dataCoding, messageClass] of classes) {
assert.equal(messageClassOf(dataCoding), messageClass, dataCoding.toString(16));
}
});
// Bit 5 marks the text compressed and bits 7-6 mark it for automatic deletion; neither moves the
// class bits, and 01xx is coded exactly as 00xx.
test('reads the class of a compressed message and of the automatic-deletion group', () => {
const classes: [number, number][] = [[0x30, 0], [0x33, 3], [0x50, 0], [0x53, 3], [0x70, 0], [0x78, 0]];
for (const [dataCoding, messageClass] of classes) {
assert.equal(messageClassOf(dataCoding), messageClass, dataCoding.toString(16));
}
});
test('reports no class where the coding group defines none', () => {
// Bit 4 clear below 0x80, then the reserved, message-waiting and reserved groups above it.
for (const dataCoding of [0x00, 0x01, 0x03, 0x08, 0x0F, 0x20, 0x40, 0x4F, 0x80, 0xC0, 0xD0, 0xE0, 0xEF]) {
assert.equal(messageClassOf(dataCoding), undefined, dataCoding.toString(16));
}
});
});
describe('an inbound message', () => {
test('is flash for message class 0 alone, in either coding group', async t => {
const peer = await messagesInto(t);
// 0.4.0 read 0x10 and nothing else; this branch read 0x10-0x1F, so the three stored classes
// arrived as flash and the whole 0xF0 group did not (interop-tests/findings/02-smppsim.md, C17).
const codings: [number, boolean][] = [
[0x00, false], [0x03, false], [0x08, false],
[0x10, true], [0x11, false], [0x12, false], [0x13, false],
[0x14, true], [0x18, true], [0x30, true], [0x50, true],
[0xF0, true], [0xF1, false], [0xF2, false], [0xF3, false], [0xF4, true],
];
for (const [dataCoding] of codings) {
const sent = await peer.session.send({
cmdName: 'submit_sm',
params: {
data_coding: dataCoding,
destination_addr: to,
short_message: 'hi',
source_addr: from,
},
});
assert.equal(sent.err, undefined, dataCoding.toString(16));
}
assert.deepEqual(peer.received.map(sms => sms.flash), codings.map(([, flash]) => flash));
assert.deepEqual(peer.received.map(sms => sms.message), codings.map(() => 'hi'));
});
test('keeps the alphabet its class group declares, so a flash UCS2 message still reads as UCS2', async t => {
const peer = await messagesInto(t);
const sent = await peer.session.send({
cmdName: 'submit_sm',
params: {
data_coding: 0x18,
destination_addr: to,
short_message: 'تست',
source_addr: from,
},
});
const [sms] = peer.received;
assert.equal(sent.err, undefined);
assert.ok(sms);
assert.equal(sms.message, 'تست');
assert.equal(sms.flash, true);
});
});
describe('sendSms() flash', () => {
test('writes the message class into data_coding beside the alphabet, never over it', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sends = [
{ from, message: 'Hello world', to },
{ flash: true, from, message: 'Hello world', to },
{ from, message: 'تست', to },
{ flash: true, from, message: 'تست', to },
{ encoding: 'LATIN1', from, message: 'Hello world', to },
] as const;
for (const send of sends) {
assert.equal((await session.sendSms(send)).err, undefined, send.message);
}
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]);
});
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
const attempts: PduObjectInput[] = [];
const deps = recordingDeps(attempts);
const sent = await submitSms(deps, { encoding: 'LATIN1', flash: true, from, message: 'Hello world', to });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /flash/);
assert.match(sent.err.message, /Latin-1/);
assert.deepEqual(sent.pduObjs, []);
assert.deepEqual(sent.smsIds, []);
assert.equal(sent.unanswered, 0);
assert.equal(attempts.length, 0, 'a refused flash message puts nothing on the wire');
// Either half alone is fine; it is only the pair that has nowhere to go.
const latin1 = await submitSms(deps, { encoding: 'LATIN1', from, message: 'Hello world', to });
const flash = await submitSms(deps, { flash: true, from, message: 'Hello world', to });
assert.equal(latin1.err?.message, 'the recording peer never answers');
assert.equal(flash.err?.message, 'the recording peer never answers');
assert.equal(attempts.length, 2);
});
// FLASH named data_coding 0x10 among the alphabets, so the class was reachable through the option
// that chooses a charset — and a non-GSM body sent that way was flattened to spaces, unrefused.
test('refuses the message class posing as an alphabet, naming the option that means it', async () => {
const attempts: PduObjectInput[] = [];
const sent = await submitSms(recordingDeps(attempts), { encoding: 'FLASH', from, message: 'Hello world', to });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /encoding FLASH is a message class/);
assert.match(sent.err.message, /flash: true/);
assert.equal(attempts.length, 0);
});
test('refuses an encoding naming no alphabet rather than throwing out of the send', async () => {
const attempts: PduObjectInput[] = [];
const deps = recordingDeps(attempts);
for (const encoding of ['utf8', 'ascii', 8, {}]) {
const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to });
assert.ok(sent.err instanceof Error, JSON.stringify(encoding));
assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/);
assert.deepEqual(sent.smsIds, []);
}
assert.equal(attempts.length, 0, 'a refused encoding puts nothing on the wire');
});
});
+22 -5
View File
@@ -120,11 +120,28 @@ session message is a change to every call site.
another message's in a correlation table. The cost is that every consumer narrows, including
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
**This one is 1.0.0-or-never** — after release it needs a major version.
- [ ] `sms.flash` is true only for the 0x10 coding group, never for 0xF0. GSM 03.38 gives 0xF00xF3
as data coding plus message class, where class 0 is immediate display — flash. `encodings.ts`
already reads the 0xF0 group for the alphabet; `sms.ts` does not read it for flash. SMPPSim
observed and recorded it as not-flash (`findings/02-smppsim.md`, C17), so it is measured, not
suspected. Either read the group or say in the README that `flash` means the 0x10 spelling.
- [ ] **`bitCount()`, `encodeMessage()` and `splitMessage()` throw on an encoding name they have no
codec for**, where `sendSms()` now refuses one by name. All three are value-exported, so this
is the published surface, one door over from the one [#95](https://github.com/larvit/larvitsmpp/pull/95)
closed — and that PR sharpened the edge, since `encodeMessage(msg, 'FLASH')` used to encode
quietly and now throws out of the codec table. `decodeMessage()` is unaffected; it resolves
through `encodingByDataCoding()`. Tag-relevant because of the fix shape: guarding them means
either a `Result` signature on three published functions, which needs a major version after
the tag, or a documented deviation saying a typed caller cannot get here. Maintainer's call.
Raised by the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95),
2026-09-09.
- [ ] **A concatenated Latin-1 segment is 159 octets, and an SMS carries 140.** `segmentUnits` in
`message.ts` budgets 153 for everything that is not UCS2, which is right for GSM 7-bit alone —
the SMSC packs 153 septets into 134 octets. Latin-1 is 8-bit and never packed, so a long
`encoding: 'LATIN1'` message goes out with segments no SMSC can carry. Same family as the
0.4.0 "Short segments" row, in the other direction; goal 1 owns it, so it wants fixing before
the tag. One line — 134 octets for both unpacked alphabets — plus a regression test. Raised by
the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95), 2026-09-09.
- [ ] **The interop suite still asserts the defect [#95](https://github.com/larvit/larvitsmpp/pull/95)
fixed.** `interop-tests/smppsim.test.ts:621`, `a raw submit_sm with data_coding 0xF0 is not
read as flash`, fails on the next `./interop-tests/run.py smppsim`; nothing in CI runs that
suite, so it fails silently until someone does. The test name and its assertion both need
inverting, and the C17 row in `findings/02-smppsim.md` names that sub-test, so it follows.
- [ ] Tag `v1.0.0` to publish.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do.