@mt-tl/server 0.1.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.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/auth/handshake.d.ts +35 -0
  4. package/dist/auth/handshake.d.ts.map +1 -0
  5. package/dist/auth/handshake.js +208 -0
  6. package/dist/auth/handshake.js.map +1 -0
  7. package/dist/auth/nonce-store.d.ts +22 -0
  8. package/dist/auth/nonce-store.d.ts.map +1 -0
  9. package/dist/auth/nonce-store.js +23 -0
  10. package/dist/auth/nonce-store.js.map +1 -0
  11. package/dist/bootstrap.d.ts +36 -0
  12. package/dist/bootstrap.d.ts.map +1 -0
  13. package/dist/bootstrap.js +82 -0
  14. package/dist/bootstrap.js.map +1 -0
  15. package/dist/config.d.ts +103 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +2 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/core/context.d.ts +57 -0
  20. package/dist/core/context.d.ts.map +1 -0
  21. package/dist/core/context.js +27 -0
  22. package/dist/core/context.js.map +1 -0
  23. package/dist/core/errors.d.ts +33 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +47 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/index.d.ts +5 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/index.js +5 -0
  30. package/dist/core/index.js.map +1 -0
  31. package/dist/core/rpc.d.ts +83 -0
  32. package/dist/core/rpc.d.ts.map +1 -0
  33. package/dist/core/rpc.js +102 -0
  34. package/dist/core/rpc.js.map +1 -0
  35. package/dist/core/updates.d.ts +56 -0
  36. package/dist/core/updates.d.ts.map +1 -0
  37. package/dist/core/updates.js +34 -0
  38. package/dist/core/updates.js.map +1 -0
  39. package/dist/create-server.d.ts +89 -0
  40. package/dist/create-server.d.ts.map +1 -0
  41. package/dist/create-server.js +109 -0
  42. package/dist/create-server.js.map +1 -0
  43. package/dist/crypto/aes-ige.d.ts +3 -0
  44. package/dist/crypto/aes-ige.d.ts.map +1 -0
  45. package/dist/crypto/aes-ige.js +55 -0
  46. package/dist/crypto/aes-ige.js.map +1 -0
  47. package/dist/crypto/dh.d.ts +21 -0
  48. package/dist/crypto/dh.d.ts.map +1 -0
  49. package/dist/crypto/dh.js +99 -0
  50. package/dist/crypto/dh.js.map +1 -0
  51. package/dist/crypto/hashes.d.ts +6 -0
  52. package/dist/crypto/hashes.d.ts.map +1 -0
  53. package/dist/crypto/hashes.js +14 -0
  54. package/dist/crypto/hashes.js.map +1 -0
  55. package/dist/crypto/msg-key.d.ts +15 -0
  56. package/dist/crypto/msg-key.d.ts.map +1 -0
  57. package/dist/crypto/msg-key.js +24 -0
  58. package/dist/crypto/msg-key.js.map +1 -0
  59. package/dist/crypto/rsa.d.ts +27 -0
  60. package/dist/crypto/rsa.d.ts.map +1 -0
  61. package/dist/crypto/rsa.js +50 -0
  62. package/dist/crypto/rsa.js.map +1 -0
  63. package/dist/dispatch/dispatcher.d.ts +72 -0
  64. package/dist/dispatch/dispatcher.d.ts.map +1 -0
  65. package/dist/dispatch/dispatcher.js +503 -0
  66. package/dist/dispatch/dispatcher.js.map +1 -0
  67. package/dist/dispatch/forwarders/in-process.d.ts +12 -0
  68. package/dist/dispatch/forwarders/in-process.d.ts.map +1 -0
  69. package/dist/dispatch/forwarders/in-process.js +15 -0
  70. package/dist/dispatch/forwarders/in-process.js.map +1 -0
  71. package/dist/dispatch/forwarders/print.d.ts +14 -0
  72. package/dist/dispatch/forwarders/print.d.ts.map +1 -0
  73. package/dist/dispatch/forwarders/print.js +23 -0
  74. package/dist/dispatch/forwarders/print.js.map +1 -0
  75. package/dist/dispatch/rpc-forwarder.d.ts +14 -0
  76. package/dist/dispatch/rpc-forwarder.d.ts.map +1 -0
  77. package/dist/dispatch/rpc-forwarder.js +2 -0
  78. package/dist/dispatch/rpc-forwarder.js.map +1 -0
  79. package/dist/dispatch/types.d.ts +32 -0
  80. package/dist/dispatch/types.d.ts.map +1 -0
  81. package/dist/dispatch/types.js +23 -0
  82. package/dist/dispatch/types.js.map +1 -0
  83. package/dist/gateway.d.ts +57 -0
  84. package/dist/gateway.d.ts.map +1 -0
  85. package/dist/gateway.js +150 -0
  86. package/dist/gateway.js.map +1 -0
  87. package/dist/index.d.ts +7 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +25 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/lib.d.ts +12 -0
  92. package/dist/lib.d.ts.map +1 -0
  93. package/dist/lib.js +13 -0
  94. package/dist/lib.js.map +1 -0
  95. package/dist/server/message-pipeline.d.ts +57 -0
  96. package/dist/server/message-pipeline.d.ts.map +1 -0
  97. package/dist/server/message-pipeline.js +199 -0
  98. package/dist/server/message-pipeline.js.map +1 -0
  99. package/dist/session/inbound-tracker.d.ts +118 -0
  100. package/dist/session/inbound-tracker.d.ts.map +1 -0
  101. package/dist/session/inbound-tracker.js +170 -0
  102. package/dist/session/inbound-tracker.js.map +1 -0
  103. package/dist/session/message-id.d.ts +19 -0
  104. package/dist/session/message-id.d.ts.map +1 -0
  105. package/dist/session/message-id.js +30 -0
  106. package/dist/session/message-id.js.map +1 -0
  107. package/dist/session/salts.d.ts +51 -0
  108. package/dist/session/salts.d.ts.map +1 -0
  109. package/dist/session/salts.js +132 -0
  110. package/dist/session/salts.js.map +1 -0
  111. package/dist/session/session-manager.d.ts +18 -0
  112. package/dist/session/session-manager.d.ts.map +1 -0
  113. package/dist/session/session-manager.js +43 -0
  114. package/dist/session/session-manager.js.map +1 -0
  115. package/dist/storage/index.d.ts +14 -0
  116. package/dist/storage/index.d.ts.map +1 -0
  117. package/dist/storage/index.js +16 -0
  118. package/dist/storage/index.js.map +1 -0
  119. package/dist/storage/memory.d.ts +3 -0
  120. package/dist/storage/memory.d.ts.map +1 -0
  121. package/dist/storage/memory.js +91 -0
  122. package/dist/storage/memory.js.map +1 -0
  123. package/dist/storage/mongo.d.ts +3 -0
  124. package/dist/storage/mongo.d.ts.map +1 -0
  125. package/dist/storage/mongo.js +175 -0
  126. package/dist/storage/mongo.js.map +1 -0
  127. package/dist/storage/types.d.ts +85 -0
  128. package/dist/storage/types.d.ts.map +1 -0
  129. package/dist/storage/types.js +3 -0
  130. package/dist/storage/types.js.map +1 -0
  131. package/dist/testkit.d.ts +11 -0
  132. package/dist/testkit.d.ts.map +1 -0
  133. package/dist/testkit.js +17 -0
  134. package/dist/testkit.js.map +1 -0
  135. package/dist/tl/codec.d.ts +37 -0
  136. package/dist/tl/codec.d.ts.map +1 -0
  137. package/dist/tl/codec.js +297 -0
  138. package/dist/tl/codec.js.map +1 -0
  139. package/dist/tl/layered-registry.d.ts +29 -0
  140. package/dist/tl/layered-registry.d.ts.map +1 -0
  141. package/dist/tl/layered-registry.js +118 -0
  142. package/dist/tl/layered-registry.js.map +1 -0
  143. package/dist/tl/protocol.d.ts +126 -0
  144. package/dist/tl/protocol.d.ts.map +1 -0
  145. package/dist/tl/protocol.js +22 -0
  146. package/dist/tl/protocol.js.map +1 -0
  147. package/dist/tl/reader.d.ts +30 -0
  148. package/dist/tl/reader.d.ts.map +1 -0
  149. package/dist/tl/reader.js +87 -0
  150. package/dist/tl/reader.js.map +1 -0
  151. package/dist/tl/registry.d.ts +39 -0
  152. package/dist/tl/registry.d.ts.map +1 -0
  153. package/dist/tl/registry.js +52 -0
  154. package/dist/tl/registry.js.map +1 -0
  155. package/dist/tl/writer.d.ts +24 -0
  156. package/dist/tl/writer.d.ts.map +1 -0
  157. package/dist/tl/writer.js +95 -0
  158. package/dist/tl/writer.js.map +1 -0
  159. package/dist/transport/connection-registry.d.ts +31 -0
  160. package/dist/transport/connection-registry.d.ts.map +1 -0
  161. package/dist/transport/connection-registry.js +84 -0
  162. package/dist/transport/connection-registry.js.map +1 -0
  163. package/dist/transport/connection.d.ts +62 -0
  164. package/dist/transport/connection.d.ts.map +1 -0
  165. package/dist/transport/connection.js +77 -0
  166. package/dist/transport/connection.js.map +1 -0
  167. package/dist/transport/framing.d.ts +39 -0
  168. package/dist/transport/framing.d.ts.map +1 -0
  169. package/dist/transport/framing.js +212 -0
  170. package/dist/transport/framing.js.map +1 -0
  171. package/dist/transport/proxy-protocol.d.ts +23 -0
  172. package/dist/transport/proxy-protocol.d.ts.map +1 -0
  173. package/dist/transport/proxy-protocol.js +108 -0
  174. package/dist/transport/proxy-protocol.js.map +1 -0
  175. package/dist/transport/server-common.d.ts +27 -0
  176. package/dist/transport/server-common.d.ts.map +1 -0
  177. package/dist/transport/server-common.js +27 -0
  178. package/dist/transport/server-common.js.map +1 -0
  179. package/dist/transport/tcp-server.d.ts +26 -0
  180. package/dist/transport/tcp-server.d.ts.map +1 -0
  181. package/dist/transport/tcp-server.js +91 -0
  182. package/dist/transport/tcp-server.js.map +1 -0
  183. package/dist/transport/ws-server.d.ts +19 -0
  184. package/dist/transport/ws-server.d.ts.map +1 -0
  185. package/dist/transport/ws-server.js +78 -0
  186. package/dist/transport/ws-server.js.map +1 -0
  187. package/dist/update-publisher.d.ts +34 -0
  188. package/dist/update-publisher.d.ts.map +1 -0
  189. package/dist/update-publisher.js +29 -0
  190. package/dist/update-publisher.js.map +1 -0
  191. package/dist/updates/mongo-update-log.d.ts +13 -0
  192. package/dist/updates/mongo-update-log.d.ts.map +1 -0
  193. package/dist/updates/mongo-update-log.js +39 -0
  194. package/dist/updates/mongo-update-log.js.map +1 -0
  195. package/dist/updates/presence-binder.d.ts +29 -0
  196. package/dist/updates/presence-binder.d.ts.map +1 -0
  197. package/dist/updates/presence-binder.js +36 -0
  198. package/dist/updates/presence-binder.js.map +1 -0
  199. package/dist/updates/presence.d.ts +31 -0
  200. package/dist/updates/presence.d.ts.map +1 -0
  201. package/dist/updates/presence.js +44 -0
  202. package/dist/updates/presence.js.map +1 -0
  203. package/dist/updates/push.d.ts +25 -0
  204. package/dist/updates/push.d.ts.map +1 -0
  205. package/dist/updates/push.js +72 -0
  206. package/dist/updates/push.js.map +1 -0
  207. package/dist/updates/redis-bus.d.ts +45 -0
  208. package/dist/updates/redis-bus.d.ts.map +1 -0
  209. package/dist/updates/redis-bus.js +59 -0
  210. package/dist/updates/redis-bus.js.map +1 -0
  211. package/dist/updates/redis-presence.d.ts +43 -0
  212. package/dist/updates/redis-presence.d.ts.map +1 -0
  213. package/dist/updates/redis-presence.js +65 -0
  214. package/dist/updates/redis-presence.js.map +1 -0
  215. package/dist/updates/render.d.ts +16 -0
  216. package/dist/updates/render.d.ts.map +1 -0
  217. package/dist/updates/render.js +46 -0
  218. package/dist/updates/render.js.map +1 -0
  219. package/dist/updates/router.d.ts +27 -0
  220. package/dist/updates/router.d.ts.map +1 -0
  221. package/dist/updates/router.js +36 -0
  222. package/dist/updates/router.js.map +1 -0
  223. package/dist/updates/types.d.ts +23 -0
  224. package/dist/updates/types.d.ts.map +1 -0
  225. package/dist/updates/types.js +2 -0
  226. package/dist/updates/types.js.map +1 -0
  227. package/dist/updates/update-bus.d.ts +29 -0
  228. package/dist/updates/update-bus.d.ts.map +1 -0
  229. package/dist/updates/update-bus.js +24 -0
  230. package/dist/updates/update-bus.js.map +1 -0
  231. package/dist/util/bytes.d.ts +12 -0
  232. package/dist/util/bytes.d.ts.map +1 -0
  233. package/dist/util/bytes.js +46 -0
  234. package/dist/util/bytes.js.map +1 -0
  235. package/package.json +84 -0
  236. package/src/auth/handshake.ts +262 -0
  237. package/src/auth/nonce-store.ts +39 -0
  238. package/src/bootstrap.ts +114 -0
  239. package/src/config.ts +103 -0
  240. package/src/core/context.ts +94 -0
  241. package/src/core/errors.ts +52 -0
  242. package/src/core/index.ts +4 -0
  243. package/src/core/rpc.ts +165 -0
  244. package/src/core/updates.ts +69 -0
  245. package/src/create-server.ts +181 -0
  246. package/src/crypto/aes-ige.ts +57 -0
  247. package/src/crypto/dh.ts +101 -0
  248. package/src/crypto/hashes.ts +17 -0
  249. package/src/crypto/msg-key.ts +29 -0
  250. package/src/crypto/rsa.ts +70 -0
  251. package/src/dispatch/dispatcher.ts +586 -0
  252. package/src/dispatch/forwarders/in-process.ts +14 -0
  253. package/src/dispatch/forwarders/print.ts +22 -0
  254. package/src/dispatch/rpc-forwarder.ts +15 -0
  255. package/src/dispatch/types.ts +60 -0
  256. package/src/gateway.ts +214 -0
  257. package/src/index.ts +53 -0
  258. package/src/lib.ts +24 -0
  259. package/src/server/message-pipeline.ts +256 -0
  260. package/src/session/inbound-tracker.ts +221 -0
  261. package/src/session/message-id.ts +43 -0
  262. package/src/session/salts.ts +162 -0
  263. package/src/session/session-manager.ts +66 -0
  264. package/src/storage/index.ts +26 -0
  265. package/src/storage/memory.ts +101 -0
  266. package/src/storage/mongo.ts +215 -0
  267. package/src/storage/types.ts +92 -0
  268. package/src/testkit.ts +19 -0
  269. package/src/tl/codec.ts +292 -0
  270. package/src/tl/layered-registry.ts +132 -0
  271. package/src/tl/protocol.ts +146 -0
  272. package/src/tl/reader.ts +99 -0
  273. package/src/tl/registry.ts +78 -0
  274. package/src/tl/writer.ts +104 -0
  275. package/src/transport/connection-registry.ts +91 -0
  276. package/src/transport/connection.ts +113 -0
  277. package/src/transport/framing.ts +223 -0
  278. package/src/transport/proxy-protocol.ts +109 -0
  279. package/src/transport/server-common.ts +49 -0
  280. package/src/transport/tcp-server.ts +102 -0
  281. package/src/transport/ws-server.ts +94 -0
  282. package/src/update-publisher.ts +47 -0
  283. package/src/updates/mongo-update-log.ts +49 -0
  284. package/src/updates/presence-binder.ts +51 -0
  285. package/src/updates/presence.ts +61 -0
  286. package/src/updates/push.ts +90 -0
  287. package/src/updates/redis-bus.ts +86 -0
  288. package/src/updates/redis-presence.ts +87 -0
  289. package/src/updates/render.ts +53 -0
  290. package/src/updates/router.ts +52 -0
  291. package/src/updates/types.ts +24 -0
  292. package/src/updates/update-bus.ts +49 -0
  293. package/src/util/bytes.ts +49 -0
