serverless-ircd 0.9.0 → 0.10.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/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type LegacyAccountRow,
4
+ buildMigrationSql,
5
+ buildServicesPutItem,
6
+ parseSaslAccountsForMigration,
7
+ } from '../src/account-migration';
8
+
9
+ describe('parseSaslAccountsForMigration', () => {
10
+ it('returns an empty array for undefined', () => {
11
+ expect(parseSaslAccountsForMigration(undefined)).toEqual([]);
12
+ });
13
+
14
+ it('returns an empty array for an empty string', () => {
15
+ expect(parseSaslAccountsForMigration('')).toEqual([]);
16
+ });
17
+
18
+ it('parses a single username:password entry', () => {
19
+ expect(parseSaslAccountsForMigration('alice:s3cret')).toEqual([
20
+ { username: 'alice', password: 's3cret' },
21
+ ]);
22
+ });
23
+
24
+ it('skips blank lines and malformed entries', () => {
25
+ expect(
26
+ parseSaslAccountsForMigration('alice:s3cret\n\nnocolon\n:emptyuser\nemptypass:\n \n'),
27
+ ).toEqual([{ username: 'alice', password: 's3cret' }]);
28
+ });
29
+ });
30
+
31
+ describe('buildMigrationSql', () => {
32
+ const legacyRow = (account: string, salt = 'salt', hash = 'hash'): LegacyAccountRow => ({
33
+ account,
34
+ algorithm: 'scrypt',
35
+ salt,
36
+ hash,
37
+ });
38
+
39
+ it('creates a nickserv_accounts row with cert_subjects "[]" for a legacy account', () => {
40
+ const stmts = buildMigrationSql([legacyRow('alice')], undefined);
41
+ expect(stmts[0]).toContain('CREATE TABLE IF NOT EXISTS nickserv_accounts');
42
+ expect(stmts[1]).toContain('INSERT OR IGNORE');
43
+ expect(stmts[1]).toContain("'alice'");
44
+ expect(stmts[1]).toContain("'[]'");
45
+ });
46
+
47
+ it('uses INSERT OR IGNORE (never REPLACE)', () => {
48
+ const stmts = buildMigrationSql([legacyRow('alice')], undefined);
49
+ expect(stmts[1]).toContain('INSERT OR IGNORE');
50
+ expect(stmts[1]).not.toContain('INSERT OR REPLACE');
51
+ });
52
+
53
+ it('is idempotent (same input → same output)', () => {
54
+ const stmts1 = buildMigrationSql([legacyRow('alice')], undefined);
55
+ const stmts2 = buildMigrationSql([legacyRow('alice')], undefined);
56
+ expect(stmts1).toEqual(stmts2);
57
+ });
58
+
59
+ it('carries SASL_ACCOUNTS env-seed entries not in the legacy table', () => {
60
+ const stmts = buildMigrationSql([legacyRow('alice')], 'bob:bobspw\nalice:alicespw');
61
+ expect(stmts).toHaveLength(3);
62
+ expect(stmts[1]).toContain("'alice'");
63
+ expect(stmts[1]).toContain("'salt'");
64
+ expect(stmts[2]).toContain("'bob'");
65
+ expect(stmts[2]).not.toContain("'bobspw'");
66
+ });
67
+
68
+ it('does not re-hash legacy rows', () => {
69
+ const stmts = buildMigrationSql([legacyRow('alice', 'mysalt', 'myhash')], undefined);
70
+ expect(stmts[1]).toContain("'mysalt'");
71
+ expect(stmts[1]).toContain("'myhash'");
72
+ });
73
+
74
+ it('produces only CREATE TABLE when both inputs are empty', () => {
75
+ const stmts = buildMigrationSql([], undefined);
76
+ expect(stmts).toHaveLength(1);
77
+ });
78
+
79
+ it('escapes single quotes in usernames', () => {
80
+ const stmts = buildMigrationSql([legacyRow("O'Brien")], undefined);
81
+ expect(stmts[1]).toContain("'O''Brien'");
82
+ });
83
+ });
84
+
85
+ describe('buildServicesPutItem', () => {
86
+ it('targets the NICK:<fold> partition', () => {
87
+ const input = buildServicesPutItem(
88
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
89
+ 'StagingServices',
90
+ );
91
+ expect(input.TableName).toBe('StagingServices');
92
+ expect(input.Item.pk).toBe('NICK:alice');
93
+ expect(input.Item.sk).toBe('#');
94
+ expect(input.Item.type).toBe('nick');
95
+ });
96
+
97
+ it('preserves the legacy scrypt credential fields', () => {
98
+ const input = buildServicesPutItem(
99
+ { account: 'alice', algorithm: 'scrypt', salt: 'mysalt', hash: 'myhash' },
100
+ 'Services',
101
+ );
102
+ expect(input.Item.algorithm).toBe('scrypt');
103
+ expect(input.Item.salt).toBe('mysalt');
104
+ expect(input.Item.hash).toBe('myhash');
105
+ });
106
+
107
+ it('defaults certSubjects to an empty array', () => {
108
+ const input = buildServicesPutItem(
109
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
110
+ 'Services',
111
+ );
112
+ expect(input.Item.certSubjects).toEqual([]);
113
+ });
114
+
115
+ it('folds the nick case-insensitively', () => {
116
+ const input = buildServicesPutItem(
117
+ { account: 'Alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
118
+ 'Services',
119
+ );
120
+ expect(input.Item.pk).toBe('NICK:alice');
121
+ expect(input.Item.nick).toBe('Alice');
122
+ });
123
+
124
+ it('stamps default email / enforce / createdAt', () => {
125
+ const input = buildServicesPutItem(
126
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
127
+ 'Services',
128
+ );
129
+ expect(input.Item.email).toBe('');
130
+ expect(input.Item.enforce).toBe('none');
131
+ expect(input.Item.createdAt).toBe(0);
132
+ });
133
+ });
@@ -115,6 +115,24 @@ describe('markreadChannelReducer — cap gating', () => {
115
115
  Effect.send('c1', [L(':irc.example.com 421 alice MARKREAD :Unknown command')]),
116
116
  ]);
117
117
  });
