serverless-ircd 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
@@ -6,6 +6,8 @@
6
6
  * can pin the timeline and identity space (PLAN §8 determinism rules).
7
7
  */
8
8
 
9
+ import { caseFold } from './case-fold.js';
10
+
9
11
  /** Read-only wall clock, epoch milliseconds. */
10
12
  export interface Clock {
11
13
  now(): number;
@@ -192,6 +194,68 @@ export interface AccountStore {
192
194
  verify(mech: string, payload: SaslPayload): SaslResult;
193
195
  }
194
196
 
197
+ /**
198
+ * One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
199
+ * The `username` is also the canonical account name recorded on the
200
+ * connection for `extended-join` / `account-tag`.
201
+ */
202
+ export interface SaslAccountCredential {
203
+ username: string;
204
+ password: string;
205
+ }
206
+
207
+ /**
208
+ * Reference in-memory {@link AccountStore} backed by a static list of PLAIN
209
+ * credentials seeded at construction.
210
+ *
211
+ * Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
212
+ * pattern: the port is synchronous, so adapters that need an async backend
213
+ * (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
214
+ * at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
215
+ * viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
216
+ * is a documented follow-up that swaps in by replacing the construction call.
217
+ *
218
+ * Password comparison is constant-time to avoid the early-return timing
219
+ * oracle a naive `===` would expose. Only `PLAIN` is verified; any other
220
+ * mechanism returns failure (matching the reducer's mech gating).
221
+ */
222
+ export class InMemoryAccountStore implements AccountStore {
223
+ private readonly creds: ReadonlyMap<string, string>;
224
+
225
+ constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
226
+ this.creds = new Map(credentials.map((c) => [c.username, c.password]));
227
+ }
228
+
229
+ verify(mech: string, payload: SaslPayload): SaslResult {
230
+ if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
231
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
232
+ }
233
+ const expected = this.creds.get(payload.username);
234
+ if (expected === undefined) {
235
+ return { ok: false, reason: 'invalid credentials' };
236
+ }
237
+ if (!constantTimeEquals(payload.password, expected)) {
238
+ return { ok: false, reason: 'invalid credentials' };
239
+ }
240
+ return { ok: true, account: payload.username };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Constant-time string equality. Compares every byte regardless of early
246
+ * mismatches so a remote attacker cannot short-circuit the comparison via a
247
+ * timing side-channel. Returns `false` when the lengths differ (the length
248
+ * is not considered secret for SASL PLAIN passwords).
249
+ */
250
+ function constantTimeEquals(a: string, b: string): boolean {
251
+ if (a.length !== b.length) return false;
252
+ let diff = 0;
253
+ for (let i = 0; i < a.length; i++) {
254
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
255
+ }
256
+ return diff === 0;
257
+ }
258
+
195
259
  // ============================================================================
196
260
  // Chat-history persistence (IRCv3 draft/chathistory)
197
261
  // ============================================================================
@@ -301,7 +365,7 @@ export class InMemoryMessageStore implements MessageStore {
301
365
  }
302
366
 
303
367
  record(message: StoredMessage): void {
304
- const key = message.chan.toLowerCase();
368
+ const key = caseFold('rfc1459', message.chan);
305
369
  let arr = this.byChan.get(key);
306
370
  if (arr === undefined) {
307
371
  arr = [];
@@ -315,7 +379,7 @@ export class InMemoryMessageStore implements MessageStore {
315
379
 
316
380
  query(q: HistoryQuery): StoredMessage[] {
317
381
  if (q.limit <= 0) return [];
318
- const arr = this.byChan.get(q.chan.toLowerCase());
382
+ const arr = this.byChan.get(caseFold('rfc1459', q.chan));
319
383
  if (arr === undefined || arr.length === 0) return [];
320
384
 
321
385
  switch (q.direction) {
@@ -335,14 +399,14 @@ export class InMemoryMessageStore implements MessageStore {
335
399
  }
336
400
 
337
401
  hasMsgid(chan: string, msgid: string): boolean {
338
- const arr = this.byChan.get(chan.toLowerCase());
402
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
339
403
  if (arr === undefined) return false;
340
404
  return arr.some((m) => m.msgid === msgid);
341
405
  }
342
406
 
343
407
  recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[] {
344
408
  if (limit <= 0) return [];
345
- const arr = this.byChan.get(chan.toLowerCase());
409
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
346
410
  if (arr === undefined || arr.length === 0) return [];
347
411
  let start = 0;
348
412
  if (sinceMsgid !== undefined) {
@@ -25,6 +25,17 @@ export interface CloakingConfig {
25
25
  readonly cloakedSuffix?: string | undefined;
26
26
  }
27
27
 
28
+ /**
29
+ * A single IRC operator credential. The {@link operReducer} consults the
30
+ * configured list to authenticate `OPER <name> <password>`. Both fields are
31
+ * compared verbatim; adapters are responsible for any hashing at the auth
32
+ * boundary if they layer one on top.
33
+ */
34
+ export interface OperCred {
35
+ readonly user: string;
36
+ readonly password: string;
37
+ }
38
+
28
39
  /**
29
40
  * Per-deployment server configuration. Adapters load this from a KV store
30
41
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
@@ -53,6 +64,17 @@ export interface ServerConfig {
53
64
  * (see `serverPassword` note).
54
65
  */
55
66
  readonly cloaking?: CloakingConfig | undefined;
67
+ /**
68
+ * IRC operator credentials consulted by the `OPER` reducer. `undefined`
69
+ * (or an empty array) disables oper authentication entirely — an `OPER`
70
+ * attempt then receives `491 ERR_NOOPERHOST`.
71
+ *
72
+ * Typed `ReadonlyArray<OperCred> | undefined` to mirror Zod's `.optional()`
73
+ * output under `exactOptionalPropertyTypes: true`; the parsed config always
74
+ * supplies an array (defaulting to `[]`), so a parsed config is assignable
75
+ * here.
76
+ */
77
+ readonly operCreds?: ReadonlyArray<OperCred> | undefined;
56
78
  maxChannelsPerUser: number;
57
79
  maxTargetsPerCommand: number;
58
80
  maxListEntries: number;
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type AccountStore,
4
+ InMemoryAccountStore,
5
+ type SaslPayload,
6
+ type SaslResult,
7
+ } from '../src/ports';
8
+
9
+ /** Convenience: a PLAIN payload. */
10
+ function plain(username: string, password: string): SaslPayload {
11
+ return { kind: 'PLAIN', username, password };
12
+ }
13
+
14
+ describe('InMemoryAccountStore — PLAIN', () => {
15
+ it('implements the AccountStore port', () => {
16
+ const store: AccountStore = new InMemoryAccountStore([
17
+ { username: 'alice', password: 'secret' },
18
+ ]);
19
+ expect(store).toBeDefined();
20
+ });
21
+
22
+ it('accepts valid PLAIN credentials and returns the account name', () => {
23
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
24
+ const out = store.verify('PLAIN', plain('alice', 'secret'));
25
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
26
+ });
27
+
28
+ it('rejects a wrong password', () => {
29
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
30
+ const out = store.verify('PLAIN', plain('alice', 'wrong'));
31
+ expect(out.ok).toBe(false);
32
+ });
33
+
34
+ it('rejects an unknown username', () => {
35
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
36
+ const out = store.verify('PLAIN', plain('bob', 'secret'));
37
+ expect(out.ok).toBe(false);
38
+ });
39
+
40
+ it('matches the first of several configured accounts', () => {
41
+ const store = new InMemoryAccountStore([
42
+ { username: 'alice', password: 'a-secret' },
43
+ { username: 'bob', password: 'b-secret' },
44
+ ]);
45
+ expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual<SaslResult>({
46
+ ok: true,
47
+ account: 'bob',
48
+ });
49
+ });
50
+
51
+ it('rejects when constructed with an empty account list', () => {
52
+ const store = new InMemoryAccountStore([]);
53
+ const out = store.verify('PLAIN', plain('alice', 'secret'));
54
+ expect(out.ok).toBe(false);
55
+ });
56
+
57
+ it('does not confuse credentials across accounts (no password cross-match)', () => {
58
+ const store = new InMemoryAccountStore([
59
+ { username: 'alice', password: 'a-secret' },
60
+ { username: 'bob', password: 'b-secret' },
61
+ ]);
62
+ // bob's password with alice's username must fail.
63
+ expect(store.verify('PLAIN', plain('alice', 'b-secret')).ok).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe('InMemoryAccountStore — mechanism handling', () => {
68
+ it('rejects any non-PLAIN mechanism with a failure result', () => {
69
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
70
+ const out = store.verify('EXTERNAL', { kind: 'RAW', data: 'cert-subject' });
71
+ expect(out.ok).toBe(false);
72
+ });
73
+
74
+ it('treats the mechanism name case-insensitively for PLAIN', () => {
75
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
76
+ const out = store.verify('plain', plain('alice', 'secret'));
77
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
78
+ });
79
+ });
80
+
81
+ describe('InMemoryAccountStore — failure reason', () => {
82
+ it('returns a human-readable reason on failure', () => {
83
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
84
+ const out = store.verify('PLAIN', plain('alice', 'wrong'));
85
+ if (out.ok) throw new Error('expected failure');
86
+ expect(out.reason.length).toBeGreaterThan(0);
87
+ });
88
+ });
@@ -0,0 +1,80 @@
1
+ import fc from 'fast-check';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { type CaseMapping, caseFold } from '../src/case-fold';
4
+
5
+ // ============================================================================
6
+ // caseFold — rfc1459 special-character folding
7
+ // ============================================================================
8
+
9
+ describe('caseFold — rfc1459', () => {
10
+ it('folds [Foo] to {foo}', () => {
11
+ expect(caseFold('rfc1459', '[Foo]')).toBe('{foo}');
12
+ });
13
+
14
+ it('folds uppercase A–Z to a–z', () => {
15
+ expect(caseFold('rfc1459', 'ABCXYZ')).toBe('abcxyz');
16
+ });
17
+
18
+ it('folds the four rfc1459 specials [ \\ ] ^ to { | } ~', () => {
19
+ expect(caseFold('rfc1459', '[\\]^')).toBe('{|}~');
20
+ });
21
+
22
+ it('does not fold { | } ~ any further', () => {
23
+ expect(caseFold('rfc1459', '{|}~')).toBe('{|}~');
24
+ });
25
+
26
+ it('leaves non-folding characters unchanged', () => {
27
+ expect(caseFold('rfc1459', 'a1_-.#@')).toBe('a1_-.#@');
28
+ });
29
+
30
+ it('returns the empty string unchanged', () => {
31
+ expect(caseFold('rfc1459', '')).toBe('');
32
+ });
33
+ });
34
+
35
+ // ============================================================================
36
+ // caseFold — ascii mapping (strict subset of rfc1459)
37
+ // ============================================================================
38
+
39
+ describe('caseFold — ascii', () => {
40
+ it('folds uppercase A–Z to a–z', () => {
41
+ expect(caseFold('ascii', 'ABCXYZ')).toBe('abcxyz');
42
+ });
43
+
44
+ it('does NOT fold the rfc1459 specials [ \\ ] ^', () => {
45
+ expect(caseFold('ascii', '[Foo]')).toBe('[foo]');
46
+ });
47
+
48
+ it('leaves non-folding characters unchanged', () => {
49
+ expect(caseFold('ascii', 'a1_-.#[]')).toBe('a1_-.#[]');
50
+ });
51
+ });
52
+
53
+ // ============================================================================
54
+ // caseFold — idempotency (property test)
55
+ // ============================================================================
56
+
57
+ describe('caseFold — idempotency', () => {
58
+ const mappings: CaseMapping[] = ['rfc1459', 'ascii'];
59
+
60
+ for (const mapping of mappings) {
61
+ it(`fold(fold(s)) === fold(s) under ${mapping}`, () => {
62
+ const nickish = fc.stringMatching(/[\x21-\x7e]{0,16}/u);
63
+ fc.assert(
64
+ fc.property(nickish, (s) => {
65
+ const once = caseFold(mapping, s);
66
+ expect(caseFold(mapping, once)).toBe(once);
67
+ }),
68
+ );
69
+ });
70
+ }
71
+
72
+ it('rfc1459 and ascii agree on pure A–Z / a–z input', () => {
73
+ const alpha = fc.stringMatching(/^[A-Za-z]{1,16}$/u);
74
+ fc.assert(
75
+ fc.property(alpha, (s) => {
76
+ expect(caseFold('rfc1459', s)).toBe(caseFold('ascii', s));
77
+ }),
78
+ );
79
+ });
80
+ });
@@ -134,6 +134,21 @@ describe('kickReducer — success', () => {
134
134
  );
135
135
  });
136
136
 
137
+ it('resolves the target using rfc1459 case-mapping ([ and { are equal)', () => {
138
+ const chan = makeChan('#foo');
139
+ addMember(chan, 'c1', 'alice', true);
140
+ addMember(chan, 'c2', 'foo[bar]');
141
+ const conn = makeConn('c1', 'alice');
142
+ const ctx = makeCtx(conn);
143
+
144
+ const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'FOO{BAR}'], tags: {} }, ctx);
145
+
146
+ expect(out.state.members.has('c2')).toBe(false);
147
+ expect(out.effects).toContainEqual<EffectType>(
148
+ Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),
149
+ );
150
+ });
151
+
137
152
  it('updates the kicker lastSeen to ctx.clock.now()', () => {
138
153
  const chan = makeChan('#foo');
139
154
  addMember(chan, 'c1', 'alice', true);
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { matchOperCred, operReducer } from '../../src/commands/oper';
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 baseServerConfig: 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
+ const serverConfigWithCreds: ServerConfig = {
22
+ ...baseServerConfig,
23
+ operCreds: [
24
+ { user: 'alice', password: 'secret' },
25
+ { user: 'bob', password: 'hunter2' },
26
+ ],
27
+ };
28
+
29
+ function makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWithCreds): Ctx {
30
+ return buildCtx({
31
+ serverConfig: config,
32
+ clock: new FakeClock(5_000),
33
+ ids: new SequentialIdFactory(),
34
+ motd: EmptyMotdProvider,
35
+ connection: state,
36
+ });
37
+ }
38
+
39
+ function makeState(): ConnectionState {
40
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
41
+ s.nick = 'alice';
42
+ s.user = 'alice';
43
+ s.host = 'example.com';
44
+ s.realname = 'Alice';
45
+ s.registration = 'registered';
46
+ return s;
47
+ }
48
+
49
+ const L = (text: string): RawLine => ({ text });
50
+
51
+ const oper = (name?: string, password?: string) =>
52
+ ({
53
+ command: 'OPER',
54
+ params: name === undefined ? [] : password === undefined ? [name] : [name, password],
55
+ tags: {},
56
+ }) as const;
57
+
58
+ describe('operReducer — successful authentication', () => {
59
+ it('sets userModes.oper and emits 381 RPL_YOUREOPER on matching credentials', () => {
60
+ const state = makeState();
61
+ const ctx = makeCtx(state);
62
+
63
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
64
+
65
+ expect(out.state.userModes.oper).toBe(true);
66
+ expect(out.effects).toEqual<EffectType[]>([
67
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
68
+ ]);
69
+ });
70
+
71
+ it('matches the second configured credential pair', () => {
72
+ const state = makeState();
73
+ const ctx = makeCtx(state);
74
+
75
+ const out = operReducer(state, oper('bob', 'hunter2'), ctx);
76
+
77
+ expect(out.state.userModes.oper).toBe(true);
78
+ });
79
+
80
+ it('returns the same state reference (mutation permitted, no copy)', () => {
81
+ const state = makeState();
82
+ const ctx = makeCtx(state);
83
+
84
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
85
+
86
+ expect(out.state).toBe(state);
87
+ });
88
+
89
+ it('updates connection lastSeen to ctx.clock.now()', () => {
90
+ const state = makeState();
91
+ expect(state.lastSeen).toBe(0);
92
+ const ctx = makeCtx(state);
93
+
94
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
95
+
96
+ expect(out.state.lastSeen).toBe(5_000);
97
+ });
98
+
99
+ it('accepts a lower-case command token', () => {
100
+ const state = makeState();
101
+ const ctx = makeCtx(state);
102
+
103
+ const out = operReducer(state, { command: 'oper', params: ['alice', 'secret'], tags: {} }, ctx);
104
+
105
+ expect(out.state.userModes.oper).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe('operReducer — already an operator (graceful no-op)', () => {
110
+ it('re-emits 381 without error when the connection is already oper', () => {
111
+ const state = makeState();
112
+ state.userModes.oper = true;
113
+ const ctx = makeCtx(state);
114
+
115
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
116
+
117
+ expect(out.state.userModes.oper).toBe(true);
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
120
+ ]);
121
+ });
122
+
123
+ it('does not require correct credentials when already oper', () => {
124
+ const state = makeState();
125
+ state.userModes.oper = true;
126
+ const ctx = makeCtx(state);
127
+
128
+ const out = operReducer(state, oper('alice', 'wrong'), ctx);
129
+
130
+ expect(out.state.userModes.oper).toBe(true);
131
+ expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
132
+ // Critical: never a 464 for an already-oper connection.
133
+ for (const e of out.effects) {
134
+ if (e.tag === 'Send') for (const line of e.lines) expect(line.text).not.toContain(' 464 ');
135
+ }
136
+ });
137
+ });
138
+
139
+ describe('operReducer — wrong credentials', () => {
140
+ it('emits 464 ERR_PASSWDMISMATCH for a wrong password', () => {
141
+ const state = makeState();
142
+ const ctx = makeCtx(state);
143
+
144
+ const out = operReducer(state, oper('alice', 'wrong'), ctx);
145
+
146
+ expect(out.state.userModes.oper).toBe(false);
147
+ expect(out.effects).toEqual<EffectType[]>([
148
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
149
+ ]);
150
+ });
151
+
152
+ it('emits 464 ERR_PASSWDMISMATCH for an unknown operator name', () => {
153
+ const state = makeState();
154
+ const ctx = makeCtx(state);
155
+
156
+ const out = operReducer(state, oper('mallory', 'whatever'), ctx);
157
+
158
+ expect(out.state.userModes.oper).toBe(false);
159
+ expect(out.effects).toEqual<EffectType[]>([
160
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
161
+ ]);
162
+ });
163
+ });
164
+
165
+ describe('operReducer — no credentials configured', () => {
166
+ it('emits 491 ERR_NOOPERHOST when operCreds is absent', () => {
167
+ const state = makeState();
168
+ const ctx = makeCtx(state, baseServerConfig);
169
+
170
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
171
+
172
+ expect(out.state.userModes.oper).toBe(false);
173
+ expect(out.effects).toEqual<EffectType[]>([
174
+ Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
175
+ ]);
176
+ });
177
+
178
+ it('emits 491 ERR_NOOPERHOST when operCreds is an empty array', () => {
179
+ const state = makeState();
180
+ const ctx = makeCtx(state, { ...baseServerConfig, operCreds: [] });
181
+
182
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
183
+
184
+ expect(out.state.userModes.oper).toBe(false);
185
+ expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
186
+ for (const e of out.effects) {
187
+ if (e.tag === 'Send') for (const line of e.lines) expect(line.text).toContain(' 491 ');
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('operReducer — missing parameters', () => {
193
+ it('emits 461 ERR_NEEDMOREPARAMS when no parameters are supplied', () => {
194
+ const state = makeState();
195
+ const ctx = makeCtx(state);
196
+
197
+ const out = operReducer(state, oper(), ctx);
198
+
199
+ expect(out.state.userModes.oper).toBe(false);
200
+ expect(out.effects).toEqual<EffectType[]>([
201
+ Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
202
+ ]);
203
+ });
204
+
205
+ it('emits 461 ERR_NEEDMOREPARAMS when only the name is supplied', () => {
206
+ const state = makeState();
207
+ const ctx = makeCtx(state);
208
+
209
+ const out = operReducer(state, oper('alice'), ctx);
210
+
211
+ expect(out.state.userModes.oper).toBe(false);
212
+ expect(out.effects).toEqual<EffectType[]>([
213
+ Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
214
+ ]);
215
+ });
216
+
217
+ it('uses "*" as the nick in 461 when the connection has no nick yet', () => {
218
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
219
+ const ctx = makeCtx(state);
220
+
221
+ const out = operReducer(state, oper(), ctx);
222
+
223
+ expect(out.effects).toEqual<EffectType[]>([
224
+ Effect.send('c1', [L(':irc.example.com 461 * OPER :Not enough parameters')]),
225
+ ]);
226
+ });
227
+ });
228
+
229
+ describe('matchOperCred', () => {
230
+ it('returns true when a credential pair matches exactly', () => {
231
+ const creds = [
232
+ { user: 'alice', password: 'secret' },
233
+ { user: 'bob', password: 'hunter2' },
234
+ ];
235
+ expect(matchOperCred(creds, 'bob', 'hunter2')).toBe(true);
236
+ });
237
+
238
+ it('returns false when the password does not match', () => {
239
+ const creds = [{ user: 'alice', password: 'secret' }];
240
+ expect(matchOperCred(creds, 'alice', 'nope')).toBe(false);
241
+ });
242
+
243
+ it('returns false when no user matches', () => {
244
+ const creds = [{ user: 'alice', password: 'secret' }];
245
+ expect(matchOperCred(creds, 'mallory', 'secret')).toBe(false);
246
+ });
247
+
248
+ it('returns false for an empty credential list', () => {
249
+ expect(matchOperCred([], 'alice', 'secret')).toBe(false);
250
+ });
251
+
252
+ it('does a case-sensitive comparison of both fields', () => {
253
+ const creds = [{ user: 'alice', password: 'secret' }];
254
+ expect(matchOperCred(creds, 'Alice', 'secret')).toBe(false);
255
+ expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);
256
+ });
257
+ });