serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -0,0 +1,231 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { wallopsReducer } from '../../src/commands/wallops';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const serverConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {
22
+ return buildCtx({
23
+ serverConfig,
24
+ clock,
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: conn,
28
+ });
29
+ }
30
+
31
+ function makeOper(id = 'c1', nick = 'alice'): ConnectionState {
32
+ const s = createConnection({ id, connectedSince: 0 });
33
+ s.nick = nick;
34
+ s.user = nick;
35
+ s.host = 'example.com';
36
+ s.realname = 'Alice';
37
+ s.registration = 'registered';
38
+ s.userModes.oper = true;
39
+ return s;
40
+ }
41
+
42
+ const L = (text: string): RawLine => ({ text });
43
+
44
+ const wallops = (message?: string) =>
45
+ ({
46
+ command: 'WALLOPS',
47
+ params: message === undefined ? [] : [message],
48
+ tags: {},
49
+ }) as const;
50
+
51
+ describe('wallopsReducer — oper happy path', () => {
52
+ it('emits a global BroadcastWallops effect carrying the oper source prefix', () => {
53
+ const oper = makeOper();
54
+ const ctx = makeCtx(oper);
55
+
56
+ const out = wallopsReducer(oper, wallops('hello'), ctx);
57
+
58
+ expect(out.effects).toEqual<EffectType[]>([
59
+ Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :hello')], 'c1'),
60
+ ]);
61
+ });
62
+
63
+ it('passes the message text through verbatim (multi-word)', () => {
64
+ const oper = makeOper();
65
+ const ctx = makeCtx(oper);
66
+
67
+ const out = wallopsReducer(oper, wallops('restart in 5 minutes'), ctx);
68
+
69
+ expect(out.effects).toEqual<EffectType[]>([
70
+ Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :restart in 5 minutes')], 'c1'),
71
+ ]);
72
+ });
73
+
74
+ it('defaults except to the sender (skip-self, NOTICE/PRIVMSG semantics)', () => {
75
+ const oper = makeOper();
76
+ const ctx = makeCtx(oper);
77
+
78
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
79
+
80
+ expect(out.effects[0]?.tag).toBe('BroadcastWallops');
81
+ // except is the oper's own connection id, so the runtime never echoes
82
+ // the wallops back to its originator.
83
+ expect((out.effects[0] as { except?: string }).except).toBe('c1');
84
+ });
85
+
86
+ it('updates the oper lastSeen to ctx.clock.now()', () => {
87
+ const oper = makeOper();
88
+ expect(oper.lastSeen).toBe(0);
89
+ const ctx = makeCtx(oper);
90
+
91
+ wallopsReducer(oper, wallops('hi'), ctx);
92
+
93
+ expect(oper.lastSeen).toBe(5_000);
94
+ });
95
+
96
+ it('returns the same state reference unchanged', () => {
97
+ const oper = makeOper();
98
+ const ctx = makeCtx(oper);
99
+
100
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
101
+
102
+ expect(out.state).toBe(oper);
103
+ });
104
+
105
+ it('falls back to the bare nick when the hostmask is incomplete (no user/host)', () => {
106
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
107
+ oper.nick = 'alice';
108
+ oper.registration = 'registered';
109
+ oper.userModes.oper = true;
110
+ const ctx = makeCtx(oper);
111
+
112
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
113
+
114
+ expect(out.effects).toEqual<EffectType[]>([
115
+ Effect.broadcastWallops([L(':alice WALLOPS :hi')], 'c1'),
116
+ ]);
117
+ });
118
+
119
+ it('falls back to "*" when the oper has neither nick nor hostmask (defensive)', () => {
120
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
121
+ oper.registration = 'registered';
122
+ oper.userModes.oper = true;
123
+ const ctx = makeCtx(oper);
124
+
125
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
126
+
127
+ expect(out.effects).toEqual<EffectType[]>([
128
+ Effect.broadcastWallops([L(':* WALLOPS :hi')], 'c1'),
129
+ ]);
130
+ });
131
+ });
132
+
133
+ describe('wallopsReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {
134
+ it('emits 481 when the sender is not an oper', () => {
135
+ const oper = makeOper();
136
+ oper.userModes.oper = false;
137
+ const ctx = makeCtx(oper);
138
+
139
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
140
+
141
+ expect(out.effects).toEqual<EffectType[]>([
142
+ Effect.send('c1', [
143
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
144
+ ]),
145
+ ]);
146
+ });
147
+
148
+ it('does not emit a BroadcastWallops when the sender is not an oper', () => {
149
+ const oper = makeOper();
150
+ oper.userModes.oper = false;
151
+ const ctx = makeCtx(oper);
152
+
153
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
154
+
155
+ expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);
156
+ });
157
+
158
+ it('gates before param validation: a non-oper with no message still gets 481', () => {
159
+ const oper = makeOper();
160
+ oper.userModes.oper = false;
161
+ const ctx = makeCtx(oper);
162
+
163
+ const out = wallopsReducer(oper, wallops(), ctx);
164
+
165
+ expect(out.effects).toEqual<EffectType[]>([
166
+ Effect.send('c1', [
167
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
168
+ ]),
169
+ ]);
170
+ });
171
+
172
+ it('uses "*" as the nick in 481 when the connection has no nick', () => {
173
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
174
+ oper.userModes.oper = false;
175
+ const ctx = makeCtx(oper);
176
+
177
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
178
+
179
+ expect(out.effects).toEqual<EffectType[]>([
180
+ Effect.send('c1', [
181
+ L(":irc.example.com 481 * :Permission Denied - You're not an IRC operator"),
182
+ ]),
183
+ ]);
184
+ });
185
+ });
186
+
187
+ describe('wallopsReducer — empty message (412 ERR_NOTEXTTOSEND)', () => {
188
+ it('emits 412 when no message parameter is supplied', () => {
189
+ const oper = makeOper();
190
+ const ctx = makeCtx(oper);
191
+
192
+ const out = wallopsReducer(oper, wallops(), ctx);
193
+
194
+ expect(out.effects).toEqual<EffectType[]>([
195
+ Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
196
+ ]);
197
+ });
198
+
199
+ it('emits 412 when the message is the empty string', () => {
200
+ const oper = makeOper();
201
+ const ctx = makeCtx(oper);
202
+
203
+ const out = wallopsReducer(oper, wallops(''), ctx);
204
+
205
+ expect(out.effects).toEqual<EffectType[]>([
206
+ Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
207
+ ]);
208
+ });
209
+
210
+ it('does not broadcast when the message is empty', () => {
211
+ const oper = makeOper();
212
+ const ctx = makeCtx(oper);
213
+
214
+ const out = wallopsReducer(oper, wallops(''), ctx);
215
+
216
+ expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);
217
+ });
218
+
219
+ it('uses "*" as the nick in 412 when the connection has no nick', () => {
220
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
221
+ oper.registration = 'registered';
222
+ oper.userModes.oper = true;
223
+ const ctx = makeCtx(oper);
224
+
225
+ const out = wallopsReducer(oper, wallops(''), ctx);
226
+
227
+ expect(out.effects).toEqual<EffectType[]>([
228
+ Effect.send('c1', [L(':irc.example.com 412 * :No text to send')]),
229
+ ]);
230
+ });
231
+ });
@@ -166,6 +166,40 @@ describe('whoisReducer — happy path', () => {
166
166
  expect(send.lines.some((l) => l.text.endsWith(':is an IRC operator'))).toBe(true);
167
167
  }
168
168
  });