118
+
119
+ it('substitutes * for the nick in the 421 numeric when the connection has no nick', () => {
120
+ const chan = makeChan();
121
+ // A bare connection: no nick set and no draft/read-marker cap negotiated.
122
+ // The 421 numeric's `ctx.connection.nick ?? '*'` falls back to `*`.
123
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
124
+ const ctx = makeCtx(conn);
125
+
126
+ const out = markreadChannelReducer(
127
+ chan,
128
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
129
+ ctx,
130
+ );
131
+
132
+ expect(out.effects).toEqual<EffectType[]>([
133
+ Effect.send('c1', [L(':irc.example.com 421 * MARKREAD :Unknown command')]),
134
+ ]);
135
+ });
118
136
  });
119
137
 
120
138
  // ============================================================================
@@ -168,6 +186,42 @@ describe('markreadChannelReducer — parameter errors', () => {
168
186
  Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
169
187
  ]);
170
188
  });
189
+
190
+ it('FAILs with INVALID_PARAMS when the timestamp prefix is present but the value is empty', () => {
191
+ const chan = makeChan();
192
+ addMember(chan, 'c1', 'alice');
193
+ const store = new InMemoryMessageStore();
194
+ seed(store, '#foo', 3);
195
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
196
+
197
+ const out = markreadChannelReducer(
198
+ chan,
199
+ { command: 'MARKREAD', params: ['#foo', 'timestamp='], tags: {} },
200
+ ctx,
201
+ );
202
+
203
+ expect(out.effects).toEqual<EffectType[]>([
204
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
205
+ ]);
206
+ });
207
+
208
+ it('FAILs with INVALID_PARAMS when the timestamp value is not a parseable date', () => {
209
+ const chan = makeChan();
210
+ addMember(chan, 'c1', 'alice');
211
+ const store = new InMemoryMessageStore();
212
+ seed(store, '#foo', 3);
213
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
214
+
215
+ const out = markreadChannelReducer(
216
+ chan,
217
+ { command: 'MARKREAD', params: ['#foo', 'timestamp=not-a-date'], tags: {} },
218
+ ctx,
219
+ );
220
+
221
+ expect(out.effects).toEqual<EffectType[]>([
222
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
223
+ ]);
224
+ });
171
225
  });
