serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -958,3 +958,150 @@ describe('privmsgChannelReducer — chathistory recording', () => {
958
958
  expect(recorded.map((m) => m.msgid)).toEqual(['nonce-0', 'nonce-1']);
959
959
  });
960
960
  });
961
+
962
+ // ============================================================================
963
+ // channelReducer — ChanServ +R (block unidentified PRIVMSG)
964
+ // ============================================================================
965
+
966
+ describe('privmsgChannelReducer — +R (block unidentified PRIVMSG)', () => {
967
+ it('rejects an unidentified member with 404 ERR_CANNOTSENDTOCHAN', () => {
968
+ const chan = makeChan('#foo');
969
+ chan.modes.blockUnidentified = true;
970
+ addMember(chan, 'c1', 'guest');
971
+ const conn = makeConn('c1', 'guest');
972
+ // guest has no account.
973
+ const ctx = makeCtx(conn);
974
+
975
+ const out = privmsgChannelReducer(
976
+ chan,
977
+ { command: 'PRIVMSG', params: ['#foo', 'hello'], tags: {} },
978
+ ctx,
979
+ );
980
+
981
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(0);
982
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
983
+ expect(texts).toContain(':irc.example.com 404 guest #foo :Cannot send to channel');
984
+ });
985
+
986
+ it('admits an identified member (account set)', () => {
987
+ const chan = makeChan('#foo');
988
+ chan.modes.blockUnidentified = true;
989
+ addMember(chan, 'c1', 'alice');
990
+ const conn = makeConn('c1', 'alice');
991
+ conn.account = 'alice';
992
+ const ctx = makeCtx(conn);
993
+
994
+ const out = privmsgChannelReducer(
995
+ chan,
996
+ { command: 'PRIVMSG', params: ['#foo', 'hello'], tags: {} },
997
+ ctx,
998
+ );
999
+
1000
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(1);
1001
+ });
1002
+
1003
+ it('NOTICE is silent on +R rejection (RFC: NOTICE never produces numerics)', () => {
1004
+ const chan = makeChan('#foo');
1005
+ chan.modes.blockUnidentified = true;
1006
+ addMember(chan, 'c1', 'guest');
1007
+ const conn = makeConn('c1', 'guest');
1008
+ const ctx = makeCtx(conn);
1009
+
1010
+ const out = noticeChannelReducer(
1011
+ chan,
1012
+ { command: 'NOTICE', params: ['#foo', 'hello'], tags: {} },
1013
+ ctx,
1014
+ );
1015
+
1016
+ expect(out.effects).toEqual<EffectType[]>([]);
1017
+ });
1018
+ });
1019
+
1020
+ // ============================================================================
1021
+ // channelReducer — ChanServ +M (moderated requires identification)
1022
+ // ============================================================================
1023
+
1024
+ describe('privmsgChannelReducer — +M (moderated-ident)', () => {
1025
+ it('rejects an unidentified non-op/non-voice member with 404', () => {
1026
+ const chan = makeChan('#foo');
1027
+ chan.modes.moderatedIdent = true;
1028
+ addMember(chan, 'c1', 'guest');
1029
+ const conn = makeConn('c1', 'guest');
1030
+ const ctx = makeCtx(conn);
1031
+
1032
+ const out = privmsgChannelReducer(
1033
+ chan,
1034
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
1035
+ ctx,
1036
+ );
1037
+
1038
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(0);
1039
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1040
+ expect(texts).toContain(':irc.example.com 404 guest #foo :Cannot send to channel');
1041
+ });
1042
+
1043
+ it('admits an identified non-op/non-voice member', () => {
1044
+ const chan = makeChan('#foo');
1045
+ chan.modes.moderatedIdent = true;
1046
+ addMember(chan, 'c1', 'alice');
1047
+ const conn = makeConn('c1', 'alice');
1048
+ conn.account = 'alice';
1049
+ const ctx = makeCtx(conn);
1050
+
1051
+ const out = privmsgChannelReducer(
1052
+ chan,
1053
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
1054
+ ctx,
1055
+ );
1056
+
1057
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(1);
1058
+ });
1059
+
1060
+ it('admits an unidentified op (op bypasses +M)', () => {
1061
+ const chan = makeChan('#foo');
1062
+ chan.modes.moderatedIdent = true;
1063
+ addMember(chan, 'c1', 'guest', true);
1064
+ const conn = makeConn('c1', 'guest');
1065
+ const ctx = makeCtx(conn);
1066
+
1067
+ const out = privmsgChannelReducer(
1068
+ chan,
1069
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
1070
+ ctx,
1071
+ );
1072
+
1073
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(1);
1074
+ });
1075
+
1076
+ it('admits an unidentified voice (voice bypasses +M)', () => {
1077
+ const chan = makeChan('#foo');
1078
+ chan.modes.moderatedIdent = true;
1079
+ addMember(chan, 'c1', 'guest', false, true);
1080
+ const conn = makeConn('c1', 'guest');
1081
+ const ctx = makeCtx(conn);
1082
+
1083
+ const out = privmsgChannelReducer(
1084
+ chan,
1085
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
1086
+ ctx,
1087
+ );
1088
+
1089
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(1);
1090
+ });
1091
+
1092
+ it('NOTICE is silent on +M rejection (RFC: NOTICE never produces numerics)', () => {
1093
+ const chan = makeChan('#foo');
1094
+ chan.modes.moderatedIdent = true;
1095
+ addMember(chan, 'c1', 'guest');
1096
+ const conn = makeConn('c1', 'guest');
1097
+ const ctx = makeCtx(conn);
1098
+
1099
+ const out = noticeChannelReducer(
1100
+ chan,
1101
+ { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
1102
+ ctx,
1103
+ );
1104
+
1105
+ expect(out.effects).toEqual<EffectType[]>([]);
1106
+ });
1107
+ });
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  EmptyMotdProvider,
12
12
  FakeClock,
13
- InMemoryReadMarkerStore,
13
+ InMemoryServicesStore,
14
14
  SequentialIdFactory,
15
15
  } from '../../src/ports';
