@openclaw/crabline 0.1.10 → 0.1.12

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 (197) hide show
  1. package/README.md +99 -12
  2. package/dist/src/bin/crabline.js +3 -1
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +5 -1
  5. package/dist/src/cli/program.js +264 -50
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +39 -3
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +20 -0
  10. package/dist/src/config/schema.js +163 -35
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.d.ts +1 -0
  13. package/dist/src/core/errors.js +6 -3
  14. package/dist/src/core/errors.js.map +1 -1
  15. package/dist/src/core/http-path.d.ts +1 -0
  16. package/dist/src/core/http-path.js +14 -0
  17. package/dist/src/core/http-path.js.map +1 -0
  18. package/dist/src/core/matcher.d.ts +3 -1
  19. package/dist/src/core/matcher.js +58 -2
  20. package/dist/src/core/matcher.js.map +1 -1
  21. package/dist/src/core/nonces.d.ts +2 -0
  22. package/dist/src/core/nonces.js +11 -1
  23. package/dist/src/core/nonces.js.map +1 -1
  24. package/dist/src/core/reporters.d.ts +5 -0
  25. package/dist/src/core/reporters.js +26 -2
  26. package/dist/src/core/reporters.js.map +1 -1
  27. package/dist/src/core/run.d.ts +3 -1
  28. package/dist/src/core/run.js +178 -66
  29. package/dist/src/core/run.js.map +1 -1
  30. package/dist/src/matrix-ids.d.ts +5 -0
  31. package/dist/src/matrix-ids.js +83 -0
  32. package/dist/src/matrix-ids.js.map +1 -0
  33. package/dist/src/openclaw/artifact-generation.d.ts +12 -2
  34. package/dist/src/openclaw/artifact-generation.js +436 -15
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  36. package/dist/src/openclaw/bridges/matrix.js +21 -4
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +13 -8
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
  41. package/dist/src/openclaw/bridges/probe-response.js +5 -0
  42. package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
  43. package/dist/src/openclaw/bridges/signal.js +15 -6
  44. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  45. package/dist/src/openclaw/bridges/slack.js +116 -23
  46. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  47. package/dist/src/openclaw/bridges/telegram.js +45 -19
  48. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  49. package/dist/src/openclaw/bridges/whatsapp.js +32 -16
  50. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  51. package/dist/src/openclaw/bridges/zalo.js +8 -3
  52. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  53. package/dist/src/openclaw/private-file.d.ts +40 -3
  54. package/dist/src/openclaw/private-file.js +2070 -95
  55. package/dist/src/openclaw/private-file.js.map +1 -1
  56. package/dist/src/openclaw/shared.d.ts +1 -0
  57. package/dist/src/openclaw/shared.js +48 -13
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +9 -0
  60. package/dist/src/openclaw/smoke-lock.js +696 -187
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -1
  62. package/dist/src/openclaw.js +352 -29
  63. package/dist/src/openclaw.js.map +1 -1
  64. package/dist/src/platform/process-owned-lock.d.ts +11 -0
  65. package/dist/src/platform/process-owned-lock.js +1698 -0
  66. package/dist/src/platform/process-owned-lock.js.map +1 -0
  67. package/dist/src/platform/recorder-directory.d.ts +1 -0
  68. package/dist/src/platform/recorder-directory.js +14 -0
  69. package/dist/src/platform/recorder-directory.js.map +1 -0
  70. package/dist/src/platform/windows-acl.d.ts +18 -0
  71. package/dist/src/platform/windows-acl.js +1367 -0
  72. package/dist/src/platform/windows-acl.js.map +1 -0
  73. package/dist/src/platform/windows-lock-root.d.ts +13 -0
  74. package/dist/src/platform/windows-lock-root.js +98 -0
  75. package/dist/src/platform/windows-lock-root.js.map +1 -0
  76. package/dist/src/providers/builtin/discord.d.ts +4 -0
  77. package/dist/src/providers/builtin/discord.js +52 -9
  78. package/dist/src/providers/builtin/discord.js.map +1 -1
  79. package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
  80. package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
  81. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
  82. package/dist/src/providers/builtin/feishu.d.ts +1 -0
  83. package/dist/src/providers/builtin/feishu.js +96 -26
  84. package/dist/src/providers/builtin/feishu.js.map +1 -1
  85. package/dist/src/providers/builtin/googlechat.d.ts +1 -0
  86. package/dist/src/providers/builtin/googlechat.js +95 -26
  87. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  88. package/dist/src/providers/builtin/imessage.js +60 -17
  89. package/dist/src/providers/builtin/imessage.js.map +1 -1
  90. package/dist/src/providers/builtin/loopback.js +88 -35
  91. package/dist/src/providers/builtin/loopback.js.map +1 -1
  92. package/dist/src/providers/builtin/matrix.d.ts +2 -2
  93. package/dist/src/providers/builtin/matrix.js +78 -21
  94. package/dist/src/providers/builtin/matrix.js.map +1 -1
  95. package/dist/src/providers/builtin/mattermost.d.ts +11 -7
  96. package/dist/src/providers/builtin/mattermost.js +138 -24
  97. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  98. package/dist/src/providers/builtin/msteams.d.ts +1 -0
  99. package/dist/src/providers/builtin/msteams.js +118 -38
  100. package/dist/src/providers/builtin/msteams.js.map +1 -1
  101. package/dist/src/providers/builtin/native-local-mock.js +4 -4
  102. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  103. package/dist/src/providers/builtin/script.js +1057 -146
  104. package/dist/src/providers/builtin/script.js.map +1 -1
  105. package/dist/src/providers/builtin/slack.d.ts +8 -1
  106. package/dist/src/providers/builtin/slack.js +327 -24
  107. package/dist/src/providers/builtin/slack.js.map +1 -1
  108. package/dist/src/providers/builtin/telegram.d.ts +1 -1
  109. package/dist/src/providers/builtin/telegram.js +55 -14
  110. package/dist/src/providers/builtin/telegram.js.map +1 -1
  111. package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
  112. package/dist/src/providers/builtin/whatsapp.js +157 -29
  113. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  114. package/dist/src/providers/builtin/zalo.d.ts +5 -2
  115. package/dist/src/providers/builtin/zalo.js +31 -7
  116. package/dist/src/providers/builtin/zalo.js.map +1 -1
  117. package/dist/src/providers/catalog.d.ts +1 -1
  118. package/dist/src/providers/catalog.js +1 -1
  119. package/dist/src/providers/catalog.js.map +1 -1
  120. package/dist/src/providers/local-mock.d.ts +4 -0
  121. package/dist/src/providers/local-mock.js +162 -35
  122. package/dist/src/providers/local-mock.js.map +1 -1
  123. package/dist/src/providers/native-ids.d.ts +2 -0
  124. package/dist/src/providers/native-ids.js +3 -0
  125. package/dist/src/providers/native-ids.js.map +1 -1
  126. package/dist/src/providers/recorder.d.ts +14 -3
  127. package/dist/src/providers/recorder.js +532 -42
  128. package/dist/src/providers/recorder.js.map +1 -1
  129. package/dist/src/providers/registry.d.ts +1 -0
  130. package/dist/src/providers/registry.js +65 -20
  131. package/dist/src/providers/registry.js.map +1 -1
  132. package/dist/src/providers/signed-jwt.d.ts +6 -1
  133. package/dist/src/providers/signed-jwt.js +241 -50
  134. package/dist/src/providers/signed-jwt.js.map +1 -1
  135. package/dist/src/providers/slack-ids.d.ts +1 -0
  136. package/dist/src/providers/slack-ids.js +5 -0
  137. package/dist/src/providers/slack-ids.js.map +1 -1
  138. package/dist/src/providers/target-normalizers.d.ts +9 -1
  139. package/dist/src/providers/target-normalizers.js +177 -58
  140. package/dist/src/providers/target-normalizers.js.map +1 -1
  141. package/dist/src/providers/webhook-server.d.ts +3 -0
  142. package/dist/src/providers/webhook-server.js +268 -47
  143. package/dist/src/providers/webhook-server.js.map +1 -1
  144. package/dist/src/servers/http.d.ts +14 -2
  145. package/dist/src/servers/http.js +229 -58
  146. package/dist/src/servers/http.js.map +1 -1
  147. package/dist/src/servers/matrix.d.ts +3 -0
  148. package/dist/src/servers/matrix.js +268 -110
  149. package/dist/src/servers/matrix.js.map +1 -1
  150. package/dist/src/servers/mattermost.d.ts +5 -0
  151. package/dist/src/servers/mattermost.js +389 -91
  152. package/dist/src/servers/mattermost.js.map +1 -1
  153. package/dist/src/servers/recorder.d.ts +11 -0
  154. package/dist/src/servers/recorder.js +1241 -22
  155. package/dist/src/servers/recorder.js.map +1 -1
  156. package/dist/src/servers/signal.d.ts +1 -0
  157. package/dist/src/servers/signal.js +279 -44
  158. package/dist/src/servers/signal.js.map +1 -1
  159. package/dist/src/servers/slack.d.ts +15 -0
  160. package/dist/src/servers/slack.js +259 -51
  161. package/dist/src/servers/slack.js.map +1 -1
  162. package/dist/src/servers/telegram-identity.d.ts +6 -0
  163. package/dist/src/servers/telegram-identity.js +27 -0
  164. package/dist/src/servers/telegram-identity.js.map +1 -0
  165. package/dist/src/servers/telegram.d.ts +17 -6
  166. package/dist/src/servers/telegram.js +1193 -118
  167. package/dist/src/servers/telegram.js.map +1 -1
  168. package/dist/src/servers/webhook-target.d.ts +12 -4
  169. package/dist/src/servers/webhook-target.js +226 -28
  170. package/dist/src/servers/webhook-target.js.map +1 -1
  171. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
  172. package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
  173. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  174. package/dist/src/servers/whatsapp-jid.d.ts +1 -0
  175. package/dist/src/servers/whatsapp-jid.js +9 -5
  176. package/dist/src/servers/whatsapp-jid.js.map +1 -1
  177. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
  178. package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
  179. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
  181. package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
  182. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  183. package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
  184. package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
  185. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  186. package/dist/src/servers/whatsapp-wire/signal.js +3 -3
  187. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  188. package/dist/src/servers/whatsapp.d.ts +7 -0
  189. package/dist/src/servers/whatsapp.js +149 -24
  190. package/dist/src/servers/whatsapp.js.map +1 -1
  191. package/dist/src/servers/zalo.d.ts +1 -0
  192. package/dist/src/servers/zalo.js +193 -82
  193. package/dist/src/servers/zalo.js.map +1 -1
  194. package/docs/channel-setup.md +185 -43
  195. package/fixtures/examples/crabline.example.yaml +3 -2
  196. package/package.json +18 -10
  197. package/fixtures/examples/openclaw-bridge.yaml +0 -125