172
226
 
173
227
  // ============================================================================
@@ -71,6 +71,25 @@ describe('memoservReducer — services availability', () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe('memoservReducer — empty body', () => {
75
+ it('replies with the help notice when the PRIVMSG carries no body parameter', () => {
76
+ const conn = makeConn();
77
+ const services = new InMemoryServicesStore();
78
+ const ctx = makeCtx(conn, services);
79
+ // params: [MEMOSERV_NICK] only — params[1] is undefined, exercising the
80
+ // `msg.params[1] ?? ''` fallback. The empty body resolves to an empty
81
+ // subcommand, which emits the help notice.
82
+ const out = memoservReducer(
83
+ conn,
84
+ { command: 'PRIVMSG', params: [MEMOSERV_NICK], tags: {} },
85
+ ctx,
86
+ );
87
+ expect(sentTexts(out.effects)).toContain(
88
+ ':MemoServ!MemoServ@services NOTICE alice :Available commands: SEND, LIST, READ, DEL',
89
+ );
90
+ });
91
+ });
92
+
74
93
  describe('memoservReducer — SEND', () => {
75
94
  it('requires the sender to be identified', () => {
76
95
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
@@ -622,7 +622,7 @@ describe('nickservReducer — misc', () => {
622
622
  const out = nickservReducer(conn, privmsg('FROBNICATE foo bar'), ctx);
623
623
 
624
624
  expect(sentTexts(out.effects)).toContain(
625
- ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET',
625
+ ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
626
626
  );
627
627
  });
628
628
 
@@ -857,6 +857,242 @@ describe('nickservReducer — SET ENFORCE', () => {
857
857
  });
858
858
  });
859
859
 
860
+ // ============================================================================
861
+ // SET PASSWORD
862
+ // ============================================================================
863
+
864
+ describe('nickservReducer — SET PASSWORD', () => {
865
+ it('changes the password when the caller supplies the correct old one', () => {
866
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
867
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
868
+ const conn = makeConn();
869
+ conn.account = 'alice';
870
+ conn.userModes.registered = true;
871
+ const ctx = makeCtx(conn, services);
872
+
873
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
874
+
875
+ expect(sentTexts(out.effects)).toContain(
876
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
877
+ );
878
+ // The new password verifies, the old one no longer does.
879
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(true);
880
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(false);
881
+ });
882
+
883
+ it('rejects a wrong old password and leaves the credential unchanged', () => {
884
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
885
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
886
+ const conn = makeConn();
887
+ conn.account = 'alice';
888
+ conn.userModes.registered = true;
889
+ const ctx = makeCtx(conn, services);
890
+
891
+ const out = nickservReducer(conn, privmsg('SET PASSWORD wrongold newpassword'), ctx);
892
+
893
+ expect(sentTexts(out.effects)).toContain(
894
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
895
+ );
896
+ // The original password still verifies; the new one does not.
897
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
898
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
899
+ });
900
+
901
+ it('requires identification before SET PASSWORD (unidentified caller)', () => {
902
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
903
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
904
+ const conn = makeConn();
905
+ // No account / +r.
906
+ const ctx = makeCtx(conn, services);
907
+
908
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
909
+
910
+ expect(sentTexts(out.effects)).toContain(
911
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing your password.',
912
+ );
913
+ // Credential unchanged.
914
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
915
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
916
+ });
917
+
918
+ it('rejects SET PASSWORD when identified as a different account', () => {
919
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
920
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
921
+ // bob is identified, but is using alice's nick.
922
+ services.registerNick('bob', 'bobpw', 'bob@example.com');
923
+ const conn = makeConn('c1', 'alice');
924
+ conn.account = 'bob';
925
+ conn.userModes.registered = true;
926
+ const ctx = makeCtx(conn, services);
927
+
928
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
929
+
930
+ expect(sentTexts(out.effects)).toContain(
931
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing your password.',
932
+ );
933
+ // Credential unchanged.
934
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
935
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
936
+ });
937
+
938
+ it('emits the syntax notice when args are missing', () => {
939
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
940
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
941
+ const conn = makeConn();
942
+ conn.account = 'alice';
943
+ conn.userModes.registered = true;
944
+ const ctx = makeCtx(conn, services);
945
+
946
+ const noArgs = nickservReducer(conn, privmsg('SET PASSWORD'), ctx);
947
+ expect(sentTexts(noArgs.effects)).toContain(
948
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET PASSWORD <old-password> <new-password>',
949
+ );
950
+
951
+ const oneArg = nickservReducer(conn, privmsg('SET PASSWORD only-one'), ctx);
952
+ expect(sentTexts(oneArg.effects)).toContain(
953
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET PASSWORD <old-password> <new-password>',
954
+ );
955
+ // Credential unchanged.
956
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
957
+ });
958
+
959
+ it('rejects a new password shorter than the default minimum (8)', () => {
960
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
961
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
962
+ const conn = makeConn();
963
+ conn.account = 'alice';
964
+ conn.userModes.registered = true;
965
+ const ctx = makeCtx(conn, services);
966
+
967
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 short'), ctx);
968
+
969
+ expect(sentTexts(out.effects)).toContain(
970
+ ':NickServ!NickServ@services NOTICE alice :Password is too short (minimum 8 characters).',
971
+ );
972
+ // Credential unchanged.
973
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
974
+ expect(services.verifyNick('alice', 'short').ok).toBe(false);
975
+ });
976
+
977
+ it('honours a configured nickServ.minPasswordLength override', () => {
978
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
979
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
980
+ const conn = makeConn();
981
+ conn.account = 'alice';
982
+ conn.userModes.registered = true;
983
+ const ctx: Ctx = buildCtx({
984
+ serverConfig: { ...serverConfig, nickServ: { minPasswordLength: 12 } },
985
+ clock: new FakeClock(NOW),
986
+ ids: new SequentialIdFactory(),
987
+ motd: EmptyMotdProvider,
988
+ connection: conn,
989
+ services,
990
+ });
991
+
992
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 elevenchars'), ctx);
993
+
994
+ expect(sentTexts(out.effects)).toContain(
995
+ ':NickServ!NickServ@services NOTICE alice :Password is too short (minimum 12 characters).',
996
+ );
997
+ // Credential unchanged.
998
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
999
+ });
1000
+
1001
+ it('rejects a new password longer than the maximum (256)', () => {
1002
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1003
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1004
+ const conn = makeConn();
1005
+ conn.account = 'alice';
1006
+ conn.userModes.registered = true;
1007
+ const ctx = makeCtx(conn, services);
1008
+
1009
+ const tooLong = 'x'.repeat(257);
1010
+ const out = nickservReducer(conn, privmsg(`SET PASSWORD hunter2 ${tooLong}`), ctx);
1011
+
1012
+ expect(sentTexts(out.effects)).toContain(
1013
+ ':NickServ!NickServ@services NOTICE alice :Password is too long (maximum 256 characters).',
1014
+ );
1015
+ // Credential unchanged.
1016
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1017
+ });
1018
+
1019
+ it('keeps the session +r after a successful change (no re-identify required)', () => {
1020
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1021
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1022
+ const conn = makeConn();
1023
+ conn.account = 'alice';
1024
+ conn.userModes.registered = true;
1025
+ const ctx = makeCtx(conn, services);
1026
+
1027
+ nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1028
+
1029
+ expect(conn.userModes.registered).toBe(true);
1030
+ expect(conn.account).toBe('alice');
1031
+ });
1032
+
1033
+ it('accepts a new password exactly at the minimum length boundary (8)', () => {
1034
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1035
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1036
+ const conn = makeConn();
1037
+ conn.account = 'alice';
1038
+ conn.userModes.registered = true;
1039
+ const ctx = makeCtx(conn, services);
1040
+
1041
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 exactly8'), ctx);
1042
+
1043
+ expect(sentTexts(out.effects)).toContain(
1044
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1045
+ );
1046
+ expect(services.verifyNick('alice', 'exactly8').ok).toBe(true);
1047
+ });
1048
+
1049
+ it('accepts a new password exactly at the maximum length boundary (256)', () => {
1050
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1051
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1052
+ const conn = makeConn();
1053
+ conn.account = 'alice';
1054
+ conn.userModes.registered = true;
1055
+ const ctx = makeCtx(conn, services);
1056
+
1057
+ const boundary = 'x'.repeat(256);
1058
+ const out = nickservReducer(conn, privmsg(`SET PASSWORD hunter2 ${boundary}`), ctx);
1059
+
1060
+ expect(sentTexts(out.effects)).toContain(
1061
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1062
+ );
1063
+ expect(services.verifyNick('alice', boundary).ok).toBe(true);
1064
+ });
1065
+
1066
+ it('is case-insensitive on the PASSWORD subkey', () => {
1067
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1068
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1069
+ const conn = makeConn();
1070
+ conn.account = 'alice';
1071
+ conn.userModes.registered = true;
1072
+ const ctx = makeCtx(conn, services);
1073
+
1074
+ const out = nickservReducer(conn, privmsg('set password hunter2 newpassword'), ctx);
1075
+
1076
+ expect(sentTexts(out.effects)).toContain(
1077
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1078
+ );
1079
+ });
1080
+
1081
+ it('rejects SET PASSWORD on an unregistered nick', () => {
1082
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1083
+ const conn = makeConn();
1084
+ conn.account = 'alice';
1085
+ conn.userModes.registered = true;
1086
+ const ctx = makeCtx(conn, services);
1087
+
1088
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1089
+
1090
+ expect(sentTexts(out.effects)).toContain(
1091
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is not registered.',
1092
+ );
1093
+ });
1094
+ });
1095
+
860
1096
  // ============================================================================
861
1097
  // enforceRegisteredNick — the enforcement hook fired by the NICK path
862
1098
  // ============================================================================
@@ -81,6 +81,21 @@ describe('operReducer — successful authentication', () => {
81
81
  expect(modeLines).toContain(':alice!alice@example.com MODE alice +o');
82
82
  });
83
83
 
84
+ it('falls back to ? / * in the MODE echo when the connection has no nick or hostmask', () => {
85
+ // A bare connection with no nick/user/host: hostmaskOf returns undefined
86
+ // and state.nick is undefined, so modeEchoLine falls back to '?' / '*'.
87
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
88
+ const ctx = makeCtx(state);
89
+
90
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
91
+
92
+ expect(out.state.userModes.oper).toBe(true);
93
+ const modeLines = out.effects
94
+ .filter((e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send')
95
+ .flatMap((e) => e.lines.map((l) => l.text));
96
+ expect(modeLines).toContain(':? MODE * +o');
97
+ });
98
+
84
99
  it('matches the second configured credential pair', () => {
85
100
  const state = makeState();
86
101
  const ctx = makeCtx(state);