@@ -0,0 +1,70 @@
1
+ import {
2
+ constants,
3
+ createPrivateKey,
4
+ createPublicKey,
5
+ generateKeyPairSync,
6
+ privateDecrypt,
7
+ publicEncrypt,
8
+ type KeyObject,
9
+ } from 'node:crypto'
10
+ import { readFileSync } from 'node:fs'
11
+ import { TlWriter } from '../tl/writer.js'
12
+ import { sha1 } from './hashes.js'
13
+ import { toBigIntLE } from '../util/bytes.js'
14
+
15
+ export interface RsaKeyPair {
16
+ privateKey: KeyObject
17
+ publicKey: KeyObject
18
+ /** Lower-64-bit key fingerprint advertised to clients in resPQ. */
19
+ fingerprint: bigint
20
+ fingerprintBuf: Buffer
21
+ }
22
+
23
+ /**
24
+ * Telegram RSA fingerprint: take the bare TL serialization of the public key
25
+ * (modulus `n` and exponent `e` as `bytes`), SHA1 it, and use the last 8 bytes.
26
+ */
27
+ export function computeFingerprint(publicKey: KeyObject): { fingerprint: bigint; buf: Buffer } {
28
+ const jwk = publicKey.export({ format: 'jwk' }) as { n: string; e: string }
29
+ const n = Buffer.from(jwk.n, 'base64url')
30
+ const e = Buffer.from(jwk.e, 'base64url')
31
+ const w = new TlWriter()
32
+ w.writeBytes(n)
33
+ w.writeBytes(e)
34
+ const digest = sha1(w.toBuffer())
35
+ const buf = digest.subarray(12, 20)
36
+ return { fingerprint: toBigIntLE(buf), buf: Buffer.from(buf) }
37
+ }
38
+
39
+ /**
40
+ * Loads the gateway's RSA key pair. With `pemPath` set, the operator-provided
41
+ * production key is used (its fingerprint must match what clients have pinned).
42
+ * Without it, an ephemeral 2048-bit key is generated for local dev/testing.
43
+ */
44
+ export function loadRsaKeyPair(pemPath?: string): RsaKeyPair {
45
+ let privateKey: KeyObject
46
+ let publicKey: KeyObject
47
+
48
+ if (pemPath) {
49
+ const pem = readFileSync(pemPath, 'utf-8')
50
+ privateKey = createPrivateKey(pem)
51
+ publicKey = createPublicKey(privateKey)
52
+ } else {
53
+ const pair = generateKeyPairSync('rsa', { modulusLength: 2048 })
54
+ privateKey = pair.privateKey
55
+ publicKey = pair.publicKey
56
+ }
57
+
58
+ const { fingerprint, buf } = computeFingerprint(publicKey)
59
+ return { privateKey, publicKey, fingerprint, fingerprintBuf: buf }
60
+ }
61
+
62
+ /** RSA decrypt of the client's `encrypted_data` with no padding (raw 256 bytes). */
63
+ export function rsaDecryptNoPadding(privateKey: KeyObject, encryptedData: Buffer): Buffer {
64
+ return privateDecrypt({ key: privateKey, padding: constants.RSA_NO_PADDING }, encryptedData)
65
+ }
66
+
67
+ /** RSA encrypt with no padding — used only by the test client. */
68
+ export function rsaEncryptNoPadding(publicKey: KeyObject, data: Buffer): Buffer {
69
+ return publicEncrypt({ key: publicKey, padding: constants.RSA_NO_PADDING }, data)
70
+ }
@@ -0,0 +1,586 @@
1
+ import { gunzipSync } from 'node:zlib'
2
+ import { TlReader } from '../tl/reader.js'
3
+ import type { TlCodec } from '../tl/codec.js'
4
+ import type { TlRegistry } from '../tl/registry.js'
5
+ import { fromJson, toJson, MigrationRegistry, type JsonValue, type TlObject, type TlValue } from '@mt-tl/tl'
6
+ import type { Connection } from '../transport/connection.js'
7
+ import type { Storage } from '../storage/index.js'
8
+ import type { SaltService } from '../session/salts.js'
9
+ import { replyToBadAccept, type MessageContext, type Responder } from './types.js'
10
+ import { messageClass } from '../session/inbound-tracker.js'
11
+ import type { RpcForwarder, RpcContext, SessionEffect } from './rpc-forwarder.js'
12
+ import type { PresenceBinder } from '../updates/presence-binder.js'
13
+ import { NoopPresenceBinder } from '../updates/presence-binder.js'
14
+ import type { UpdateLog } from '../core/updates.js'
15
+ import { noopLogger, type Logger } from '@mt-tl/tl'
16
+
17
+ const ID_GZIP_PACKED = 0x3072cfa1
18
+ const ID_MSG_CONTAINER = 0x73f1f8dc
19
+ /** Spec cap: a container carries at most 1024 messages. */
20
+ const CONTAINER_MAX_MESSAGES = 1024
21
+ /** Managed `updates.getDifference`: max updates per response before slicing. */
22
+ const DIFF_SLICE = 100
23
+ /** Managed `updates.getDifference`: gap beyond which we force a full resync (differenceTooLong). */
24
+ const DIFF_TOO_LONG = 5000
25
+ /** Methods the engine answers itself when `updates.managed` (else forwarded to the app). */
26
+ const MANAGED_UPDATES = new Set(['updates.getState', 'updates.getDifference'])
27
+
28
+ /** Protocol/service predicates handled inside the gateway (never forwarded). */
29
+ const SERVICE = new Set([
30
+ 'ping',
31
+ 'ping_delay_disconnect',
32
+ 'msgs_ack',
33
+ 'msgs_state_req',
34
+ 'msgs_all_info',
35
+ 'msg_resend_req',
36
+ 'destroy_session',
37
+ 'destroy_auth_key',
38
+ 'get_future_salts',
39
+ 'rpc_drop_answer',
40
+ 'http_wait',
41
+ ])
42
+
43
+ const WRAPPERS = new Set([
44
+ 'invokeWithLayer',
45
+ 'initConnection',
46
+ 'invokeWithoutUpdates',
47
+ 'invokeAfterMsg',
48
+ 'invokeAfterMsgs',
49
+ 'invokeWithMessagesRange',
50
+ 'invokeWithTakeout',
51
+ ])
52
+
53
+ export interface DispatcherDeps {
54
+ codec: TlCodec
55
+ registry: TlRegistry
56
+ storage: Storage
57
+ saltService: SaltService
58
+ responder: Responder
59
+ forwarder: RpcForwarder
60
+ /** Presence/registry binder; defaults to a no-op (push disabled). */
61
+ binder?: PresenceBinder
62
+ /** Per-predicate migration ladders; defaults to empty (identity). */
63
+ migrations?: MigrationRegistry
64
+ /** Observability sink; defaults to a no-op logger. */
65
+ logger?: Logger
66
+ /** Disable inbound seqno validation for container-inner messages (mirrors the
67
+ * pipeline's `disableSeqNoCheck`); default enforced. */
68
+ disableSeqNoCheck?: boolean
69
+ /** Durable pts log for protocol-managed `updates.getState`/`getDifference`. */
70
+ updateLog?: UpdateLog
71
+ /** When true (+ `updateLog`), answer getState/getDifference in-engine. */
72
+ managedUpdates?: boolean
73
+ /** Whitelist of accepted `initConnection.api_id`s; omitted → any id is accepted. */
74
+ allowedApiIds?: Iterable<number>
75
+ }
76
+
77
+ export class Dispatcher {
78
+ private readonly binder: PresenceBinder
79
+ private readonly migrations: MigrationRegistry
80
+ private readonly logger: Logger
81
+ private readonly checkSeqNo: boolean
82
+ private readonly managedUpdates: boolean
83
+ /** Built once from `deps.allowedApiIds`; undefined = whitelist disabled. */
84
+ private readonly allowedApiIds?: ReadonlySet<number>
85
+
86
+ constructor(private readonly deps: DispatcherDeps) {
87
+ this.binder = deps.binder ?? new NoopPresenceBinder()
88
+ this.migrations = deps.migrations ?? new MigrationRegistry()
89
+ this.logger = deps.logger ?? noopLogger
90
+ this.checkSeqNo = !deps.disableSeqNoCheck
91
+ this.managedUpdates = !!deps.managedUpdates && !!deps.updateLog
92
+ this.allowedApiIds = deps.allowedApiIds ? new Set(deps.allowedApiIds) : undefined
93
+ }
94
+
95
+ /** Entry point: a raw message body (after the encrypted envelope). */
96
+ async dispatchPayload(payload: Buffer, ctx: MessageContext, conn: Connection): Promise<void> {
97
+ if (payload.length < 4) return
98
+ const id = payload.readUInt32LE(0)
99
+
100
+ if (id === ID_GZIP_PACKED) {
101
+ const r = new TlReader(payload)
102
+ r.readUInt32()
103
+ const inflated = gunzipSync(r.readBytes())
104
+ return this.dispatchPayload(inflated, ctx, conn)
105
+ }
106
+
107
+ if (id === ID_MSG_CONTAINER) {
108
+ // Parse the whole container before dispatching anything: a malformed one
109
+ // (bad count / inner length overflow) is rejected atomically with
110
+ // bad_msg_notification code 64 ("invalid container"), nothing processed.
111
+ let inners: Array<{ msgId: bigint; seqNo: number; inner: Buffer }>
112
+ try {
113
+ inners = parseContainer(payload)
114
+ } catch {
115
+ this.logger.warn('container.invalid', {
116
+ authKeyId: conn.ctx.authKeyId,
117
+ layer: conn.ctx.apiLayer,
118
+ bytes: payload.length,
119
+ })
120
+ this.deps.responder.sendEncrypted(
121
+ conn,
122
+ {
123
+ _: 'bad_msg_notification',
124
+ bad_msg_id: ctx.msgId,
125
+ bad_msg_seqno: ctx.seqNo,
126
+ error_code: 64,
127
+ },
128
+ { contentRelated: false },
129
+ )
130
+ return
131
+ }
132
+ for (const { msgId, seqNo, inner } of inners) {
133
+ // Validate each inner message like the outer envelope, EXCEPT ordering
134
+ // (code 32): a resend container legitimately carries old seqnos. A bad
135
+ // inner gets its own bad_msg_notification / msg_detailed_info and is
136
+ // skipped; the others still run.
137
+ const check = conn.tracker.accept(msgId, seqNo, {
138
+ ...messageClass(inner),
139
+ checkSeqNo: this.checkSeqNo,
140
+ checkOrder: false,
141
+ })
142
+ if (!check.ok) {
143
+ replyToBadAccept(this.deps.responder, conn, check, msgId, seqNo)
144
+ continue
145
+ }
146
+ await this.dispatchPayload(inner, { ...ctx, msgId, seqNo }, conn)
147
+ }
148
+ return
149
+ }
150
+
151
+ let body: TlObject
152
+ try {
153
+ body = this.deps.codec.decode(payload) as TlObject
154
+ } catch {
155
+ // Unknown/undecodable type — ack-by-silence, but log so it's visible.
156
+ this.logger.warn('decode.fail', {
157
+ id: '0x' + id.toString(16).padStart(8, '0'),
158
+ authKeyId: conn.ctx.authKeyId,
159
+ sessionId: conn.ctx.sessionId,
160
+ layer: conn.ctx.apiLayer,
161
+ bytes: payload.length,
162
+ })
163
+ return
164
+ }
165
+ await this.dispatchObject(body, ctx, conn)
166
+ }
167
+
168
+ private async dispatchObject(body: TlObject, ctx: MessageContext, conn: Connection): Promise<void> {
169
+ const name = body._
170
+ this.logger.debug('msg', {
171
+ method: name,
172
+ authKeyId: conn.ctx.authKeyId,
173
+ sessionId: conn.ctx.sessionId,
174
+ layer: conn.ctx.apiLayer,
175
+ })
176
+
177
+ if (WRAPPERS.has(name)) {
178
+ if (name === 'invokeWithLayer' && typeof body.layer === 'number') {
179
+ conn.ctx.apiLayer = body.layer
180
+ if (conn.ctx.sessionId !== undefined) {
181
+ await this.deps.storage.sessions.update(conn.ctx.sessionId, { apiLayer: body.layer })
182
+ }
183
+ } else if (name === 'initConnection') {
184
+ const rejected = await this.handleInitConnection(body, ctx, conn)
185
+ if (rejected) return
186
+ } else if (name === 'invokeWithoutUpdates') {
187
+ // Client opts this connection out of server-push (PushService skips it).
188
+ conn.ctx.noUpdates = true
189
+ }
190
+ const query = body.query
191
+ if (query && typeof query === 'object' && '_' in query) {
192
+ return this.dispatchObject(query as TlObject, ctx, conn)
193
+ }
194
+ return
195
+ }
196
+
197
+ if (SERVICE.has(name)) return this.handleService(body, ctx, conn)
198
+
199
+ if (this.managedUpdates && MANAGED_UPDATES.has(name)) {
200
+ return this.handleManagedUpdate(body, ctx, conn)
201
+ }
202
+
203
+ return this.forwardBusiness(body, ctx, conn)
204
+ }
205
+
206
+ /**
207
+ * Process an `initConnection` envelope: optionally enforce the `api_id`
208
+ * whitelist, then capture the device/app fields onto the connection and
209
+ * persist them to the auth key's meta (the per-device source of truth; an
210
+ * auth key is one app install, so this is stable per key, not per session).
211
+ * Returns `true` when the connection was rejected (caller must not dispatch
212
+ * the wrapped query).
213
+ */
214
+ private async handleInitConnection(
215
+ body: TlObject,
216
+ ctx: MessageContext,
217
+ conn: Connection,
218
+ ): Promise<boolean> {
219
+ const apiId = typeof body.api_id === 'number' ? body.api_id : undefined
220
+ if (this.allowedApiIds && (apiId === undefined || !this.allowedApiIds.has(apiId))) {
221
+ this.logger.warn('initConnection.rejected', {
222
+ authKeyId: conn.ctx.authKeyId,
223
+ sessionId: conn.ctx.sessionId,
224
+ apiId,
225
+ })
226
+ this.sendRpcError(conn, ctx.msgId, 400, 'API_ID_INVALID')
227
+ return true
228
+ }
229
+ const meta = {
230
+ apiId,
231
+ deviceModel: asString(body.device_model),
232
+ systemVersion: asString(body.system_version),
233
+ appVersion: asString(body.app_version),
234
+ systemLangCode: asString(body.system_lang_code),
235
+ langCode: asString(body.lang_code),
236
+ }
237
+ conn.ctx.apiId = meta.apiId
238
+ conn.ctx.deviceModel = meta.deviceModel
239
+ conn.ctx.systemVersion = meta.systemVersion
240
+ conn.ctx.appVersion = meta.appVersion
241
+ conn.ctx.systemLangCode = meta.systemLangCode
242
+ conn.ctx.langCode = meta.langCode
243
+ if (conn.ctx.authKeyId !== undefined) {
244
+ await this.deps.storage.authKeys.updateMeta(conn.ctx.authKeyId, meta)
245
+ }
246
+ return false
247
+ }
248
+
249
+ /**
250
+ * Engine-owned `updates.getState` / `updates.getDifference` (when
251
+ * `config.updates.managed`). Common pts sequence only — qts/seq/channels are 0.
252
+ * Updates are returned in `other_updates`; the durable {@link UpdateLog}
253
+ * supplies pts. Auth-gated like a normal `auth: true` method.
254
+ */
255
+ private async handleManagedUpdate(body: TlObject, ctx: MessageContext, conn: Connection): Promise<void> {
256
+ const log = this.deps.updateLog!
257
+ const subject = conn.ctx.subject
258
+ if (subject === undefined) return this.sendRpcError(conn, ctx.msgId, 401, 'AUTH_KEY_UNREGISTERED')
259
+ const date = Math.floor(Date.now() / 1000)
260
+ const state = (pts: number): JsonValue => ({
261
+ _: 'updates.state',
262
+ pts,
263
+ qts: 0,
264
+ date,
265
+ seq: 0,
266
+ unread_count: 0,
267
+ })
268
+
269
+ if (body._ === 'updates.getState') {
270
+ return this.sendRpcResult(conn, ctx.msgId, state(await log.currentPts(subject)))
271
+ }
272
+
273
+ // updates.getDifference
274
+ const sincePts = Number(body.pts ?? 0)
275
+ const current = await log.currentPts(subject)
276
+ if (sincePts >= current) {
277
+ return this.sendRpcResult(conn, ctx.msgId, { _: 'updates.differenceEmpty', date, seq: 0 })
278
+ }
279
+ if (current - sincePts > DIFF_TOO_LONG) {
280
+ return this.sendRpcResult(conn, ctx.msgId, { _: 'updates.differenceTooLong', pts: current })
281
+ }
282
+ const all = await log.since(subject, sincePts)
283
+ const sliced = all.length > DIFF_SLICE
284
+ const page = sliced ? all.slice(0, DIFF_SLICE) : all
285
+ const lastPts = page.at(-1)?.pts ?? current
286
+ const common = {
287
+ new_messages: [],
288
+ new_encrypted_messages: [],
289
+ other_updates: page.map(u => u.update),
290
+ chats: [],
291
+ users: [],
292
+ }
293
+ return this.sendRpcResult(
294
+ conn,
295
+ ctx.msgId,
296
+ sliced
297
+ ? { _: 'updates.differenceSlice', ...common, intermediate_state: state(lastPts) }
298
+ : { _: 'updates.difference', ...common, state: state(lastPts) },
299
+ )
300
+ }
301
+
302
+ private async handleService(body: TlObject, ctx: MessageContext, conn: Connection): Promise<void> {
303
+ const { responder } = this.deps
304
+ switch (body._) {
305
+ case 'ping_delay_disconnect':
306
+ // Close the connection after `disconnect_delay`s of inactivity unless
307
+ // reset; then respond like a normal ping.
308
+ conn.armDisconnect(Number(body.disconnect_delay ?? 0))
309
+ // falls through
310
+ case 'ping':
311
+ responder.sendEncrypted(
312
+ conn,
313
+ { _: 'pong', msg_id: ctx.msgId, ping_id: body.ping_id as bigint },
314
+ { contentRelated: false },
315
+ )
316
+ return
317
+ case 'msgs_state_req':
318
+ case 'msg_resend_req': {
319
+ // We keep no sent-message store to re-send, so per spec a msg_resend_req
320
+ // is answered like a msgs_state_req: report each requested id's state.
321
+ const ids = (body.msg_ids as bigint[] | undefined) ?? []
322
+ responder.sendEncrypted(
323
+ conn,
324
+ { _: 'msgs_state_info', req_msg_id: ctx.msgId, info: conn.tracker.stateOf(ids) },
325
+ { contentRelated: false },
326
+ )
327
+ return
328
+ }
329
+ case 'destroy_auth_key': {
330
+ // Permanent key destruction (logout). Block the key so any further use
331
+ // is rejected; the response is sent before this message's key is dropped.
332
+ await this.deps.storage.authKeys.setBlocked(ctx.authKeyId, true)
333
+ responder.sendEncrypted(conn, { _: 'destroy_auth_key_ok' }, { contentRelated: false })
334
+ return
335
+ }
336
+ case 'destroy_session': {
337
+ // Tear down the stored session if it belongs to this auth key; the
338
+ // client uses this to forget another session under the same key.
339
+ const sessionId = body.session_id as bigint
340
+ const existing = await this.deps.storage.sessions.get(sessionId)
341
+ const owned = !!existing && existing.authKeyId === ctx.authKeyId
342
+ if (owned) await this.deps.storage.sessions.delete(sessionId)
343
+ responder.sendEncrypted(
344
+ conn,
345
+ { _: owned ? 'destroy_session_ok' : 'destroy_session_none', session_id: sessionId },
346
+ { contentRelated: false },
347
+ )
348
+ return
349
+ }
350
+ case 'get_future_salts': {
351
+ // Return the next `num` scheduled salts (clamped) with true windows,
352
+ // minting more if the schedule is short.
353
+ const num = Math.min(64, Math.max(1, Number(body.num ?? 1)))
354
+ const authKeyId = conn.ctx.authKeyId
355
+ const scheduled =
356
+ authKeyId !== undefined ? await this.deps.saltService.future(authKeyId, num) : []
357
+ responder.sendEncrypted(
358
+ conn,
359
+ {
360
+ _: 'future_salts',
361
+ req_msg_id: ctx.msgId,
362
+ now: Math.floor(Date.now() / 1000),
363
+ salts: scheduled.map(s => ({
364
+ _: 'future_salt',
365
+ valid_since: s.validSince,
366
+ valid_until: s.validUntil,
367
+ salt: s.salt,
368
+ })),
369
+ },
370
+ { contentRelated: false },
371
+ )
372
+ return
373
+ }
374
+ case 'rpc_drop_answer':
375
+ // Packets are processed serially per connection (see transport `pump`)
376
+ // and answers are sent immediately (no outgoing queue to drop from), so
377
+ // by the time a drop arrives its target RPC has already been answered.
378
+ // `rpc_answer_unknown` ("no memory of req_msg_id / already responded")
379
+ // is the spec-correct reply here. The RpcDropAnswer is wrapped in an
380
+ // rpc_result (and acknowledged) like any RPC response — see
381
+ // docs/internals/protocol-compliance.md.
382
+ responder.sendEncrypted(conn, {
383
+ _: 'rpc_result',
384
+ req_msg_id: ctx.msgId,
385
+ result: { _: 'rpc_answer_unknown' },
386
+ })
387
+ return
388
+ case 'msgs_ack':
389
+ // Client acknowledgments of server→client messages. We never retransmit,
390
+ // so there is no resend queue for an ack to clear — nothing to do.
391
+ return
392
+ case 'msgs_all_info':
393
+ // Voluntary status of our messages from the client; informational and
394
+ // not requiring acknowledgment — nothing to do (we don't retransmit).
395
+ return
396
+ case 'http_wait':
397
+ default:
398
+ return
399
+ }
400
+ }
401
+
402
+ private async forwardBusiness(body: TlObject, ctx: MessageContext, conn: Connection): Promise<void> {
403
+ // Identity carried on every line for this request: reqId (the client's
404
+ // msg_id) + authKeyId/sessionId/subject/layer. Matches the per-request
405
+ // `ctx.log` child the handler layer binds, so engine ⇄ handler lines join.
406
+ // device/ip are added only once known (deviceModel after initConnection) so
407
+ // they don't noise pre-init lines.
408
+ const logBase = {
409
+ reqId: ctx.msgId,
410
+ method: body._,
411
+ subject: conn.ctx.subject,
412
+ authKeyId: ctx.authKeyId,
413
+ sessionId: ctx.sessionId,
414
+ layer: conn.ctx.apiLayer,
415
+ ...(conn.ctx.deviceModel ? { deviceModel: conn.ctx.deviceModel } : {}),
416
+ ...(conn.ctx.remoteAddress ? { ip: conn.ctx.remoteAddress } : {}),
417
+ }
418
+
419
+ const def = this.deps.registry.getByName(body._)
420
+ if (!def || def.kind !== 'method') {
421
+ // Not a known business method — surface as an rpc_error.
422
+ this.logger.warn('rpc.unknown', logBase)
423
+ return this.sendRpcError(conn, ctx.msgId, 400, 'METHOD_NOT_FOUND')
424
+ }
425
+
426
+ // Normalize older-layer input up to the canonical shape before forwarding.
427
+ const canonical = this.migrations.up(body, conn.ctx.apiLayer) as TlObject
428
+ const params = paramsToJson(canonical, this.deps.registry)
429
+ // Full incoming payload at debug — "what came in" (structured in JSON mode).
430
+ this.logger.debug('rpc.params', { ...logBase, params })
431
+ const rpcCtx: RpcContext = {
432
+ sessionId: ctx.sessionId.toString(),
433
+ authKeyId: ctx.authKeyId.toString(),
434
+ subject: conn.ctx.subject,
435
+ apiLayer: conn.ctx.apiLayer,
436
+ apiId: conn.ctx.apiId,
437
+ deviceModel: conn.ctx.deviceModel,
438
+ systemVersion: conn.ctx.systemVersion,
439
+ appVersion: conn.ctx.appVersion,
440
+ langCode: conn.ctx.langCode,
441
+ ip: conn.ctx.remoteAddress,
442
+ }
443
+
444
+ const startedAt = Date.now()
445
+ let res
446
+ try {
447
+ res = await this.deps.forwarder.forward({
448
+ id: ctx.msgId.toString(),
449
+ method: body._,
450
+ params,
451
+ context: rpcCtx,
452
+ })
453
+ } catch (err) {
454
+ this.logger.error('rpc.fail', { ...logBase, ms: Date.now() - startedAt, err })
455
+ return this.sendRpcError(conn, ctx.msgId, 500, 'INTERNAL')
456
+ }
457
+ const ms = Date.now() - startedAt
458
+
459
+ if (res.effects?.length) await this.applyEffects(conn, ctx, res.effects)
460
+
461
+ if (res.error) {
462
+ this.logger.info('rpc', {
463
+ ...logBase,
464
+ ms,
465
+ status: 'error',
466
+ code: res.error.code,
467
+ error: res.error.message,
468
+ })
469
+ return this.sendRpcError(conn, ctx.msgId, res.error.code, res.error.message)
470
+ }
471
+ if (res.result !== undefined) {
472
+ this.logger.info('rpc', { ...logBase, ms, status: 'ok' })
473
+ // Full outgoing payload at debug — "what went out".
474
+ this.logger.debug('rpc.result', { ...logBase, result: res.result })
475
+ return this.sendRpcResult(conn, ctx.msgId, res.result)
476
+ }
477
+ // Neither result nor error — malformed envelope.
478
+ this.logger.error('rpc.malformed', { ...logBase, ms })
479
+ return this.sendRpcError(conn, ctx.msgId, 500, 'INTERNAL')
480
+ }
481
+
482
+ /** Apply backend-requested mutations to gateway-owned auth/session state. */
483
+ private async applyEffects(
484
+ conn: Connection,
485
+ ctx: MessageContext,
486
+ effects: SessionEffect[],
487
+ ): Promise<void> {
488
+ for (const effect of effects) {
489
+ switch (effect.type) {
490
+ case 'bindUser':
491
+ await this.deps.storage.authKeys.bindUser(ctx.authKeyId, effect.subject)
492
+ conn.ctx.subject = effect.subject
493
+ await this.patchSession(conn, { subject: effect.subject })
494
+ this.binder.bind(conn, effect.subject)
495
+ // A user logged in on this auth key (device login).
496
+ this.logger.info('user.bind', { subject: effect.subject, authKeyId: ctx.authKeyId })
497
+ break
498
+ case 'unbindUser':
499
+ await this.deps.storage.authKeys.bindUser(ctx.authKeyId, null)
500
+ conn.ctx.subject = undefined
501
+ this.binder.unbind(conn)
502
+ this.logger.info('user.unbind', { authKeyId: ctx.authKeyId })
503
+ break
504
+ case 'revokeKey':
505
+ await this.deps.storage.authKeys.setBlocked(ctx.authKeyId, true)
506
+ this.logger.info('authkey.revoke', { authKeyId: ctx.authKeyId })
507
+ break
508
+ }
509
+ }
510
+ }
511
+
512
+ private async patchSession(
513
+ conn: Connection,
514
+ patch: { subject?: string; apiLayer?: number },
515
+ ): Promise<void> {
516
+ if (conn.ctx.sessionId !== undefined) {
517
+ await this.deps.storage.sessions.update(conn.ctx.sessionId, patch)
518
+ }
519
+ }
520
+
521
+ private sendRpcResult(conn: Connection, reqMsgId: bigint, result: JsonValue): void {
522
+ // Render the canonical result down to the client's layer before encoding.
523
+ const tl = this.migrations.down(fromJson(result), conn.ctx.apiLayer)
524
+ // rpc_result.result must be a boxed object, Bool, or Vector.
525
+ if (typeof tl !== 'boolean' && !Array.isArray(tl) && !(tl && typeof tl === 'object' && '_' in tl)) {
526
+ return this.sendRpcError(conn, reqMsgId, 500, 'INVALID_RESULT')
527
+ }
528
+ this.deps.responder.sendEncrypted(conn, {
529
+ _: 'rpc_result',
530
+ req_msg_id: reqMsgId,
531
+ result: tl as TlObject | boolean,
532
+ })
533
+ }
534
+
535
+ private sendRpcError(conn: Connection, reqMsgId: bigint, code: number, message: string): void {
536
+ this.deps.responder.sendEncrypted(conn, {
537
+ _: 'rpc_result',
538
+ req_msg_id: reqMsgId,
539
+ result: { _: 'rpc_error', error_code: code, error_message: message },
540
+ })
541
+ }
542
+ }
543
+
544
+ /** Build JSON-RPC params from a decoded method: drop `_` and bitmask fields. */
545
+ function paramsToJson(body: TlObject, registry: TlRegistry): JsonValue {
546
+ const def = registry.getByName(body._)
547
+ const omit = new Set<string>(['_'])
548
+ if (def) {
549
+ for (const p of def.params) {
550
+ if (p.type.kind === 'flags') omit.add(p.name)
551
+ else if (p.type.kind === 'flag') omit.add(p.type.flagsField)
552
+ }
553
+ }
554
+ const out: Record<string, JsonValue> = {}
555
+ for (const [k, v] of Object.entries(body)) {
556
+ if (!omit.has(k)) out[k] = toJson(v as TlValue)
557
+ }
558
+ return out
559
+ }
560
+
561
+ function asString(v: unknown): string | undefined {
562
+ return typeof v === 'string' ? v : undefined
563
+ }
564
+
565
+ /**
566
+ * Parse a `msg_container` body into its inner messages, validating structure.
567
+ * Throws on a malformed container (too many messages, or an inner length that
568
+ * overruns the buffer) — the caller maps that to `bad_msg_notification` code 64.
569
+ */
570
+ function parseContainer(payload: Buffer): Array<{ msgId: bigint; seqNo: number; inner: Buffer }> {
571
+ const r = new TlReader(payload)
572
+ r.readUInt32() // container constructor id
573
+ const count = r.readUInt32()
574
+ if (count > CONTAINER_MAX_MESSAGES) {
575
+ throw new Error(`container: ${count} messages exceeds ${CONTAINER_MAX_MESSAGES}`)
576
+ }
577
+ const out: Array<{ msgId: bigint; seqNo: number; inner: Buffer }> = []
578
+ for (let i = 0; i < count; i++) {
579
+ const msgId = r.readLong()
580
+ const seqNo = r.readUInt32()
581
+ const bytes = r.readUInt32()
582
+ const inner = r.read(bytes) // throws if `bytes` overruns the buffer
583
+ out.push({ msgId, seqNo, inner })
584
+ }
585
+ return out
586
+ }
@@ -0,0 +1,14 @@
1
+ import type { RpcForwarder, RpcRequest, RpcResponse } from '../rpc-forwarder.js'
2
+
3
+ /**
4
+ * Forwarder that calls a handler in the same process — for co-locating the
5
+ * gateway and a worker (no broker), and for end-to-end tests. The handler is
6
+ * typically the worker's `dispatchRpc`.
7
+ */
8
+ export class InProcessForwarder implements RpcForwarder {
9
+ constructor(private readonly handler: (req: RpcRequest) => Promise<RpcResponse>) {}
10
+
11
+ forward(req: RpcRequest): Promise<RpcResponse> {
12
+ return this.handler(req)
13
+ }
14
+ }