169
+
170
+ it('emits 276 RPL_WHOISSECURE when the target connected over TLS (user mode S)', () => {
171
+ const requester = makeConn();
172
+ const ctx = makeCtx(requester);
173
+ const target = makeTarget('c2', 'bob');
174
+ target.userModes.tls = true;
175
+
176
+ const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
177
+
178
+ const send = out.effects[0];
179
+ expect(send?.tag).toBe('Send');
180
+ if (send?.tag === 'Send') {
181
+ expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(true);
182
+ expect(
183
+ send.lines.some(
184
+ (l) => l.text === ':irc.example.com 276 alice bob :is using a secure connection',
185
+ ),
186
+ ).toBe(true);
187
+ }
188
+ });
189
+
190
+ it('does not emit 276 when the target is not on a secure connection', () => {
191
+ const requester = makeConn();
192
+ const ctx = makeCtx(requester);
193
+ const target = makeTarget('c2', 'bob');
194
+ // tls left at its default (false).
195
+
196
+ const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
197
+
198
+ const send = out.effects[0];
199
+ if (send?.tag === 'Send') {
200
+ expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(false);
201
+ }
202
+ });
169
203
  });
170
204
 
171
205
  // ============================================================================
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  DEFAULT_CREATED_TEXT,
4
- DEFAULT_SERVER_CONFIG,
5
4
  DEFAULT_SERVER_VERSION,
