serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -21,6 +21,9 @@ function fakeRuntime(calls: RecordedCall[]): IrcRuntime {
21
21
  async broadcastWallops(lines, except) {
22
22
  calls.push({ method: 'broadcastWallops', args: [lines, except] });
23
23
  },
24
+ async broadcastOperNotice(lines, except) {
25
+ calls.push({ method: 'broadcastOperNotice', args: [lines, except] });
26
+ },
24
27
  async disconnect(conn, reason) {
25
28
  calls.push({ method: 'disconnect', args: [conn, reason] });
26
29
  },
@@ -151,6 +154,30 @@ describe('dispatch', () => {
151
154
  });
152
155
  });
153
156
 
157
+ it('routes BroadcastOperNotice with lines and except', async () => {
158
+ const calls: RecordedCall[] = [];
159
+ await dispatch(
160
+ [Effect.broadcastOperNotice([L(':s NOTICE * :OPER lockout triggered for h')], 'c1')],
161
+ fakeRuntime(calls),
162
+ );
163
+ expect(calls[0]).toEqual({
164
+ method: 'broadcastOperNotice',
165
+ args: [[{ text: ':s NOTICE * :OPER lockout triggered for h' }], 'c1'],
166
+ });
167
+ });
168
+
169
+ it('routes BroadcastOperNotice preserving undefined except', async () => {
170
+ const calls: RecordedCall[] = [];
171
+ await dispatch(
172
+ [Effect.broadcastOperNotice([L(':s NOTICE * :OPER lockout triggered for h')])],
173
+ fakeRuntime(calls),
174
+ );
175
+ expect(calls[0]).toEqual({
176
+ method: 'broadcastOperNotice',
177
+ args: [[{ text: ':s NOTICE * :OPER lockout triggered for h' }], undefined],
178
+ });
179
+ });
180
+
154
181
  it('calls reserveNick, changeNick, releaseNick in order', async () => {
155
182
  const calls: RecordedCall[] = [];
156
183
  await dispatch(
@@ -279,6 +306,7 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
279
306
  broadcasts.push({ chan, lines, except });
280
307
  },
281
308
  async broadcastWallops() {},
309
+ async broadcastOperNotice() {},
282
310
  async disconnect() {},
283
311
  async sendToNick() {},
284
312
  async reserveNick() {
@@ -429,6 +457,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
429
457
  },
430
458
  async broadcast() {},
431
459
  async broadcastWallops() {},
460
+ async broadcastOperNotice() {},
432
461
  async disconnect() {},
433
462
  async sendToNick() {},
434
463
  async reserveNick() {
@@ -476,6 +505,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
476
505
  },
477
506
  async broadcast() {},
478
507
  async broadcastWallops() {},
508
+ async broadcastOperNotice() {},
479
509
  async disconnect() {},
480
510
  async sendToNick() {},
481
511
  async reserveNick() {
@@ -662,6 +692,7 @@ describe('dispatch — Broadcast caps (OR semantics)', () => {
662
692
  },
663
693
  async broadcast() {},
664
694
  async broadcastWallops() {},
695
+ async broadcastOperNotice() {},
665
696
  async disconnect() {},
666
697
  async sendToNick() {},
667
698
  async reserveNick() {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Log-scrubbing helpers: `redactLine`, `truncateToken`, `parseErrorReason`.
3
+ *
4
+ * These are the credential-exfiltration guards for log payloads. A hostile
5
+ * client can smuggle secret material into server logs by sending a
6
+ * malformed or sensitive line (e.g. `PASS hunter2` inside a frame that
7
+ * trips the parser). Anything that could end up in a warn/error record
8
+ * must pass through these helpers first: raw lines go through
9
+ * `redactLine`, parse diagnostics go through `truncateToken` +
10
+ * `parseErrorReason`.
11
+ *
12
+ * Coverage here is exhaustive by design (100% on this module is a
13
+ * release requirement): every sensitive command prefix and every branch
14
+ * of the scrubbers is pinned by a named case.
15
+ */
16
+
17
+ import { MAX_TAGS, parse, parseTags } from '@serverless-ircd/irc-core';
18
+ import { describe, expect, it } from 'vitest';
19
+ import { parseErrorReason, redactLine, truncateToken } from '../src/redact';
20
+
21
+ describe('redactLine — sensitive command prefixes', () => {
22
+ it('redacts the parameter of PASS while keeping the verb', () => {
23
+ expect(redactLine('PASS hunter2')).toBe('PASS [REDACTED]');
24
+ });
25
+
26
+ it('matches the PASS verb case-insensitively but preserves its spelling', () => {
27
+ expect(redactLine('pass hunter2')).toBe('pass [REDACTED]');
28
+ });
29
+
30
+ it('redacts the parameter of AUTHENTICATE (SASL payloads carry credentials)', () => {
31
+ expect(redactLine('AUTHENTICATE dXNlcjpodW50ZXIy')).toBe('AUTHENTICATE [REDACTED]');
32
+ });
33
+
34
+ it('redacts the parameters of OPER (name + password)', () => {
35
+ expect(redactLine('OPER admin secretpw')).toBe('OPER [REDACTED]');
36
+ });
37
+
38
+ it('redacts the parameter of a bare IDENTIFY (service shortcut form)', () => {
39
+ expect(redactLine('IDENTIFY hunter2')).toBe('IDENTIFY [REDACTED]');
40
+ });
41
+
42
+ it('redacts a NickServ-targeted PRIVMSG carrying IDENTIFY, keeping target + subcommand', () => {
43
+ expect(redactLine('PRIVMSG NickServ :IDENTIFY hunter2')).toBe(
44
+ 'PRIVMSG NickServ :IDENTIFY [REDACTED]',
45
+ );
46
+ });
47
+
48
+ it('redacts a case-folded NickServ-targeted NOTICE carrying the ID alias', () => {
49
+ expect(redactLine('NOTICE nickserv :id hunter2')).toBe('NOTICE nickserv :id [REDACTED]');
50
+ });
51
+
52
+ it('redacts a NickServ-targeted PRIVMSG whose body lacks the trailing colon', () => {
53
+ expect(redactLine('PRIVMSG NickServ IDENTIFY hunter2')).toBe(
54
+ 'PRIVMSG NickServ IDENTIFY [REDACTED]',
55
+ );
56
+ });
57
+ });
58
+
59
+ describe('redactLine — non-sensitive input passes through', () => {
60
+ it('leaves ordinary commands untouched', () => {
61
+ expect(redactLine('PRIVMSG #chan :hello there')).toBe('PRIVMSG #chan :hello there');
62
+ });
63
+
64
+ it('leaves a NickServ message that carries no credential subcommand untouched', () => {
65
+ expect(redactLine('PRIVMSG NickServ :INFO')).toBe('PRIVMSG NickServ :INFO');
66
+ });
67
+
68
+ it('leaves an IDENTIFY with no arguments untouched (nothing to redact)', () => {
69
+ expect(redactLine('PRIVMSG NickServ :IDENTIFY')).toBe('PRIVMSG NickServ :IDENTIFY');
70
+ });
71
+
72
+ it('leaves an IDENTIFY sent to a non-service nick untouched', () => {
73
+ expect(redactLine('PRIVMSG Mallory :IDENTIFY hunter2')).toBe(
74
+ 'PRIVMSG Mallory :IDENTIFY hunter2',
75
+ );
76
+ });
77
+
78
+ it('leaves a bare sensitive verb with no parameters untouched', () => {
79
+ expect(redactLine('PASS')).toBe('PASS');
80
+ });
81
+
82
+ it('leaves a bare PRIVMSG with no target or body untouched', () => {
83
+ expect(redactLine('PRIVMSG')).toBe('PRIVMSG');
84
+ });
85
+
86
+ it('leaves an empty line untouched', () => {
87
+ expect(redactLine('')).toBe('');
88
+ });
89
+ });
90
+
91
+ describe('redactLine — source prefixes', () => {
92
+ it('redacts the parameters of a prefixed sensitive command, keeping the prefix', () => {
93
+ expect(redactLine(':srv.example.com PASS secret')).toBe(':srv.example.com PASS [REDACTED]');
94
+ });
95
+
96
+ it('redacts a prefixed NickServ-targeted PRIVMSG', () => {
97
+ expect(redactLine(':alice!u@h PRIVMSG NickServ :IDENTIFY hunter2')).toBe(
98
+ ':alice!u@h PRIVMSG NickServ :IDENTIFY [REDACTED]',
99
+ );
100
+ });
101
+
102
+ it('leaves a prefix-only line untouched', () => {
103
+ expect(redactLine(':prefix-only')).toBe(':prefix-only');
104
+ });
105
+ });
106
+
107
+ describe('truncateToken', () => {
108
+ it('returns a token at the cap unchanged', () => {
109
+ expect(truncateToken('a'.repeat(32))).toBe('a'.repeat(32));
110
+ });
111
+
112
+ it('truncates a token beyond the cap with an ellipsis marker', () => {
113
+ expect(truncateToken('a'.repeat(33))).toBe(`${'a'.repeat(32)}...`);
114
+ });
115
+ });
116
+
117
+ describe('parseErrorReason — stable codes for every parser diagnostic', () => {
118
+ it('maps the empty-message diagnostic', () => {
119
+ expect(() => parse('')).toThrow();
120
+ try {
121
+ parse('');
122
+ } catch (err) {
123
+ expect(parseErrorReason(err)).toBe('empty-message');
124
+ }
125
+ });
126
+
127
+ it('maps the input-cap diagnostic', () => {
128
+ try {
129
+ parse('A'.repeat(600));
130
+ expect.unreachable('600 bytes must fail the input cap');
131
+ } catch (err) {
132
+ expect(parseErrorReason(err)).toBe('input-cap-exceeded');
133
+ }
134
+ });
135
+
136
+ it('maps the tags-without-body diagnostic', () => {
137
+ try {
138
+ parse('@account=alice');
139
+ expect.unreachable('a tag section with no message body must fail');
140
+ } catch (err) {
141
+ expect(parseErrorReason(err)).toBe('tags-without-body');
142
+ }
143
+ });
144
+
145
+ it('maps the prefix-without-command diagnostic', () => {
146
+ try {
147
+ parse(':src.example.com');
148
+ expect.unreachable('a source prefix with no command must fail');
149
+ } catch (err) {
150
+ expect(parseErrorReason(err)).toBe('prefix-without-command');
151
+ }
152
+ });
153
+
154
+ it('maps the missing-command diagnostic', () => {
155
+ try {
156
+ parse('@a :src ');
157
+ expect.unreachable('tags + prefix with no command must fail');
158
+ } catch (err) {
159
+ expect(parseErrorReason(err)).toBe('missing-command');
160
+ }
161
+ });
162
+
163
+ it('maps the invalid-command-token diagnostic', () => {
164
+ try {
165
+ parse('12345');
166
+ expect.unreachable('a 5-digit command token must fail');
167
+ } catch (err) {
168
+ expect(parseErrorReason(err)).toBe('invalid-command-token');
169
+ }
170
+ });
171
+
172
+ it('maps the too-many-tags diagnostic', () => {
173
+ try {
174
+ const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
175
+ parse(`@${tags.join(';')} PRIVMSG #foo :hi`);
176
+ expect.unreachable('a 16-tag line must fail the tag count cap');
177
+ } catch (err) {
178
+ expect(parseErrorReason(err)).toBe('too-many-tags');
179
+ }
180
+ });
181
+
182
+ it('maps the tag-section-byte-cap diagnostic', () => {
183
+ try {
184
+ parseTags(`a=${'x'.repeat(8193 - 2)}`, `@a=${'x'.repeat(8193 - 2)} PRIVMSG #foo :hi`);
185
+ expect.unreachable('an over-budget tag section must fail');
186
+ } catch (err) {
187
+ expect(parseErrorReason(err)).toBe('tag-section-too-large');
188
+ }
189
+ });
190
+
191
+ it('falls back to a stable code for an unrecognized Error message', () => {
192
+ expect(parseErrorReason(new Error('boom'))).toBe('parse-failed');
193
+ });
194
+
195
+ it('falls back to a stable code for a non-Error throw', () => {
196
+ expect(parseErrorReason('boom')).toBe('parse-failed');
197
+ });
198
+ });
@@ -38,6 +38,7 @@ describe('IrcRuntime contract', () => {
38
38
  throw new Error('reloadConfig not configured for this fake');
39
39
  },
40
40
  async broadcastWallops() {},
41
+ async broadcastOperNotice() {},
41
42
  };
42
43
  expect(runtime).toBeDefined();
43
44
  expect(typeof runtime.send).toBe('function');
@@ -55,5 +56,6 @@ describe('IrcRuntime contract', () => {
55
56
  expect(typeof runtime.listChannels).toBe('function');
56
57
  expect(typeof runtime.reloadConfig).toBe('function');
57
58
  expect(typeof runtime.broadcastWallops).toBe('function');
59
+ expect(typeof runtime.broadcastOperNotice).toBe('function');
58
60
  });
59
61
  });
@@ -199,6 +199,72 @@ describe('TcpByteStreamTransport — buffer save/restore', () => {
199
199
  });
200
200
  });
201
201
 
202
+ // ---------------------------------------------------------------------------
203
+ // Input buffer cap: a peer streaming megabytes with no line terminator must
204
+ // not grow the partial-line buffer unboundedly (edge-level memory DoS). The
205
+ // cap is measured in UTF-8 bytes on the retained (unterminated) tail after
206
+ // each append — complete lines flushed out by the same chunk never count.
207
+ // ---------------------------------------------------------------------------
208
+
209
+ describe('TcpByteStreamTransport — input buffer cap', () => {
210
+ it('signals onOverflow and drops the buffer when an appended tail exceeds the 8 KiB cap', () => {
211
+ const overflows: number[] = [];
212
+ const t = new TcpByteStreamTransport({ onOverflow: () => overflows.push(1) });
213
+ expect(t.feed('x'.repeat(9 * 1024))).toEqual([]);
214
+ expect(overflows.length).toBe(1);
215
+ // The oversized tail is dropped, not retained.
216
+ expect(t.getBuffer()).toBe('');
217
+ });
218
+
219
+ it('checks the cap once per append (chunk), not per byte', () => {
220
+ // A single 9 KiB chunk that overflows fires the callback exactly once —
221
+ // the guard runs on the appended buffer, not per input byte.
222
+ const overflows: number[] = [];
223
+ const t = new TcpByteStreamTransport({ onOverflow: () => overflows.push(1) });
224
+ t.feed('y'.repeat(9 * 1024));
225
+ expect(overflows.length).toBe(1);
226
+ });
227
+
228
+ it('signals overflow only on the append that crosses the cap (accumulated chunks)', () => {
229
+ const overflows: number[] = [];
230
+ const t = new TcpByteStreamTransport({ onOverflow: () => overflows.push(1) });
231
+ expect(t.feed('a'.repeat(4 * 1024))).toEqual([]);
232
+ expect(overflows.length).toBe(0);
233
+ expect(t.feed('b'.repeat(5 * 1024))).toEqual([]);
234
+ expect(overflows.length).toBe(1);
235
+ });
236
+
237
+ it('does NOT overflow a buffered partial line at (just under) the cap', () => {
238
+ const overflows: number[] = [];
239
+ const t = new TcpByteStreamTransport({ onOverflow: () => overflows.push(1) });
240
+ // Exactly 8192 bytes: at the cap, still allowed.
241
+ t.feed('z'.repeat(8192));
242
+ expect(overflows.length).toBe(0);
243
+ expect(t.getBuffer()).toBe('z'.repeat(8192));
244
+ });
245
+
246
+ it('does not overflow a chunk of complete lines totalling over the cap (batching)', () => {
247
+ const overflows: number[] = [];
248
+ const t = new TcpByteStreamTransport({ onOverflow: () => overflows.push(1) });
249
+ // 20 terminated ~500-byte lines ≈ 10 KiB in one chunk: every line is
250
+ // complete, so the retained tail is empty and nothing overflows.
251
+ const chunk = `${Array.from({ length: 20 }, (_, i) => `PING :${`${i}`.padStart(495, '-')}`).join('\r\n')}\r\n`;
252
+ expect(chunk.length).toBeGreaterThan(8192);
253
+ const out = t.feed(chunk);
254
+ expect(overflows.length).toBe(0);
255
+ expect(out.length).toBe(20);
256
+ expect(t.getBuffer()).toBe('');
257
+ });
258
+
259
+ it('measures the tail in UTF-8 bytes, not UTF-16 code units', () => {
260
+ // 'é' is one UTF-16 unit but two UTF-8 bytes: 5000 × é = 10000 bytes.
261
+ const overflows: number[] = [];
262
+ const t = new TcpByteStreamTransport({ onOverflow: () => overflows.push(1) });
263
+ t.feed('é'.repeat(5000));
264
+ expect(overflows.length).toBe(1);
265
+ });
266
+ });
267
+
202
268
  // ---------------------------------------------------------------------------
203
269
  // Equivalence: a fully-terminated message stream produces the same complete
204
270
  // lines regardless of how it is chunked across feed() calls.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -192,6 +192,10 @@ export class InMemoryHarness implements IrcHarness {
192
192
  clock: this.clock,
193
193
  ids: this.ids,
194
194
  motd: this.motd,
195
+ // Shared per-IP failed-OPER counter (runtime-owned) so harness
196
+ // scenarios exercise the OPER brute-force lockout exactly like the
197
+ // local CLI does.
198
+ operFailures: this.runtime.operFailures,
195
199
  transport: this.newTransport(),
196
200
  });
197
201
  client.bindActor(actor);
@@ -3,6 +3,7 @@ packages:
3
3
  - "apps/*"
4
4
  - "tools"
5
5
  - "tools/*"
6
+ - "scripts"
6
7
  linkWorkspacePackages: true
7
8
  allowBuilds:
8
9
  '@biomejs/biome': true