16
16
  import { type ConnectionState, createConnection } from '../../src/state/connection';
@@ -38,14 +38,14 @@ function makeConn(id = 'c1', account?: string): ConnectionState {
38
38
  return s;
39
39
  }
40
40
 
41
- function makeCtx(conn: ConnectionState, readMarkers?: InMemoryReadMarkerStore): Ctx {
41
+ function makeCtx(conn: ConnectionState, services?: InMemoryServicesStore): Ctx {
42
42
  return buildCtx({
43
43
  serverConfig,
44
44
  clock: new FakeClock(1_000),
45
45
  ids: new SequentialIdFactory(),
46
46
  motd: EmptyMotdProvider,
47
47
  connection: conn,
48
- ...(readMarkers !== undefined ? { readMarkers } : {}),
48
+ ...(services !== undefined ? { services } : {}),
49
49
  });
50
50
  }
51
51
 
@@ -109,18 +109,18 @@ describe('readMarkerMsgid', () => {
109
109
 
110
110
  describe('persistReadMarker', () => {
111
111
  it('writes the marker to the store keyed by (account, channel)', () => {
112
- const store = new InMemoryReadMarkerStore();
113
- const ctx = makeCtx(makeConn('c1', 'alice'), store);
112
+ const services = new InMemoryServicesStore();
113
+ const ctx = makeCtx(makeConn('c1', 'alice'), services);
114
114
 
115
115
  persistReadMarker(ctx, '#foo', 'm7');
116
116
 
117
- expect(store.get('alice', '#foo')).toBe('m7');
117
+ expect(services.getLastReadMarker('alice', '#foo')).toBe('m7');
118
118
  });
119
119
 
120
120
  it('also advances the connection in-memory lastReadMarkers', () => {
121
- const store = new InMemoryReadMarkerStore();
121
+ const services = new InMemoryServicesStore();
122
122
  const conn = makeConn('c1', 'alice');
123
- const ctx = makeCtx(conn, store);
123
+ const ctx = makeCtx(conn, services);
124
124
 
125
125
  persistReadMarker(ctx, '#foo', 'm7');
126
126
 
@@ -128,16 +128,16 @@ describe('persistReadMarker', () => {
128
128
  });
129
129
 
130
130
  it('overwrites a previously stored marker for the same pair', () => {
131
- const store = new InMemoryReadMarkerStore();
132
- const ctx = makeCtx(makeConn('c1', 'alice'), store);
131
+ const services = new InMemoryServicesStore();
132
+ const ctx = makeCtx(makeConn('c1', 'alice'), services);
133
133
 
134
134
  persistReadMarker(ctx, '#foo', 'm1');
135
135
  persistReadMarker(ctx, '#foo', 'm2');
136
136
 
137
- expect(store.get('alice', '#foo')).toBe('m2');
137
+ expect(services.getLastReadMarker('alice', '#foo')).toBe('m2');
138
138
  });
139
139
 
140
- it('is a no-op when no ReadMarkerStore is bound', () => {
140
+ it('is a no-op when no ServicesStore is bound', () => {
141
141
  const conn = makeConn('c1', 'alice');
142
142
  const ctx = makeCtx(conn); // no store
143
143
 
@@ -148,14 +148,14 @@ describe('persistReadMarker', () => {
148
148
  });
149
149
 
150
150
  it('is a no-op on the store when the connection has no account', () => {
151
- const store = new InMemoryReadMarkerStore();
151
+ const services = new InMemoryServicesStore();
152
152
  const conn = makeConn('c1'); // no account
153
- const ctx = makeCtx(conn, store);
153
+ const ctx = makeCtx(conn, services);
154
154
 
155
155
  persistReadMarker(ctx, '#foo', 'm1');
156
156
 
157
- expect(store.get('alice', '#foo')).toBeUndefined();
158
- expect(store.forAccount('alice')).toEqual([]);
157
+ expect(services.getLastReadMarker('alice', '#foo')).toBeUndefined();
158
+ expect(services.listReadMarkers('alice')).toEqual([]);
159
159
  });
160
160
  });
161
161
 
@@ -165,44 +165,44 @@ describe('persistReadMarker', () => {
165
165
 
166
166
  describe('seedReadMarkers', () => {
167
167
  it('populates lastReadMarkers from every persisted entry for the account', () => {
168
- const store = new InMemoryReadMarkerStore();
169
- store.set('alice', '#foo', 'm1');
170
- store.set('alice', '#bar', 'm2');
168
+ const services = new InMemoryServicesStore();
169
+ services.setLastReadMarker('alice', '#foo', 'm1');
170
+ services.setLastReadMarker('alice', '#bar', 'm2');
171
171
  const conn = makeConn();
172
172
 
173
- seedReadMarkers(conn, store, 'alice');
173
+ seedReadMarkers(conn, services, 'alice');
174
174
 
175
175
  expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
176
176
  expect(conn.lastReadMarkers?.get('#bar')).toBe('m2');
177
177
  });
178
178
 
179
179
  it('leaves existing in-memory markers untouched when the store is empty', () => {
180
- const store = new InMemoryReadMarkerStore();
180
+ const services = new InMemoryServicesStore();
181
181
  const conn = makeConn();
182
182
  conn.lastReadMarkers = new Map<string, string>([['#foo', 'm9']]);
183
183
 
184
- seedReadMarkers(conn, store, 'alice');
184
+ seedReadMarkers(conn, services, 'alice');
185
185
 
186
186
  expect(conn.lastReadMarkers?.get('#foo')).toBe('m9');
187
187
  });
188
188
 
189
189
  it('seeds markers for an account regardless of case spelling', () => {
190
- const store = new InMemoryReadMarkerStore();
191
- store.set('Alice', '#foo', 'm1');
190
+ const services = new InMemoryServicesStore();
191
+ services.setLastReadMarker('Alice', '#foo', 'm1');
192
192
  const conn = makeConn();
193
193
 
194
- seedReadMarkers(conn, store, 'alice');
194
+ seedReadMarkers(conn, services, 'alice');
195
195
 
196
196
  expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
197
197
  });
198
198
 
199
199
  it('does not overwrite an in-memory marker already present for a channel', () => {
200
- const store = new InMemoryReadMarkerStore();
201
- store.set('alice', '#foo', 'stored');
200
+ const services = new InMemoryServicesStore();
201
+ services.setLastReadMarker('alice', '#foo', 'stored');
202
202
  const conn = makeConn();
203
203
  conn.lastReadMarkers = new Map<string, string>([['#foo', 'in-memory']]);
204
204
 
205
- seedReadMarkers(conn, store, 'alice');
205
+ seedReadMarkers(conn, services, 'alice');
206
206
 
207
207
  // The in-memory (session) marker wins: the connection may have already
208
208
  // advanced past the persisted value this session.