6
5
  ServerConfigSchema,
7
6
  formatCreatedAt,
@@ -567,22 +566,34 @@ describe('ServerConfigSchema — failure diagnostics', () => {
567
566
  });
568
567
 
569
568
  // ============================================================================
570
- // ServerConfigSchema — defaults
569
+ // ServerConfigSchema — no placeholder serverName baseline
571
570
  // ============================================================================
572
571
 
573
- describe('ServerConfigSchema — DEFAULT_SERVER_CONFIG', () => {
574
- it('is a valid parsed config', () => {
575
- expect(DEFAULT_SERVER_CONFIG.serverName.length).toBeGreaterThan(0);
576
- expect(DEFAULT_SERVER_CONFIG.networkName.length).toBeGreaterThan(0);
577
- expect(Array.isArray(DEFAULT_SERVER_CONFIG.motdLines)).toBe(true);
578
- expect(DEFAULT_SERVER_CONFIG.channelPrefixes.length).toBeGreaterThan(0);
579
- expect(DEFAULT_SERVER_CONFIG.maxClients).toBeGreaterThan(0);
580
- expect(Array.isArray(DEFAULT_SERVER_CONFIG.operCreds)).toBe(true);
572
+ describe('ServerConfigSchema — required serverName', () => {
573
+ it('rejects an empty input with a serverName error (no placeholder fallback)', () => {
574
+ try {
575
+ parseServerConfig({});
576
+ expect.fail('expected parseServerConfig({}) to throw');
577
+ } catch (err) {
578
+ expect(err).toBeInstanceOf(Error);
579
+ const msg = (err as Error).message;
580
+ expect(msg).toMatch(/invalid server config/iu);
581
+ expect(msg).toMatch(/serverName/u);
582
+ }
581
583
  });
582
584
 
583
- it('round-trips through the schema unchanged', () => {
584
- const reparsed = parseServerConfig(DEFAULT_SERVER_CONFIG);
585
- expect(reparsed).toEqual(DEFAULT_SERVER_CONFIG);
585
+ it('rejects an empty-string serverName', () => {
586
+ expect(() => parseServerConfig({ serverName: '', networkName: 'n' })).toThrowError(
587
+ /serverName/u,
588
+ );
589
+ });
590
+
591
+ it('accepts a minimal {serverName, networkName} input', () => {
592
+ const cfg = parseServerConfig({ serverName: 'irc.test', networkName: 'TestNet' });
593
+ expect(cfg.serverName).toBe('irc.test');
594
+ expect(cfg.networkName).toBe('TestNet');
595
+ expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
596
+ expect(cfg.maxClients).toBeGreaterThan(0);
586
597
  });
587
598
  });
588
599
 
@@ -813,3 +824,82 @@ describe('ServerConfigSchema — perIpConnectionRate', () => {
813
824
  ).toThrowError(/perIpConnectionRate/u);
814
825
  });
815
826
  });
827
+
828
+ // ============================================================================
829
+ // ServerConfigSchema — sts (Strict Transport Security)
830
+ // ============================================================================
831
+
832
+ describe('ServerConfigSchema — sts', () => {
833
+ it('defaults sts to undefined when omitted (STS disabled)', () => {
834
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
835
+ expect(cfg.sts).toBeUndefined();
836
+ });
837
+
838
+ it('accepts a valid sts config with duration + port', () => {
839
+ const cfg = parseServerConfig({
840
+ serverName: 's',
841
+ networkName: 'n',
842
+ sts: { duration: 600, port: 6697 },
843
+ });
844
+ expect(cfg.sts).toEqual({ duration: 600, port: 6697 });
845
+ });
846
+
847
+ it('accepts an optional preload directive', () => {
848
+ const cfg = parseServerConfig({
849
+ serverName: 's',
850
+ networkName: 'n',
851
+ sts: { duration: 600, port: 6697, preload: true },
852
+ });
853
+ expect(cfg.sts?.preload).toBe(true);
854
+ });
855
+
856
+ it('rejects an sts config missing duration', () => {
857
+ expect(() =>
858
+ parseServerConfig({
859
+ serverName: 's',
860
+ networkName: 'n',
861
+ sts: { port: 6697 },
862
+ }),
863
+ ).toThrowError(/duration/u);
864
+ });
865
+
866
+ it('rejects an sts config missing port', () => {
867
+ expect(() =>
868
+ parseServerConfig({
869
+ serverName: 's',
870
+ networkName: 'n',
871
+ sts: { duration: 600 },
872
+ }),
873
+ ).toThrowError(/port/u);
874
+ });
875
+
876
+ it('rejects a zero duration', () => {
877
+ expect(() =>
878
+ parseServerConfig({
879
+ serverName: 's',
880
+ networkName: 'n',
881
+ sts: { duration: 0, port: 6697 },
882
+ }),
883
+ ).toThrowError(/duration/u);
884
+ });
885
+
886
+ it('rejects a zero port', () => {
887
+ expect(() =>
888
+ parseServerConfig({
889
+ serverName: 's',
890
+ networkName: 'n',
891
+ sts: { duration: 600, port: 0 },
892
+ }),
893
+ ).toThrowError(/port/u);
894
+ });
895
+
896
+ it('rejects a non-object sts value', () => {
897
+ expect(() =>
898
+ parseServerConfig({
899
+ serverName: 's',
900
+ networkName: 'n',
901
+ sts: 'secure',
902
+ }),
903
+ ).toThrowError(/sts/u);
904
+ });
905
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Regression guards for the formally-dropped S2S and obsolete RFC 2812 verbs.
3
+ *
4
+ * - S2S drop: `CONNECT` / `SQUIT` / `LINKS` are server-to-server commands.
5
+ * S2S is an explicit PLAN non-goal (`PLAN.md` §1), so the three verbs are
6
+ * formally dropped (they still return `421 ERR_UNKNOWNCOMMAND` — that is
7
+ * the correct response for an unimplemented command). Their reserved
8
+ * numerics (`RPL_LINKS`, `RPL_ENDOFLINKS`, `ERR_CANTKILLSERVER`) are removed
9
+ * here.
10
+ *
11
+ * - Obsolete-verb drop: `SERVICE` / `SUMMON` / `USERS` are RFC 2812 verbs
12
+ * that were never widely implemented and are effectively dead. They are
13
+ * formally dropped (also still `421`). Their reserved numerics
14
+ * (`RPL_YOURESERVICE`, `ERR_NOSUCHSERVICE`, `ERR_SUMMONDISABLED`,
15
+ * `ERR_USERSDISABLED`) are removed here.
16
+ *
17
+ * Because the drop is "remove the numeric, keep the 421", there is no runtime
18
+ * behaviour to assert — these are source-level / registry-level regression
19
+ * guards that prevent the numerics (and the "deferred verb" framing) from
20
+ * creeping back in.
21
+ */
22
+
23
+ import { describe, expect, it } from 'vitest';
24
+ import { Numerics, numericToName } from '../src/protocol/numerics';
25
+ import numericsSource from '../src/protocol/numerics.ts?raw';
26
+
27
+ describe('dropped S2S verbs (CONNECT / SQUIT / LINKS)', () => {
28
+ // S2S is a PLAN non-goal; the three verbs are formally dropped. Their
29
+ // reserved numerics are removed from the registry.
30
+ const droppedS2SNumerics = ['RPL_LINKS', 'RPL_ENDOFLINKS', 'ERR_CANTKILLSERVER'] as const;
31
+
32
+ it.each(droppedS2SNumerics)('numerics.ts source no longer defines %s', (name) => {
33
+ expect(numericsSource).not.toContain(`${name}:`);
34
+ });
35
+
36
+ it.each(droppedS2SNumerics)('Numerics runtime object has no %s key', (name) => {
37
+ expect((Numerics as Record<string, unknown>)[name]).toBeUndefined();
38
+ });
39
+
40
+ it('numericToName no longer resolves the dropped S2S codes (364, 365, 483)', () => {
41
+ expect(numericToName.get(364)).toBeUndefined();
42
+ expect(numericToName.get(365)).toBeUndefined();
43
+ expect(numericToName.get(483)).toBeUndefined();
44
+ });
45
+
46
+ it('numerics.ts source documents the S2S drop', () => {
47
+ // The drop must be explained where the numerics used to live, so the
48
+ // absence is not mistaken for a gap.
49
+ expect(numericsSource).toMatch(/S2S/);
50
+ expect(numericsSource).toMatch(/PLAN non-goal/);
51
+ });
52
+
53
+ it('ERR_NOSUCHSERVER (402) is retained (used by LUSERS/STATS/TRACE)', () => {
54
+ // 402 stays because the implemented query verbs use it for remote-server
55
+ // gating.
56
+ expect(Numerics.ERR_NOSUCHSERVER).toBe(402);
57
+ expect(numericToName.get(402)).toBe('ERR_NOSUCHSERVER');
58
+ });
59
+ });
60
+
61
+ describe('dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS)', () => {
62
+ // RFC 2812 obsolete verbs, never widely implemented. Formally dropped;
63
+ // their reserved numerics are removed from the registry.
64
+ const droppedObsoleteNumerics = [
65
+ 'RPL_YOURESERVICE',
66
+ 'ERR_NOSUCHSERVICE',
67
+ 'ERR_SUMMONDISABLED',
68
+ 'ERR_USERSDISABLED',
69
+ ] as const;
70
+
71
+ it.each(droppedObsoleteNumerics)('numerics.ts source no longer defines %s', (name) => {
72
+ expect(numericsSource).not.toContain(`${name}:`);
73
+ });
74
+
75
+ it.each(droppedObsoleteNumerics)('Numerics runtime object has no %s key', (name) => {
76
+ expect((Numerics as Record<string, unknown>)[name]).toBeUndefined();
77
+ });
78
+
79
+ it('numericToName no longer resolves the dropped obsolete codes (383, 408, 445, 446)', () => {
80
+ expect(numericToName.get(383)).toBeUndefined();
81
+ expect(numericToName.get(408)).toBeUndefined();
82
+ expect(numericToName.get(445)).toBeUndefined();
83
+ expect(numericToName.get(446)).toBeUndefined();
84
+ });
85
+
86
+ it('numerics.ts source documents the obsolete-verb drop', () => {
87
+ expect(numericsSource).toMatch(/RFC 2812/);
88
+ expect(numericsSource).toMatch(/obsolete/);
89
+ });
90
+ });
@@ -82,6 +82,18 @@ describe('Effect constructors', () => {
82
82
  notFoundLines: [{ text: '401' }],
83
83
  });
84
84
  });
85
+
86
+ it('BroadcastWallops carries lines and optional except', () => {
87
+ expect(Effect.broadcastWallops([line(':a WALLOPS :hi')])).toEqual<EffectType>({
88
+ tag: 'BroadcastWallops',
89
+ lines: [{ text: ':a WALLOPS :hi' }],
90
+ });
91
+ expect(Effect.broadcastWallops([line(':a WALLOPS :hi')], 'c1')).toEqual<EffectType>({
92
+ tag: 'BroadcastWallops',
93
+ lines: [{ text: ':a WALLOPS :hi' }],
94
+ except: 'c1',
95
+ });
96
+ });
85
97
  });
86
98
 
87
99
  describe('Effect tag set', () => {
@@ -95,6 +107,7 @@ describe('Effect tag set', () => {
95
107
  'ReleaseNick',
96
108
  'ApplyChannelDelta',
97
109
  'SendToNick',
110
+ 'BroadcastWallops',
98
111
  ];
99
112
 
100
113
  it.each(expectedTags)('exposes a constructor for the %s tag', (tag) => {
@@ -107,6 +120,7 @@ describe('Effect tag set', () => {
107
120
  ReleaseNick: Effect.releaseNick('n'),
108
121
  ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
109
122
  SendToNick: Effect.sendToNick('n', 'c', []),
123
+ BroadcastWallops: Effect.broadcastWallops([]),
110
124
  };
111
125
  expect(constructors[tag].tag).toBe(tag);
112
126
  });