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.
- package/.github/workflows/ci.yml +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { topicReducer } from '../../src/commands/topic';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EmptyMotdProvider,
|
|
7
|
+
FakeClock,
|
|
8
|
+
InMemoryServicesStore,
|
|
9
|
+
SequentialIdFactory,
|
|
10
|
+
type ServicesStore,
|
|
11
|
+
} from '../../src/ports';
|
|
6
12
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
7
13
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
8
14
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -19,13 +25,18 @@ const serverConfig: ServerConfig = {
|
|
|
19
25
|
quitMessage: 'Client Quit',
|
|
20
26
|
};
|
|
21
27
|
|
|
22
|
-
function makeCtx(
|
|
28
|
+
function makeCtx(
|
|
29
|
+
conn: ConnectionState,
|
|
30
|
+
clock = new FakeClock(1_000),
|
|
31
|
+
services?: ServicesStore,
|
|
32
|
+
): Ctx {
|
|
23
33
|
return buildCtx({
|
|
24
34
|
serverConfig,
|
|
25
35
|
clock,
|
|
26
36
|
ids: new SequentialIdFactory(),
|
|
27
37
|
motd: EmptyMotdProvider,
|
|
28
38
|
connection: conn,
|
|
39
|
+
...(services !== undefined ? { services } : {}),
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -335,3 +346,84 @@ describe('topicReducer — rejections', () => {
|
|
|
335
346
|
]);
|
|
336
347
|
});
|
|
337
348
|
});
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// topicReducer — ChanServ KEEPTOPIC persistence
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
describe('topicReducer — KEEPTOPIC persistence', () => {
|
|
355
|
+
it('persists a non-empty topic when KEEPTOPIC is on', () => {
|
|
356
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
357
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
358
|
+
services.registerChannel('#foo', 'alice');
|
|
359
|
+
services.setChannelKeepTopic('#foo', true);
|
|
360
|
+
const chan = makeChan('#foo');
|
|
361
|
+
addMember(chan, 'c1', 'alice', true);
|
|
362
|
+
const conn = makeConn();
|
|
363
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), services);
|
|
364
|
+
|
|
365
|
+
topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'remember me'], tags: {} }, ctx);
|
|
366
|
+
|
|
367
|
+
const stored = services.getChannelTopic('#foo');
|
|
368
|
+
expect(stored).toEqual({
|
|
369
|
+
text: 'remember me',
|
|
370
|
+
setter: 'alice!alice@example.com',
|
|
371
|
+
setAt: 2_000,
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('clears the persisted topic when the topic is cleared', () => {
|
|
376
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
377
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
378
|
+
services.registerChannel('#foo', 'alice');
|
|
379
|
+
services.setChannelKeepTopic('#foo', true);
|
|
380
|
+
services.setChannelTopic('#foo', { text: 'old', setter: 's', setAt: 0 });
|
|
381
|
+
const chan = makeChan('#foo');
|
|
382
|
+
addMember(chan, 'c1', 'alice', true);
|
|
383
|
+
const conn = makeConn();
|
|
384
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), services);
|
|
385
|
+
|
|
386
|
+
topicReducer(chan, { command: 'TOPIC', params: ['#foo', ''], tags: {} }, ctx);
|
|
387
|
+
|
|
388
|
+
expect(services.getChannelTopic('#foo')).toBeUndefined();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('does not persist when KEEPTOPIC is off', () => {
|
|
392
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
393
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
394
|
+
services.registerChannel('#foo', 'alice');
|
|
395
|
+
// keepTopic is false by default.
|
|
396
|
+
const chan = makeChan('#foo');
|
|
397
|
+
addMember(chan, 'c1', 'alice', true);
|
|
398
|
+
const conn = makeConn();
|
|
399
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), services);
|
|
400
|
+
|
|
401
|
+
topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'forget me'], tags: {} }, ctx);
|
|
402
|
+
|
|
403
|
+
expect(services.getChannelTopic('#foo')).toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('does not persist when no services store is bound', () => {
|
|
407
|
+
const chan = makeChan('#foo');
|
|
408
|
+
addMember(chan, 'c1', 'alice', true);
|
|
409
|
+
const conn = makeConn();
|
|
410
|
+
const ctx = makeCtx(conn);
|
|
411
|
+
|
|
412
|
+
// Should not throw — services is undefined.
|
|
413
|
+
const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'x'], tags: {} }, ctx);
|
|
414
|
+
|
|
415
|
+
expect(out.state.topic?.text).toBe('x');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('does not persist when the channel is not registered', () => {
|
|
419
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
420
|
+
const chan = makeChan('#foo');
|
|
421
|
+
addMember(chan, 'c1', 'alice', true);
|
|
422
|
+
const conn = makeConn();
|
|
423
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), services);
|
|
424
|
+
|
|
425
|
+
topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'x'], tags: {} }, ctx);
|
|
426
|
+
|
|
427
|
+
expect(services.getChannelTopic('#foo')).toBeUndefined();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified account behaviour: a NickServ registration IS a SASL account,
|
|
3
|
+
* and a SASL-created account IS a registered nick.
|
|
4
|
+
*
|
|
5
|
+
* Covers the cross-cutting acceptance contract for the unified account
|
|
6
|
+
* store:
|
|
7
|
+
* - `PRIVMSG NickServ :REGISTER <pw> <email>` creates a record that
|
|
8
|
+
* `AUTHENTICATE PLAIN <nick>:<pw>` authenticates against immediately.
|
|
9
|
+
* - A SASL-created account is treated as a registered nick for NickServ
|
|
10
|
+
* purposes (`INFO` resolves it; `SET ENFORCE` applies).
|
|
11
|
+
* - NickServ `IDENTIFY` and SASL success run the same downstream effects
|
|
12
|
+
* via the shared `applyAccountSuccess`.
|
|
13
|
+
* - Credentials live in one scrypt-hashed store; no plaintext password
|
|
14
|
+
* is persisted on a registered nick.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
import { applyAccountSuccess } from '../../src/commands/account-auth';
|
|
19
|
+
import { NICKSERV_NICK, nickservReducer } from '../../src/commands/nickserv';
|
|
20
|
+
import { authenticateReducer } from '../../src/commands/sasl';
|
|
21
|
+
import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
|
|
22
|
+
import type { Effect as EffectType } from '../../src/effects';
|
|
23
|
+
import type { AccountStore } from '../../src/ports';
|
|
24
|
+
import {
|
|
25
|
+
EmptyMotdProvider,
|
|
26
|
+
FakeClock,
|
|
27
|
+
InMemoryServicesStore,
|
|
28
|
+
type NickEnforcePolicy,
|
|
29
|
+
SequentialIdFactory,
|
|
30
|
+
type ServicesSnapshot,
|
|
31
|
+
} from '../../src/ports';
|
|
32
|
+
import { encodeBase64 } from '../../src/protocol/base64';
|
|
33
|
+
import type { IrcMessage } from '../../src/protocol/messages';
|
|
34
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
35
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
36
|
+
|
|
37
|
+
const NOW = 1_700_000_000_000;
|
|
38
|
+
const SV = 'irc.example.com';
|
|
39
|
+
|
|
40
|
+
const serverConfig: ServerConfig = {
|
|
41
|
+
serverName: SV,
|
|
42
|
+
networkName: 'ExampleNet',
|
|
43
|
+
maxChannelsPerUser: 30,
|
|
44
|
+
maxTargetsPerCommand: 10,
|
|
45
|
+
maxListEntries: 50,
|
|
46
|
+
nickLen: 30,
|
|
47
|
+
channelLen: 50,
|
|
48
|
+
topicLen: 390,
|
|
49
|
+
quitMessage: 'Client Quit',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
53
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
54
|
+
s.nick = nick;
|
|
55
|
+
s.user = nick;
|
|
56
|
+
s.host = 'example.com';
|
|
57
|
+
s.realname = nick;
|
|
58
|
+
s.registration = 'registered';
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function saslReadyState(nick = 'alice'): ConnectionState {
|
|
63
|
+
const s = createConnection({ id: 'c1', connectedSince: 0 });
|
|
64
|
+
s.nick = nick;
|
|
65
|
+
s.user = nick;
|
|
66
|
+
s.host = 'example.com';
|
|
67
|
+
s.realname = nick;
|
|
68
|
+
s.registration = 'registering';
|
|
69
|
+
s.capNegotiating = true;
|
|
70
|
+
s.caps.add('sasl');
|
|
71
|
+
return s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeCtx(
|
|
75
|
+
state: ConnectionState,
|
|
76
|
+
accounts?: AccountStore,
|
|
77
|
+
services?: InMemoryServicesStore,
|
|
78
|
+
): Ctx {
|
|
79
|
+
return buildCtx({
|
|
80
|
+
serverConfig,
|
|
81
|
+
clock: new FakeClock(NOW),
|
|
82
|
+
ids: new SequentialIdFactory(),
|
|
83
|
+
motd: EmptyMotdProvider,
|
|
84
|
+
connection: state,
|
|
85
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
86
|
+
...(services !== undefined ? { services } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function privmsg(body: string): IrcMessage {
|
|
91
|
+
return { command: 'PRIVMSG', params: [NICKSERV_NICK, body], tags: {} };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function authenticate(param: string): IrcMessage {
|
|
95
|
+
return { command: 'AUTHENTICATE', params: [param], tags: {} };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function plainPayload(authcid: string, password: string): string {
|
|
99
|
+
return encodeBase64(`\0${authcid}\0${password}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sentTexts(effects: EffectType[]): string[] {
|
|
103
|
+
return effects.flatMap((e) => (e.tag === 'Send' ? e.lines : [])).map((l) => l.text);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Snapshot with empty sections (helper for legacy-row fixtures). */
|
|
107
|
+
function emptySnapshot(): ServicesSnapshot {
|
|
108
|
+
return {
|
|
109
|
+
nicks: [],
|
|
110
|
+
channels: [],
|
|
111
|
+
access: [],
|
|
112
|
+
levels: [],
|
|
113
|
+
vhosts: [],
|
|
114
|
+
pendingVhosts: [],
|
|
115
|
+
memos: [],
|
|
116
|
+
readMarkers: [],
|
|
117
|
+
akills: [],
|
|
118
|
+
jupes: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// AC: NickServ REGISTER → SASL PLAIN succeeds with the same password
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
127
|
+
it('a freshly NickServ-registered nick authenticates via SASL PLAIN with no second registration', () => {
|
|
128
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
129
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
130
|
+
const regCtx = makeCtx(registrar, undefined, services);
|
|
131
|
+
nickservReducer(registrar, privmsg('REGISTER hunter2 alice@example.com'), regCtx);
|
|
132
|
+
|
|
133
|
+
// A second connection speaking SASL PLAIN with the same nick:password.
|
|
134
|
+
const saslConn = saslReadyState('alice');
|
|
135
|
+
const saslCtx = makeCtx(saslConn, undefined, services);
|
|
136
|
+
authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
|
|
137
|
+
const out = authenticateReducer(
|
|
138
|
+
saslConn,
|
|
139
|
+
authenticate(plainPayload('alice', 'hunter2')),
|
|
140
|
+
saslCtx,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(saslConn.account).toBe('alice');
|
|
144
|
+
expect(saslConn.userModes.registered).toBe(true);
|
|
145
|
+
const texts = sentTexts(out.effects);
|
|
146
|
+
expect(texts).toContain(
|
|
147
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
148
|
+
);
|
|
149
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('SASL PLAIN with the wrong password fails after NickServ REGISTER', () => {
|
|
153
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
154
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
155
|
+
nickservReducer(
|
|
156
|
+
registrar,
|
|
157
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
158
|
+
makeCtx(registrar, undefined, services),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const saslConn = saslReadyState('alice');
|
|
162
|
+
const saslCtx = makeCtx(saslConn, undefined, services);
|
|
163
|
+
authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
|
|
164
|
+
const out = authenticateReducer(
|
|
165
|
+
saslConn,
|
|
166
|
+
authenticate(plainPayload('alice', 'wrong')),
|
|
167
|
+
saslCtx,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(saslConn.account).toBeUndefined();
|
|
171
|
+
expect(sentTexts(out.effects)).toContain(`:${SV} 904 alice :SASL authentication failed`);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// AC: SASL-created account → treated as a registered nick by NickServ
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', () => {
|
|
180
|
+
it('NickServ INFO resolves an account seeded into the unified store', () => {
|
|
181
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
182
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
183
|
+
|
|
184
|
+
const conn = makeConn('c1', 'bob');
|
|
185
|
+
const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, undefined, services));
|
|
186
|
+
|
|
187
|
+
const texts = sentTexts(out.effects);
|
|
188
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: alice');
|
|
189
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Account: alice');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('NickServ isRegisteredNick resolves a seeded SASL account', () => {
|
|
193
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
194
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
195
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
196
|
+
expect(services.isRegisteredNick('ALICE')).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('NickServ SET ENFORCE applies to a seeded SASL account once identified', () => {
|
|
200
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
201
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
202
|
+
const conn = makeConn('c1', 'alice');
|
|
203
|
+
conn.account = 'alice';
|
|
204
|
+
conn.userModes.registered = true;
|
|
205
|
+
const out = nickservReducer(
|
|
206
|
+
conn,
|
|
207
|
+
privmsg('SET ENFORCE kill'),
|
|
208
|
+
makeCtx(conn, undefined, services),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(services.getNickEnforce('alice')).toBe<NickEnforcePolicy>('kill');
|
|
212
|
+
expect(sentTexts(out.effects)).toContain(
|
|
213
|
+
':NickServ!NickServ@services NOTICE alice :Enforcement for nick alice is now set to kill.',
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('NickServ IDENTIFY succeeds against a seeded SASL account', () => {
|
|
218
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
219
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
220
|
+
const conn = makeConn('c1', 'alice');
|
|
221
|
+
const out = nickservReducer(
|
|
222
|
+
conn,
|
|
223
|
+
privmsg('IDENTIFY hunter2'),
|
|
224
|
+
makeCtx(conn, undefined, services),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(conn.account).toBe('alice');
|
|
228
|
+
expect(conn.userModes.registered).toBe(true);
|
|
229
|
+
expect(sentTexts(out.effects)).toContain(
|
|
230
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('NickServ IDENTIFY with the wrong password fails against a seeded SASL account', () => {
|
|
235
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
236
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
237
|
+
const conn = makeConn('c1', 'alice');
|
|
238
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, undefined, services));
|
|
239
|
+
|
|
240
|
+
expect(conn.account).toBeUndefined();
|
|
241
|
+
expect(sentTexts(out.effects)).toContain(
|
|
242
|
+
':NickServ!NickServ@services NOTICE alice :Invalid password.',
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('a NickServ REGISTER does not overwrite an ingested SASL credential', () => {
|
|
247
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
248
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
249
|
+
const conn = makeConn('c1', 'alice');
|
|
250
|
+
const out = nickservReducer(
|
|
251
|
+
conn,
|
|
252
|
+
privmsg('REGISTER otherpw alice@example.com'),
|
|
253
|
+
makeCtx(conn, undefined, services),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(sentTexts(out.effects)).toContain(
|
|
257
|
+
':NickServ!NickServ@services NOTICE alice :Nickname alice is already registered.',
|
|
258
|
+
);
|
|
259
|
+
// The original credential still verifies.
|
|
260
|
+
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
261
|
+
expect(services.verifyNick('alice', 'otherpw').ok).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('ingestAccountCredential returns false when the nick is already registered', () => {
|
|
265
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
266
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
267
|
+
expect(services.ingestAccountCredential(hashAccountCredential('alice', 'otherpw'))).toBe(false);
|
|
268
|
+
// The original credential still verifies.
|
|
269
|
+
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// AC: IDENTIFY and SASL success run the same downstream effects
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
|
|
278
|
+
it('IDENTIFY delivers queued memos (parity with SASL success)', () => {
|
|
279
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
280
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
281
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'hello', sentAt: NOW });
|
|
282
|
+
|
|
283
|
+
const conn = makeConn('c1', 'alice');
|
|
284
|
+
const out = nickservReducer(
|
|
285
|
+
conn,
|
|
286
|
+
privmsg('IDENTIFY hunter2'),
|
|
287
|
+
makeCtx(conn, undefined, services),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(sentTexts(out.effects)).toContain(
|
|
291
|
+
':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] hello',
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('both paths share applyAccountSuccess for +r stamping', () => {
|
|
296
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
297
|
+
services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
|
|
298
|
+
const conn = makeConn('c1', 'alice');
|
|
299
|
+
const ctx = makeCtx(conn, undefined, services);
|
|
300
|
+
|
|
301
|
+
const effects = applyAccountSuccess(conn, ctx, 'alice');
|
|
302
|
+
|
|
303
|
+
expect(conn.account).toBe('alice');
|
|
304
|
+
expect(conn.userModes.registered).toBe(true);
|
|
305
|
+
// The shared helper emits 900 + 903.
|
|
306
|
+
expect(sentTexts(effects)).toContain(
|
|
307
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
308
|
+
);
|
|
309
|
+
expect(sentTexts(effects)).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ============================================================================
|
|
314
|
+
// AC: Credentials live in one scrypt-hashed store (no plaintext)
|
|
315
|
+
// ============================================================================
|
|
316
|
+
|
|
317
|
+
describe('T156 unified account: scrypt-hashed credentials at rest', () => {
|
|
318
|
+
it('registerNick stores a scrypt hash, never the plaintext password', () => {
|
|
319
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
320
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
321
|
+
|
|
322
|
+
const snap = services.snapshot();
|
|
323
|
+
expect(snap.nicks).toHaveLength(1);
|
|
324
|
+
const row = snap.nicks[0];
|
|
325
|
+
if (row === undefined) throw new Error('expected one nick row');
|
|
326
|
+
// The persisted credential must be a HashedAccountCredential.
|
|
327
|
+
expect(row.credential.algorithm).toBe('scrypt');
|
|
328
|
+
expect(row.credential.salt).not.toBe('');
|
|
329
|
+
expect(row.credential.hash).not.toBe('');
|
|
330
|
+
// Plaintext must not be present anywhere on the row.
|
|
331
|
+
expect(JSON.stringify(row)).not.toContain('hunter2');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('verifyNick validates the correct password and rejects a wrong one', () => {
|
|
335
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
336
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
337
|
+
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
338
|
+
expect(services.verifyNick('alice', 'wrong').ok).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('the snapshot does not carry a plaintext password field', () => {
|
|
342
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
343
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
344
|
+
const row = services.snapshot().nicks[0];
|
|
345
|
+
if (row === undefined) throw new Error('expected one nick row');
|
|
346
|
+
expect((row as unknown as Record<string, unknown>).password).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('a legacy plaintext-only row does not authenticate (no silent fallback)', () => {
|
|
350
|
+
const legacySnapshot: ServicesSnapshot = {
|
|
351
|
+
...emptySnapshot(),
|
|
352
|
+
nicks: [
|
|
353
|
+
{
|
|
354
|
+
nick: 'legacy',
|
|
355
|
+
account: 'legacy',
|
|
356
|
+
email: 'l@x',
|
|
357
|
+
createdAt: NOW,
|
|
358
|
+
enforce: 'none',
|
|
359
|
+
// Malformed credential — empty hash, rejected by verifyHashedPassword.
|
|
360
|
+
credential: { account: 'legacy', algorithm: 'scrypt', salt: 's', hash: '' },
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
};
|
|
364
|
+
const services = new InMemoryServicesStore({
|
|
365
|
+
clock: new FakeClock(NOW),
|
|
366
|
+
snapshot: legacySnapshot,
|
|
367
|
+
});
|
|
368
|
+
expect(services.verifyNick('legacy', 'plaintext').ok).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('round-trips a hashed credential across a snapshot reload', () => {
|
|
372
|
+
const original = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
373
|
+
original.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
374
|
+
const snap = original.snapshot();
|
|
375
|
+
const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
|
|
376
|
+
expect(reloaded.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
377
|
+
expect(reloaded.verifyNick('alice', 'wrong').ok).toBe(false);
|
|
378
|
+
// The plaintext never appears in the persisted row.
|
|
379
|
+
expect(JSON.stringify(snap.nicks[0])).not.toContain('hunter2');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// Adapter ingestion contract: ingestAccountCredential accepts HashedAccountCredential
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
387
|
+
describe('T156 unified account: ingestAccountCredential', () => {
|
|
388
|
+
it('creates a registered nick with default email / enforce from a hashed credential', () => {
|
|
389
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
390
|
+
const cred = hashAccountCredential('alice', 'hunter2');
|
|
391
|
+
expect(services.ingestAccountCredential(cred)).toBe(true);
|
|
392
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
393
|
+
const rec = services.getNick('alice');
|
|
394
|
+
expect(rec).toBeDefined();
|
|
395
|
+
if (rec !== undefined) {
|
|
396
|
+
expect(rec.account).toBe('alice');
|
|
397
|
+
expect(rec.enforce).toBe<NickEnforcePolicy>('none');
|
|
398
|
+
}
|
|
399
|
+
// verifyNick goes through the same scrypt path as registerNick.
|
|
400
|
+
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('preserves the original spelling of the account name', () => {
|
|
404
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
405
|
+
const cred: HashedAccountCredential = {
|
|
406
|
+
account: 'Alice',
|
|
407
|
+
algorithm: 'scrypt',
|
|
408
|
+
salt: 's',
|
|
409
|
+
hash: 'aGVsbG8=',
|
|
410
|
+
};
|
|
411
|
+
services.ingestAccountCredential(cred);
|
|
412
|
+
const rec = services.getNick('Alice');
|
|
413
|
+
expect(rec?.nick).toBe('Alice');
|
|
414
|
+
expect(rec?.account).toBe('Alice');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_CREATED_TEXT,
|
|
4
|
-
DEFAULT_SERVER_VERSION,
|
|
5
5
|
ServerConfigSchema,
|
|
6
6
|
formatCreatedAt,
|
|
7
7
|
parseServerConfig,
|
|
8
8
|
resolveServerVersion,
|
|
9
9
|
} from '../src/config';
|
|
10
10
|
|
|
11
|
+
// The schema default must track the irc-core package version so a deployment
|
|
12
|
+
// that omits `serverVersion` advertises the version it was shipped with, not
|
|
13
|
+
// a stale hardcoded placeholder.
|
|
14
|
+
const PACKAGE_VERSION = pkg.version as string;
|
|
15
|
+
|
|
11
16
|
// ============================================================================
|
|
12
17
|
// ServerConfigSchema — valid inputs
|
|
13
18
|
// ============================================================================
|
|
@@ -230,9 +235,9 @@ describe('ServerConfigSchema — serverVersion', () => {
|
|
|
230
235
|
expect(cfg.serverVersion).toBe('9.9.9');
|
|
231
236
|
});
|
|
232
237
|
|
|
233
|
-
it('defaults to
|
|
238
|
+
it('defaults to the irc-core package version when omitted', () => {
|
|
234
239
|
const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
|
|
235
|
-
expect(cfg.serverVersion).toBe(
|
|
240
|
+
expect(cfg.serverVersion).toBe(PACKAGE_VERSION);
|
|
236
241
|
});
|
|
237
242
|
|
|
238
243
|
it('rejects an empty version string', () => {
|
|
@@ -289,8 +294,8 @@ describe('resolveServerVersion', () => {
|
|
|
289
294
|
it('returns the configured value when present', () => {
|
|
290
295
|
expect(resolveServerVersion({ serverVersion: '1.2.3' })).toBe('1.2.3');
|
|
291
296
|
});
|
|
292
|
-
it('falls back to
|
|
293
|
-
expect(resolveServerVersion({})).toBe(
|
|
297
|
+
it('falls back to the irc-core package version when undefined', () => {
|
|
298
|
+
expect(resolveServerVersion({})).toBe(PACKAGE_VERSION);
|
|
294
299
|
});
|
|
295
300
|
});
|
|
296
301
|
|
|
@@ -903,3 +908,42 @@ describe('ServerConfigSchema — sts', () => {
|
|
|
903
908
|
).toThrowError(/sts/u);
|
|
904
909
|
});
|
|
905
910
|
});
|
|
911
|
+
|
|
912
|
+
// ============================================================================
|
|
913
|
+
// ServerConfigSchema — hostservAutoApproveVhosts
|
|
914
|
+
// ============================================================================
|
|
915
|
+
|
|
916
|
+
describe('ServerConfigSchema — hostservAutoApproveVhosts', () => {
|
|
917
|
+
it('defaults to false when omitted (queue mode is the default)', () => {
|
|
918
|
+
const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
|
|
919
|
+
expect(cfg.hostservAutoApproveVhosts).toBe(false);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('accepts an explicit true (auto-approve mode)', () => {
|
|
923
|
+
const cfg = parseServerConfig({
|
|
924
|
+
serverName: 's',
|
|
925
|
+
networkName: 'n',
|
|
926
|
+
hostservAutoApproveVhosts: true,
|
|
927
|
+
});
|
|
928
|
+
expect(cfg.hostservAutoApproveVhosts).toBe(true);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('accepts an explicit false (queue mode)', () => {
|
|
932
|
+
const cfg = parseServerConfig({
|
|
933
|
+
serverName: 's',
|
|
934
|
+
networkName: 'n',
|
|
935
|
+
hostservAutoApproveVhosts: false,
|
|
936
|
+
});
|
|
937
|
+
expect(cfg.hostservAutoApproveVhosts).toBe(false);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('rejects a non-boolean value', () => {
|
|
941
|
+
expect(() =>
|
|
942
|
+
parseServerConfig({
|
|
943
|
+
serverName: 's',
|
|
944
|
+
networkName: 'n',
|
|
945
|
+
hostservAutoApproveVhosts: 'yes',
|
|
946
|
+
}),
|
|
947
|
+
).toThrowError(/hostservAutoApproveVhosts/u);
|
|
948
|
+
});
|
|
949
|
+
});
|
|
@@ -94,6 +94,23 @@ describe('Effect constructors', () => {
|
|
|
94
94
|
except: 'c1',
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
it('EnforceNick carries conn, nick, policy, and graceMs', () => {
|
|
99
|
+
expect(Effect.enforceNick('c1', 'alice', 'kill', 0)).toEqual<EffectType>({
|
|
100
|
+
tag: 'EnforceNick',
|
|
101
|
+
conn: 'c1',
|
|
102
|
+
nick: 'alice',
|
|
103
|
+
policy: 'kill',
|
|
104
|
+
graceMs: 0,
|
|
105
|
+
});
|
|
106
|
+
expect(Effect.enforceNick('c1', 'alice', 'ghost', 30_000)).toEqual<EffectType>({
|
|
107
|
+
tag: 'EnforceNick',
|
|
108
|
+
conn: 'c1',
|
|
109
|
+
nick: 'alice',
|
|
110
|
+
policy: 'ghost',
|
|
111
|
+
graceMs: 30_000,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
97
114
|
});
|
|
98
115
|
|
|
99
116
|
describe('Effect tag set', () => {
|
|
@@ -108,6 +125,7 @@ describe('Effect tag set', () => {
|
|
|
108
125
|
'ApplyChannelDelta',
|
|
109
126
|
'SendToNick',
|
|
110
127
|
'BroadcastWallops',
|
|
128
|
+
'EnforceNick',
|
|
111
129
|
];
|
|
112
130
|
|
|
113
131
|
it.each(expectedTags)('exposes a constructor for the %s tag', (tag) => {
|
|
@@ -121,6 +139,7 @@ describe('Effect tag set', () => {
|
|
|
121
139
|
ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
|
|
122
140
|
SendToNick: Effect.sendToNick('n', 'c', []),
|
|
123
141
|
BroadcastWallops: Effect.broadcastWallops([]),
|
|
142
|
+
EnforceNick: Effect.enforceNick('c', 'n', 'none', 0),
|
|
124
143
|
};
|
|
125
144
|
expect(constructors[tag].tag).toBe(tag);
|
|
126
145
|
});
|