@@ -1,12 +1,15 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { isIP } from "node:net";
3
4
  import path from "node:path";
4
- import { adminAuthError, closeServer, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, jsonResponse, parseUnknownRequestBody, queryRecord, readInteger, readTrimmedString, RequestBodyTooLargeError, writeResponse, } from "./http.js";
5
- import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
5
+ import { adminAuthError, closeServer, drainRequestBody, formatUrlHost, hasAdminToken, InvalidJsonBodyError, isJsonObject, jsonResponse, parseUnknownRequestBody, queryRecord, readInteger, readTrimmedString, RequestBodyTooLargeError, writeResponse, } from "./http.js";
6
+ import { recordCommittedServerEvent, recordServerEvent, ServerRecorderCommittedError, } from "./recorder.js";
6
7
  import { resolveMaxPendingInboundEvents } from "./pending-events.js";
7
8
  const SIGNAL_CLI_SSE_KEEPALIVE_MS = 15_000;
8
9
  const DEFAULT_MAX_SIGNAL_SSE_CLIENTS = 32;
9
10
  const MAX_SIGNAL_SSE_BUFFER_BYTES = 2 * 1024 * 1024;
11
+ const SIGNAL_PHONE_NUMBER_RE = /^\+[1-9]\d{2,14}$/u;
12
+ const SIGNAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
10
13
  async function appendEvent(state, event, committed = false) {
11
14
  const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
12
15
  await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
@@ -25,12 +28,18 @@ function removeSignalClient(state, client, destroy) {
25
28
  const buffer = state.clientBuffers.get(client);
26
29
  state.clients.delete(client);
27
30
  state.clientBuffers.delete(client);
31
+ const bufferedEvents = new Set([
32
+ ...state.pendingEvents,
33
+ ...[...state.clientBuffers.values()].flatMap((entry) => [...entry.inFlight, ...entry.events]),
34
+ ...(buffer ? [...buffer.inFlight, ...buffer.events] : []),
35
+ ]);
36
+ for (const event of bufferedEvents) {
37
+ event.recipients?.delete(client);
38
+ }
28
39
  if (buffer) {
29
- const undeliveredEvents = [...buffer.inFlight, ...buffer.events].filter((event) => {
30
- event.recipients?.delete(client);
31
- return event.sequence !== undefined && event.recipients?.size === 0;
32
- });
33
- const restoredEvents = undeliveredEvents.filter((event) => !state.pendingEvents.includes(event));
40
+ const restoredEvents = [...new Set([...buffer.inFlight, ...buffer.events])].filter((event) => event.sequence !== undefined &&
41
+ event.recipients?.size === 0 &&
42
+ !isSignalEventBuffered(state, event));
34
43
  if (restoredEvents.length > 0) {
35
44
  state.pendingEvents.push(...restoredEvents);
36
45
  state.pendingEvents.sort((left, right) => (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? 0));
@@ -223,14 +232,67 @@ function emitSignalEvent(state, payload) {
223
232
  }
224
233
  return delivered || queueSignalEvent(state, event);
225
234
  }
235
+ function normalizeSignalPhoneNumber(value) {
236
+ const number = readTrimmedString(value)?.replace(/[\s().-]/gu, "");
237
+ return number && SIGNAL_PHONE_NUMBER_RE.test(number) ? number : undefined;
238
+ }
239
+ function isSignalDirectRecipient(value) {
240
+ const recipient = readSignalRpcString(value);
241
+ if (!recipient) {
242
+ return false;
243
+ }
244
+ if (SIGNAL_UUID_RE.test(recipient)) {
245
+ return true;
246
+ }
247
+ // signal-cli treats recognized prefixes as terminal instead of retrying them as phone numbers.
248
+ if (recipient.startsWith("PNI:")) {
249
+ return SIGNAL_UUID_RE.test(recipient.slice(4));
250
+ }
251
+ if (recipient.startsWith("u:")) {
252
+ return recipient.length > 2;
253
+ }
254
+ return SIGNAL_PHONE_NUMBER_RE.test(recipient);
255
+ }
256
+ function nextSignalTimestamp(state) {
257
+ return Math.max(state.nextTimestamp, (state.lastCommittedTimestamp ?? -1) + 1);
258
+ }
259
+ function commitSignalTimestamp(state, timestamp) {
260
+ state.lastCommittedTimestamp = timestamp;
261
+ state.nextTimestamp = Math.max(state.nextTimestamp, timestamp + 1);
262
+ }
226
263
  async function handleAdminInbound(params) {
227
264
  const text = typeof params.body.text === "string" ? params.body.text : undefined;
228
- const sourceNumber = readTrimmedString(params.body.sourceNumber ?? params.body.senderId);
229
- const sourceUuid = readTrimmedString(params.body.sourceUuid);
265
+ const sourceNumberValue = params.body.sourceNumber ?? params.body.senderId;
266
+ const sourceNumber = normalizeSignalPhoneNumber(sourceNumberValue);
267
+ const sourceUuidValue = readTrimmedString(params.body.sourceUuid);
268
+ const sourceUuid = sourceUuidValue && SIGNAL_UUID_RE.test(sourceUuidValue)
269
+ ? sourceUuidValue.toLowerCase()
270
+ : undefined;
271
+ if (sourceNumberValue !== undefined && sourceNumber === undefined) {
272
+ return jsonResponse({ error: "sourceNumber must be an E.164 telephone number", ok: false }, 400);
273
+ }
274
+ if (params.body.sourceUuid !== undefined && sourceUuid === undefined) {
275
+ return jsonResponse({ error: "sourceUuid must be a UUID", ok: false }, 400);
276
+ }
230
277
  if (!text || text.trim().length === 0 || (!sourceNumber && !sourceUuid)) {
231
278
  return jsonResponse({ error: "text and at least one source identity are required", ok: false }, 400);
232
279
  }
233
- const timestamp = readInteger(params.body.timestamp) ?? params.state.nextTimestamp++;
280
+ const suppliedTimestamp = params.body.timestamp === undefined ? undefined : readInteger(params.body.timestamp);
281
+ if (params.body.timestamp !== undefined &&
282
+ (suppliedTimestamp === undefined ||
283
+ suppliedTimestamp < 0 ||
284
+ suppliedTimestamp >= Number.MAX_SAFE_INTEGER)) {
285
+ return jsonResponse({ error: "timestamp must be a non-negative safe integer with room to advance", ok: false }, 400);
286
+ }
287
+ if (suppliedTimestamp !== undefined &&
288
+ params.state.lastCommittedTimestamp !== undefined &&
289
+ suppliedTimestamp <= params.state.lastCommittedTimestamp) {
290
+ return jsonResponse({ error: "timestamp must be greater than the last committed timestamp", ok: false }, 409);
291
+ }
292
+ const timestamp = suppliedTimestamp ?? nextSignalTimestamp(params.state);
293
+ if (timestamp >= Number.MAX_SAFE_INTEGER) {
294
+ return jsonResponse({ error: "timestamp capacity exhausted", ok: false }, 503);
295
+ }
234
296
  const groupId = readTrimmedString(params.body.groupId);
235
297
  const payload = {
236
298
  envelope: {
@@ -251,6 +313,7 @@ async function handleAdminInbound(params) {
251
313
  ok: false,
252
314
  }, 503);
253
315
  }
316
+ commitSignalTimestamp(params.state, timestamp);
254
317
  return jsonResponse({ event: payload, ok: true });
255
318
  }
256
319
  async function handleRpc(params) {
@@ -259,7 +322,9 @@ async function handleRpc(params) {
259
322
  const validId = !hasId ||
260
323
  id === null ||
261
324
  typeof id === "string" ||
262
- (typeof id === "number" && Number.isFinite(id));
325
+ (typeof id === "number" &&
326
+ Number.isFinite(id) &&
327
+ (!Number.isInteger(id) || Number.isSafeInteger(id)));
263
328
  const method = params.body.method;
264
329
  if (typeof method !== "string" || !validId) {
265
330
  return {
@@ -301,16 +366,36 @@ async function handleRpc(params) {
301
366
  record: false,
302
367
  response: notification
303
368
  ? new Response(null, { status: 204 })
304
- : rpcError(-32602, "Invalid params", id),
369
+ : rpcError(-32602, "Invalid params", id, 200),
370
+ };
371
+ }
372
+ const account = params.body.params.account;
373
+ if (typeof account === "string" && account !== params.state.account) {
374
+ return {
375
+ accepted: false,
376
+ record: false,
377
+ response: notification
378
+ ? new Response(null, { status: 204 })
379
+ : rpcError(-32602, "Specified account does not exist", id, 200),
380
+ };
381
+ }
382
+ const timestamp = nextSignalTimestamp(params.state);
383
+ if (timestamp >= Number.MAX_SAFE_INTEGER) {
384
+ return {
385
+ accepted: false,
386
+ record: false,
387
+ response: notification
388
+ ? new Response(null, { status: 204 })
389
+ : rpcError(-32603, "Timestamp capacity exhausted", id, 200),
305
390
  };
306
391
  }
307
- const timestamp = params.state.nextTimestamp++;
308
392
  return {
309
393
  accepted: true,
310
394
  record: true,
311
395
  response: notification
312
396
  ? new Response(null, { status: 204 })
313
397
  : rpcResponse(id, method === "sendTyping" ? {} : { timestamp }),
398
+ timestamp,
314
399
  };
315
400
  }
316
401
  return {
@@ -360,34 +445,115 @@ function hasStringArray(value) {
360
445
  value.length > 0 &&
361
446
  value.every((entry) => typeof entry === "string" && entry.trim().length > 0));
362
447
  }
448
+ function readSignalRpcString(value) {
449
+ if (typeof value !== "string") {
450
+ return undefined;
451
+ }
452
+ const trimmed = value.trim();
453
+ return trimmed.length > 0 ? trimmed : undefined;
454
+ }
455
+ function hasValidSignalRecipientFieldTypes(params) {
456
+ return ["recipient", "recipients", "groupId", "groupIds", "username", "usernames"].every((field) => {
457
+ const value = params[field];
458
+ return (value === undefined ||
459
+ (typeof value === "string" && value.trim().length > 0) ||
460
+ hasStringArray(value));
461
+ });
462
+ }
463
+ function hasValidOptionalSignalString(params, field) {
464
+ return params[field] === undefined || typeof params[field] === "string";
465
+ }
466
+ function hasValidSignalDirectRecipients(params) {
467
+ return ["recipient", "recipients"].every((field) => {
468
+ const value = params[field];
469
+ return (value === undefined ||
470
+ (Array.isArray(value)
471
+ ? value.length > 0 && value.every(isSignalDirectRecipient)
472
+ : isSignalDirectRecipient(value)));
473
+ });
474
+ }
363
475
  function validSignalRpcParams(method, value) {
364
- if (!isJsonObject(value)) {
476
+ if (!isJsonObject(value) ||
477
+ !hasValidSignalRecipientFieldTypes(value) ||
478
+ !hasValidSignalDirectRecipients(value) ||
479
+ !hasValidOptionalSignalString(value, "account") ||
480
+ (value.noteToSelf !== undefined && typeof value.noteToSelf !== "boolean")) {
365
481
  return false;
366
482
  }
367
483
  if (method === "send") {
368
- return (hasRecipients(value) &&
369
- (readTrimmedString(value.message) !== undefined ||
370
- readTrimmedString(value.attachment) !== undefined ||
484
+ return (hasValidOptionalSignalString(value, "message") &&
485
+ hasValidOptionalSignalString(value, "attachment") &&
486
+ (value.attachments === undefined || hasStringArray(value.attachments)) &&
487
+ hasRecipients(value) &&
488
+ (readSignalRpcString(value.message) !== undefined ||
489
+ readSignalRpcString(value.attachment) !== undefined ||
371
490
  hasStringArray(value.attachments)));
372
491
  }
373
492
  if (method === "sendReaction") {
374
- return (hasRecipients(value) &&
375
- readTrimmedString(value.emoji) !== undefined &&
376
- readTrimmedString(value.targetAuthor) !== undefined &&
493
+ return (hasValidOptionalSignalString(value, "emoji") &&
494
+ hasValidOptionalSignalString(value, "targetAuthor") &&
495
+ hasRecipients(value) &&
496
+ readSignalRpcString(value.emoji) !== undefined &&
497
+ isSignalDirectRecipient(value.targetAuthor) &&
377
498
  validTimestamp(value.targetTimestamp));
378
499
  }
379
500
  if (method === "sendReceipt") {
380
501
  const targetTimestamps = value.targetTimestamps ?? value.targetTimestamp;
381
502
  const timestamps = Array.isArray(targetTimestamps) ? targetTimestamps : [targetTimestamps];
382
- return ((readTrimmedString(value.recipient) !== undefined ||
383
- readTrimmedString(value.username) !== undefined ||
384
- hasStringArray(value.usernames)) &&
503
+ return (hasValidOptionalSignalString(value, "type") &&
504
+ (readSignalRpcString(value.recipient) !== undefined ||
505
+ readSignalRpcString(value.username) !== undefined ||
506
+ hasStringArray(value.usernames)) &&
385
507
  timestamps.length > 0 &&
386
508
  timestamps.every(validTimestamp) &&
387
509
  (value.type === undefined || value.type === "read" || value.type === "viewed"));
388
510
  }
389
511
  return method === "sendTyping" && hasTypingRecipients(value);
390
512
  }
513
+ function serializeSignalTimestampCommit(state, operation) {
514
+ const run = state.pendingTimestampCommits.catch(() => { }).then(operation);
515
+ state.pendingTimestampCommits = run.then(() => undefined, () => undefined);
516
+ return run;
517
+ }
518
+ function processSignalAdminInbound(params) {
519
+ return serializeSignalTimestampCommit(params.state, () => handleAdminInbound(params));
520
+ }
521
+ async function processSignalRpc(params) {
522
+ return serializeSignalTimestampCommit(params.state, async () => {
523
+ const rpc = await handleRpc({ body: params.body, state: params.state });
524
+ if (rpc.record) {
525
+ const event = {
526
+ accepted: rpc.accepted,
527
+ at: new Date().toISOString(),
528
+ body: params.body,
529
+ method: "POST",
530
+ path: params.url.pathname,
531
+ query: queryRecord(params.url),
532
+ type: "api",
533
+ };
534
+ if (rpc.accepted) {
535
+ try {
536
+ await appendEvent(params.state, event);
537
+ }
538
+ catch (error) {
539
+ if (!(error instanceof ServerRecorderCommittedError)) {
540
+ throw error;
541
+ }
542
+ }
543
+ commitSignalTimestamp(params.state, rpc.timestamp);
544
+ }
545
+ else {
546
+ await appendEvent(params.state, event, rpc.response.status === 204);
547
+ }
548
+ }
549
+ return rpc.response;
550
+ });
551
+ }
552
+ function hasSignalJsonContentType(request) {
553
+ const contentType = request.headers["content-type"];
554
+ const values = Array.isArray(contentType) ? contentType : [contentType];
555
+ return values.some((value) => value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json");
556
+ }
391
557
  async function handleRequest(params) {
392
558
  const url = new URL(params.request.url ?? "/", "http://localhost");
393
559
  if (url.pathname === "/api/v1/events" && params.request.method === "GET") {
@@ -416,7 +582,6 @@ async function handleRequest(params) {
416
582
  connection: "keep-alive",
417
583
  "content-type": "text/event-stream",
418
584
  });
419
- params.response.flushHeaders();
420
585
  params.response.once("close", () => {
421
586
  removeSignalClient(params.state, params.response, false);
422
587
  });
@@ -427,6 +592,7 @@ async function handleRequest(params) {
427
592
  events: [],
428
593
  inFlight: [],
429
594
  });
595
+ params.response.flushHeaders();
430
596
  replayExclusiveSignalEvents(params.state, params.response);
431
597
  if (params.state.clients.has(params.response)) {
432
598
  flushPendingSignalEvents(params.state);
@@ -454,11 +620,12 @@ async function handleRequest(params) {
454
620
  query: queryRecord(url),
455
621
  type: "admin",
456
622
  });
457
- fetchResponse = await handleAdminInbound({ body, state: params.state });
623
+ fetchResponse = await processSignalAdminInbound({ body, state: params.state });
458
624
  }
459
625
  }
460
626
  else if (url.pathname === "/api/v1/check" && params.request.method === "GET") {
461
627
  await appendEvent(params.state, {
628
+ accepted: true,
462
629
  at: new Date().toISOString(),
463
630
  method: "GET",
464
631
  path: url.pathname,
@@ -468,37 +635,93 @@ async function handleRequest(params) {
468
635
  fetchResponse = new Response(null, { status: 200 });
469
636
  }
470
637
  else if (url.pathname === "/api/v1/rpc" && params.request.method === "POST") {
471
- const body = await parseUnknownRequestBody(params.request);
472
- if (!isJsonObject(body)) {
473
- await writeResponse(params.response, rpcError(-32600, "Invalid Request"));
474
- return;
638
+ if (!hasSignalJsonContentType(params.request)) {
639
+ drainRequestBody(params.request);
640
+ fetchResponse = new Response(null, { status: 415 });
475
641
  }
476
- const rpc = await handleRpc({ body, state: params.state });
477
- if (rpc.record) {
478
- const event = {
479
- accepted: rpc.accepted,
480
- at: new Date().toISOString(),
481
- body,
482
- method: "POST",
483
- path: url.pathname,
484
- query: queryRecord(url),
485
- type: "api",
486
- };
487
- await appendEvent(params.state, event, rpc.accepted || rpc.response.status === 204);
642
+ else {
643
+ const body = await parseUnknownRequestBody(params.request);
644
+ if (!isJsonObject(body)) {
645
+ await writeResponse(params.response, rpcError(-32600, "Invalid Request"));
646
+ return;
647
+ }
648
+ fetchResponse = await processSignalRpc({ body, state: params.state, url });
488
649
  }
489
- fetchResponse = rpc.response;
490
650
  }
491
651
  else {
492
652
  fetchResponse = new Response("not found", { status: 404 });
493
653
  }
494
654
  await writeResponse(params.response, fetchResponse);
495
655
  }
656
+ function normalizeSignalHost(value) {
657
+ const normalized = value
658
+ .trim()
659
+ .replace(/^\[(.*)\]$/u, "$1")
660
+ .toLowerCase()
661
+ .replace(/\.$/u, "");
662
+ if (isIP(normalized) !== 6) {
663
+ return normalized;
664
+ }
665
+ const canonical = new URL(`http://[${normalized}]`).hostname.slice(1, -1);
666
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(canonical);
667
+ if (!mapped?.[1] || !mapped[2]) {
668
+ return canonical;
669
+ }
670
+ const high = Number.parseInt(mapped[1], 16);
671
+ const low = Number.parseInt(mapped[2], 16);
672
+ return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
673
+ }
674
+ function parseSignalHostHeader(value) {
675
+ const match = value.startsWith("[")
676
+ ? /^\[([^\]]+)\](?::(\d{1,5}))?$/u.exec(value)
677
+ : /^([^:]+)(?::(\d{1,5}))?$/u.exec(value);
678
+ if (!match?.[1]) {
679
+ return undefined;
680
+ }
681
+ const port = match[2] === undefined ? undefined : Number(match[2]);
682
+ if (port !== undefined && port > 65_535) {
683
+ return undefined;
684
+ }
685
+ const hostname = normalizeSignalHost(match[1]);
686
+ if (isIP(hostname) !== 0) {
687
+ return hostname;
688
+ }
689
+ const labels = hostname.split(".");
690
+ return hostname.length <= 253 &&
691
+ labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
692
+ ? hostname
693
+ : undefined;
694
+ }
695
+ function signalRequestHostAllowed(request, bindHost, allowedHosts) {
696
+ const hostHeader = request.headers.host;
697
+ if (!hostHeader) {
698
+ return false;
699
+ }
700
+ const requestedHost = parseSignalHostHeader(hostHeader);
701
+ if (requestedHost === undefined) {
702
+ return false;
703
+ }
704
+ const normalizedBindHost = normalizeSignalHost(bindHost);
705
+ if (allowedHosts.has(requestedHost)) {
706
+ return true;
707
+ }
708
+ if (normalizedBindHost !== "0.0.0.0" && normalizedBindHost !== "::") {
709
+ return false;
710
+ }
711
+ const localAddress = normalizeSignalHost(request.socket.localAddress ?? "");
712
+ return isIP(requestedHost) !== 0 && requestedHost === localAddress;
713
+ }
496
714
  export async function startSignalServer(params = {}) {
715
+ const account = normalizeSignalPhoneNumber(params.account ?? "+15550000000");
716
+ if (!account) {
717
+ throw new Error("account must be an E.164 telephone number.");
718
+ }
497
719
  const state = {
498
- account: params.account ?? "+15550000000",
720
+ account,
499
721
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
500
722
  clients: new Set(),
501
723
  clientBuffers: new Map(),
724
+ lastCommittedTimestamp: undefined,
502
725
  maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
503
726
  maxSseClients: Number.isSafeInteger(params.maxSseClients) && (params.maxSseClients ?? 0) > 0
504
727
  ? params.maxSseClients
@@ -508,11 +731,22 @@ export async function startSignalServer(params = {}) {
508
731
  onEvent: params.onEvent,
509
732
  pendingEventBytes: 0,
510
733
  pendingEvents: [],
734
+ pendingTimestampCommits: Promise.resolve(),
511
735
  pendingSseClients: 0,
512
736
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "signal.jsonl"),
513
737
  };
514
738
  const host = params.host ?? "127.0.0.1";
739
+ const normalizedHost = normalizeSignalHost(host);
740
+ const allowedHosts = new Set([
741
+ ...(normalizedHost === "0.0.0.0" || normalizedHost === "::" ? [] : [normalizedHost]),
742
+ ...(params.allowedHosts ?? []).map((allowedHost) => normalizeSignalHost(allowedHost)),
743
+ ]);
515
744
  const server = createServer((request, response) => {
745
+ if (!signalRequestHostAllowed(request, host, allowedHosts)) {
746
+ drainRequestBody(request);
747
+ void writeResponse(response, jsonResponse({ error: "Host header is not allowed", ok: false }, 400)).catch(() => response.destroy());
748
+ return;
749
+ }
516
750
  void handleRequest({ request, response, state }).catch(async (error) => {
517
751
  if (!response.headersSent) {
518
752
  let errorResponse;
@@ -560,7 +794,8 @@ export async function startSignalServer(params = {}) {
560
794
  }
561
795
  }, SIGNAL_CLI_SSE_KEEPALIVE_MS);
562
796
  keepalive.unref();
563
- const baseUrl = `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`;
797
+ const advertisedHost = normalizedHost === "0.0.0.0" ? "127.0.0.1" : normalizedHost === "::" ? "::1" : host;
798
+ const baseUrl = `http://${formatUrlHost(advertisedHost)}:${address.port}`;
564
799
  return {
565
800
  async close() {
566
801
  clearInterval(keepalive);