serverless-ircd 0.8.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.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ReadMarkerEntry,
|
|
12
12
|
type RegisteredNick,
|
|
13
13
|
type ServicesAuthResult,
|
|
14
|
+
type ServicesSnapshot,
|
|
14
15
|
type ServicesStore,
|
|
15
16
|
} from '../src/ports';
|
|
16
17
|
|
|
@@ -116,6 +117,185 @@ describe('InMemoryServicesStore — NickServ IDENTIFY (verifyNick)', () => {
|
|
|
116
117
|
});
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// NickServ CertFP — hydrate round-trip (legacy snapshot without certSubjects)
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
|
|
125
|
+
it('defaults certSubjects to [] when a legacy snapshot row omits the field', () => {
|
|
126
|
+
// Simulate a pre-certSubjects snapshot: the row is missing the field
|
|
127
|
+
// entirely (as if loaded from JSON written before the column existed).
|
|
128
|
+
const legacySnap: ServicesSnapshot = {
|
|
129
|
+
nicks: [
|
|
130
|
+
{
|
|
131
|
+
nick: 'alice',
|
|
132
|
+
account: 'alice',
|
|
133
|
+
email: 'a@x',
|
|
134
|
+
createdAt: NOW,
|
|
135
|
+
enforce: 'none',
|
|
136
|
+
certSubjects: undefined as unknown as string[],
|
|
137
|
+
credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
channels: [],
|
|
141
|
+
access: [],
|
|
142
|
+
levels: [],
|
|
143
|
+
vhosts: [],
|
|
144
|
+
pendingVhosts: [],
|
|
145
|
+
memos: [],
|
|
146
|
+
readMarkers: [],
|
|
147
|
+
akills: [],
|
|
148
|
+
jupes: [],
|
|
149
|
+
};
|
|
150
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: legacySnap });
|
|
151
|
+
const rec = s.getNick('alice');
|
|
152
|
+
expect(rec).toBeDefined();
|
|
153
|
+
if (rec !== undefined) {
|
|
154
|
+
expect(rec.certSubjects).toEqual([]);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// NickServ CertFP — SASL EXTERNAL surface (verifyCertFP / addCertFP / removeCertFP)
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
164
|
+
it('returns ok with the account name for a known subject', () => {
|
|
165
|
+
const s = store();
|
|
166
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
167
|
+
s.addCertFP('alice', 'CN=alice');
|
|
168
|
+
expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
|
|
169
|
+
ok: true,
|
|
170
|
+
account: 'alice',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns ok:false for an unknown subject', () => {
|
|
175
|
+
const s = store();
|
|
176
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
177
|
+
s.addCertFP('alice', 'CN=alice');
|
|
178
|
+
const out = s.verifyCertFP('CN=evil');
|
|
179
|
+
expect(out.ok).toBe(false);
|
|
180
|
+
if (out.ok) return;
|
|
181
|
+
expect(out.reason.length).toBeGreaterThan(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('returns ok:false when no nick has any cert subject registered', () => {
|
|
185
|
+
const s = store();
|
|
186
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
187
|
+
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('matches the subject case-sensitively (byte-exact)', () => {
|
|
191
|
+
const s = store();
|
|
192
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
193
|
+
s.addCertFP('alice', 'CN=Alice');
|
|
194
|
+
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
195
|
+
expect(s.verifyCertFP('CN=Alice').ok).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('resolves the account that owns the subject (multiple accounts)', () => {
|
|
199
|
+
const s = store();
|
|
200
|
+
s.registerNick('alice', 'pw', 'a@e');
|
|
201
|
+
s.registerNick('bob', 'pw', 'b@e');
|
|
202
|
+
s.addCertFP('bob', 'CN=bob');
|
|
203
|
+
expect(s.verifyCertFP('CN=bob')).toEqual<ServicesAuthResult>({
|
|
204
|
+
ok: true,
|
|
205
|
+
account: 'bob',
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('exposes the registered subjects on the RegisteredNick record', () => {
|
|
210
|
+
const s = store();
|
|
211
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
212
|
+
s.addCertFP('alice', 'CN=alice');
|
|
213
|
+
s.addCertFP('alice', 'CN=alice-2');
|
|
214
|
+
const rec = s.getNick('alice');
|
|
215
|
+
expect(rec).toBeDefined();
|
|
216
|
+
if (rec === undefined) return;
|
|
217
|
+
expect(rec.certSubjects).toEqual(['CN=alice', 'CN=alice-2']);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('defaults certSubjects to an empty array on a fresh registration', () => {
|
|
221
|
+
const s = store();
|
|
222
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
223
|
+
const rec = s.getNick('alice');
|
|
224
|
+
expect(rec).toBeDefined();
|
|
225
|
+
if (rec === undefined) return;
|
|
226
|
+
expect(rec.certSubjects).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / removeCertFP)', () => {
|
|
231
|
+
it('addCertFP attaches a subject to a registered nick', () => {
|
|
232
|
+
const s = store();
|
|
233
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
234
|
+
s.addCertFP('alice', 'CN=alice');
|
|
235
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('addCertFP is a no-op on an unregistered nick', () => {
|
|
239
|
+
const s = store();
|
|
240
|
+
s.addCertFP('ghost', 'CN=ghost');
|
|
241
|
+
expect(s.verifyCertFP('CN=ghost').ok).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('addCertFP does not duplicate an already-attached subject', () => {
|
|
245
|
+
const s = store();
|
|
246
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
247
|
+
s.addCertFP('alice', 'CN=alice');
|
|
248
|
+
s.addCertFP('alice', 'CN=alice');
|
|
249
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('addCertFP folds the nick case-insensitively', () => {
|
|
253
|
+
const s = store();
|
|
254
|
+
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
255
|
+
s.addCertFP('alice', 'CN=alice');
|
|
256
|
+
expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
|
|
257
|
+
ok: true,
|
|
258
|
+
account: 'Alice',
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('removeCertFP detaches a subject from a registered nick', () => {
|
|
263
|
+
const s = store();
|
|
264
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
265
|
+
s.addCertFP('alice', 'CN=alice');
|
|
266
|
+
expect(s.removeCertFP('alice', 'CN=alice')).toBe(true);
|
|
267
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([]);
|
|
268
|
+
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('removeCertFP returns false when the subject is not attached', () => {
|
|
272
|
+
const s = store();
|
|
273
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
274
|
+
expect(s.removeCertFP('alice', 'CN=alice')).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('removeCertFP returns false on an unregistered nick', () => {
|
|
278
|
+
const s = store();
|
|
279
|
+
expect(s.removeCertFP('ghost', 'CN=ghost')).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('removeCertFP folds the nick case-insensitively but matches the subject byte-exactly', () => {
|
|
283
|
+
const s = store();
|
|
284
|
+
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
285
|
+
s.addCertFP('alice', 'CN=Alice');
|
|
286
|
+
expect(s.removeCertFP('ALICE', 'CN=alice')).toBe(false);
|
|
287
|
+
expect(s.removeCertFP('ALICE', 'CN=Alice')).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('dropNick removes the attached cert subjects along with the registration', () => {
|
|
291
|
+
const s = store();
|
|
292
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
293
|
+
s.addCertFP('alice', 'CN=alice');
|
|
294
|
+
s.dropNick('alice');
|
|
295
|
+
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
119
299
|
describe('InMemoryServicesStore — NickServ DROP (dropNick)', () => {
|
|
120
300
|
it('returns true and removes the registration for a known nick', () => {
|
|
121
301
|
const s = store();
|
|
@@ -175,6 +355,29 @@ describe('InMemoryServicesStore — NickServ SET ENFORCE (setNickEnforce)', () =
|
|
|
175
355
|
});
|
|
176
356
|
});
|
|
177
357
|
|
|
358
|
+
describe('InMemoryServicesStore — NickServ SET PASSWORD (setNickPassword)', () => {
|
|
359
|
+
it('overwrites the credential so the new password verifies and the old does not', () => {
|
|
360
|
+
const s = store();
|
|
361
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
362
|
+
s.setNickPassword('alice', 'newpassword');
|
|
363
|
+
expect(s.verifyNick('alice', 'newpassword').ok).toBe(true);
|
|
364
|
+
expect(s.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('folds the nick case-insensitively', () => {
|
|
368
|
+
const s = store();
|
|
369
|
+
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
370
|
+
s.setNickPassword('alice', 'newpassword');
|
|
371
|
+
expect(s.verifyNick('ALICE', 'newpassword').ok).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('is a no-op on an unregistered nick (the original credential, if any, is untouched)', () => {
|
|
375
|
+
const s = store();
|
|
376
|
+
s.setNickPassword('ghost', 'newpassword');
|
|
377
|
+
expect(s.isRegisteredNick('ghost')).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
178
381
|
describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
|
|
179
382
|
it('returns the full record for a registered nick', () => {
|
|
180
383
|
const s = store();
|
|
@@ -186,6 +389,7 @@ describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
|
|
|
186
389
|
email: 'alice@example.com',
|
|
187
390
|
createdAt: NOW,
|
|
188
391
|
enforce: 'none',
|
|
392
|
+
certSubjects: [],
|
|
189
393
|
});
|
|
190
394
|
});
|
|
191
395
|
|
|
@@ -8,7 +8,12 @@ export default defineConfig({
|
|
|
8
8
|
provider: 'v8',
|
|
9
9
|
all: false,
|
|
10
10
|
include: ['src/**/*.ts'],
|
|
11
|
-
|
|
11
|
+
// `index.ts` files are barrel re-exports (no runtime logic);
|
|
12
|
+
// `messages.ts` is type-only (`interface` declarations that
|
|
13
|
+
// TypeScript erases to an empty module). v8 scores both 0%
|
|
14
|
+
// because they contain no executable statements — excluding
|
|
15
|
+
// them keeps the 100% threshold honest about real coverage.
|
|
16
|
+
exclude: ['src/**/index.ts', 'src/**/messages.ts'],
|
|
12
17
|
reporter: ['text', 'html', 'json-summary'],
|
|
13
18
|
thresholds: {
|
|
14
19
|
lines: 100,
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import {
|
|
29
|
-
type AccountStore,
|
|
30
29
|
type AwayStore,
|
|
31
30
|
CHANSERV_NICK,
|
|
32
31
|
type ChanName,
|
|
@@ -56,6 +55,8 @@ import {
|
|
|
56
55
|
Numerics,
|
|
57
56
|
OPERSERV_NICK,
|
|
58
57
|
type RawLine,
|
|
58
|
+
type Reducer,
|
|
59
|
+
SHORTCUT_TO_SERVICE,
|
|
59
60
|
type ServerConfig,
|
|
60
61
|
type ServerStats,
|
|
61
62
|
type ServicesStore,
|
|
@@ -148,16 +149,6 @@ export interface ConnectionActorOptions {
|
|
|
148
149
|
* opt in.
|
|
149
150
|
*/
|
|
150
151
|
readonly messages?: MessageStore;
|
|
151
|
-
/**
|
|
152
|
-
* SASL account verification source (absent when no accounts are
|
|
153
|
-
* configured). When bound, `ctx.accounts` is defined so the
|
|
154
|
-
* `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
|
|
155
|
-
* Omitted (→ `ctx.accounts` undefined) preserves the existing
|
|
156
|
-
* behaviour for callers/tests that do not opt in: a client completing
|
|
157
|
-
* `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
|
|
158
|
-
* can vouch for the credentials.
|
|
159
|
-
*/
|
|
160
|
-
readonly accounts?: AccountStore;
|
|
161
152
|
/**
|
|
162
153
|
* mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
|
|
163
154
|
* is not configured). When bound, `ctx.mtlsIdentity` is defined so the
|
|
@@ -269,7 +260,6 @@ export class ConnectionActor {
|
|
|
269
260
|
private readonly ids: IdFactory;
|
|
270
261
|
private readonly motd: MotdProvider;
|
|
271
262
|
private readonly messages: MessageStore | undefined;
|
|
272
|
-
private readonly accounts: AccountStore | undefined;
|
|
273
263
|
private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
|
|
274
264
|
private readonly history: NickHistoryStore | undefined;
|
|
275
265
|
private readonly away: AwayStore | undefined;
|
|
@@ -323,7 +313,6 @@ export class ConnectionActor {
|
|
|
323
313
|
this.ids = opts.ids;
|
|
324
314
|
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
325
315
|
this.messages = opts.messages;
|
|
326
|
-
this.accounts = opts.accounts;
|
|
327
316
|
this.mtlsIdentity = opts.mtlsIdentity;
|
|
328
317
|
this.history = opts.history;
|
|
329
318
|
this.away = opts.away;
|
|
@@ -509,7 +498,6 @@ export class ConnectionActor {
|
|
|
509
498
|
motd: this.motd,
|
|
510
499
|
connection: this.state,
|
|
511
500
|
...(this.messages !== undefined ? { messages: this.messages } : {}),
|
|
512
|
-
...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
|
|
513
501
|
...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
|
|
514
502
|
...(this.history !== undefined ? { history: this.history } : {}),
|
|
515
503
|
...(this.away !== undefined ? { away: this.away } : {}),
|
|
@@ -527,6 +515,28 @@ export class ConnectionActor {
|
|
|
527
515
|
return this.accumulateMultilineLine(msg);
|
|
528
516
|
}
|
|
529
517
|
|
|
518
|
+
// Service shortcut commands (Atheme/Anope convention): rewrite verbs
|
|
519
|
+
// like `NICKSERV IDENTIFY pw` / `NS INFO` / `CS REGISTER #home` to the
|
|
520
|
+
// canonical `PRIVMSG <ServiceNick> :<joined args>` and delegate to the
|
|
521
|
+
// existing service-routing path (`routeMessageTarget` →
|
|
522
|
+
// `serviceReducerFor`). `SHORTCUT_TO_SERVICE` is the single source of
|
|
523
|
+
// truth for the alias → service nick mapping; the service reducers run
|
|
524
|
+
// unchanged against the rewritten `params=[serviceNick, body]` shape.
|
|
525
|
+
const serviceShortcutTarget = SHORTCUT_TO_SERVICE.get(msg.command.toUpperCase());
|
|
526
|
+
if (serviceShortcutTarget !== undefined) {
|
|
527
|
+
const rewritten: IrcMessage = {
|
|
528
|
+
...msg,
|
|
529
|
+
command: 'PRIVMSG',
|
|
530
|
+
params: [serviceShortcutTarget, msg.params.join(' ')],
|
|
531
|
+
};
|
|
532
|
+
return this.routeMessageTarget(
|
|
533
|
+
rewritten,
|
|
534
|
+
this.reducers.privmsgChannel,
|
|
535
|
+
this.reducers.privmsgUser,
|
|
536
|
+
ctx,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
530
540
|
switch (msg.command) {
|
|
531
541
|
// -------- Connection-authority commands --------
|
|
532
542
|
case 'PING':
|
|
@@ -1348,39 +1358,22 @@ export class ConnectionActor {
|
|
|
1348
1358
|
}
|
|
1349
1359
|
for (const target of targets) {
|
|
1350
1360
|
const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
|
|
1361
|
+
const serviceReducer = this.serviceReducerFor(target, msg.command);
|
|
1351
1362
|
if (isChannelTarget(target)) {
|
|
1352
1363
|
const chan = this.channels.getOrCreateChannel(target);
|
|
1353
1364
|
effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
|
|
1354
|
-
} else if (
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
effects.push(...
|
|
1366
|
-
} else if (
|
|
1367
|
-
msg.command === 'PRIVMSG' &&
|
|
1368
|
-
this.services !== undefined &&
|
|
1369
|
-
caseFold('rfc1459', target) === caseFold('rfc1459', HOSTSERV_NICK)
|
|
1370
|
-
) {
|
|
1371
|
-
effects.push(...this.reducers.hostserv(this.state, singleParamMsg, ctx).effects);
|
|
1372
|
-
} else if (
|
|
1373
|
-
msg.command === 'PRIVMSG' &&
|
|
1374
|
-
this.services !== undefined &&
|
|
1375
|
-
caseFold('rfc1459', target) === caseFold('rfc1459', MEMOSERV_NICK)
|
|
1376
|
-
) {
|
|
1377
|
-
effects.push(...this.reducers.memoserv(this.state, singleParamMsg, ctx).effects);
|
|
1378
|
-
} else if (
|
|
1379
|
-
msg.command === 'PRIVMSG' &&
|
|
1380
|
-
this.services !== undefined &&
|
|
1381
|
-
caseFold('rfc1459', target) === caseFold('rfc1459', OPERSERV_NICK)
|
|
1382
|
-
) {
|
|
1383
|
-
effects.push(...this.reducers.operserv(this.state, singleParamMsg, ctx).effects);
|
|
1365
|
+
} else if (serviceReducer !== undefined) {
|
|
1366
|
+
// Echo the sender's own PRIVMSG back BEFORE the service reducer's NOTICE
|
|
1367
|
+
// reply — matching how every other PRIVMSG renders (your outgoing line
|
|
1368
|
+
// first, then any reply). Without this ordering a client sees the
|
|
1369
|
+
// service's response above its own message, which reads as a bug.
|
|
1370
|
+
if (ctx.connection.caps.has('echo-message')) {
|
|
1371
|
+
const hostmask = hostmaskOf(this.state) ?? this.state.nick ?? '?';
|
|
1372
|
+
const text = singleParamMsg.params[1] ?? '';
|
|
1373
|
+
const echoLine: RawLine = { text: `:${hostmask} PRIVMSG ${target} :${text}` };
|
|
1374
|
+
effects.push(Effect.send(ctx.connId, [echoLine]));
|
|
1375
|
+
}
|
|
1376
|
+
effects.push(...serviceReducer(this.state, singleParamMsg, ctx).effects);
|
|
1384
1377
|
} else {
|
|
1385
1378
|
effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
|
|
1386
1379
|
}
|
|
@@ -1388,6 +1381,26 @@ export class ConnectionActor {
|
|
|
1388
1381
|
return effects;
|
|
1389
1382
|
}
|
|
1390
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* Resolves the service pseudo-client reducer for a nick target, or
|
|
1386
|
+
* `undefined` when the target is not a service nick / the command is not
|
|
1387
|
+
* `PRIVMSG` / no services store is bound. Centralised so the
|
|
1388
|
+
* `echo-message` echo (and any future service-routing concern) lives in a
|
|
1389
|
+
* single place rather than being duplicated across five near-identical
|
|
1390
|
+
* branches. `NOTICE` / `TAGMSG` to a service nick intentionally return
|
|
1391
|
+
* `undefined` so they fall through to the normal user-target reducer.
|
|
1392
|
+
*
|
|
1393
|
+
* The membership test is a table lookup against {@link SERVICE_REDUCER_KEYS}
|
|
1394
|
+
* (built from the same `*_NICK` constants that back
|
|
1395
|
+
* {@link SHORTCUT_TO_SERVICE}), so the service-nick list has one source of
|
|
1396
|
+
* truth rather than a per-service `if`-chain here.
|
|
1397
|
+
*/
|
|
1398
|
+
private serviceReducerFor(target: string, command: string): Reducer<ConnectionState> | undefined {
|
|
1399
|
+
if (command !== 'PRIVMSG' || this.services === undefined) return undefined;
|
|
1400
|
+
const key = SERVICE_REDUCER_KEYS.get(caseFold('rfc1459', target));
|
|
1401
|
+
return key === undefined ? undefined : this.reducers[key];
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1391
1404
|
private unknownCommandEffect(command: string): EffectType {
|
|
1392
1405
|
const nick = this.state.nick ?? '*';
|
|
1393
1406
|
const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
|
|
@@ -1416,6 +1429,29 @@ function splitCsv(param: string): string[] {
|
|
|
1416
1429
|
return param.split(',').filter((p) => p.length > 0);
|
|
1417
1430
|
}
|
|
1418
1431
|
|
|
1432
|
+
/**
|
|
1433
|
+
* Service reducer keys on {@link RoutedReducers}. Narrower than
|
|
1434
|
+
* `keyof RoutedReducers` so the table lookup resolves to a
|
|
1435
|
+
* `Reducer<ConnectionState>` without a widening cast.
|
|
1436
|
+
*/
|
|
1437
|
+
type ServiceReducerKey = 'nickserv' | 'chanserv' | 'hostserv' | 'memoserv' | 'operserv';
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* rfc1459 case-folded service nick → reducer key. The single source of
|
|
1441
|
+
* truth for "which targets are service nicks" — built from the canonical
|
|
1442
|
+
* `*_NICK` constants that also back {@link SHORTCUT_TO_SERVICE}, so the
|
|
1443
|
+
* dispatcher's shortcut rewrite and `serviceReducerFor`'s routing consult
|
|
1444
|
+
* one set of service nicks rather than each maintaining its own
|
|
1445
|
+
* hand-written `if`-chain.
|
|
1446
|
+
*/
|
|
1447
|
+
const SERVICE_REDUCER_KEYS: ReadonlyMap<string, ServiceReducerKey> = new Map([
|
|
1448
|
+
[caseFold('rfc1459', NICKSERV_NICK), 'nickserv'],
|
|
1449
|
+
[caseFold('rfc1459', CHANSERV_NICK), 'chanserv'],
|
|
1450
|
+
[caseFold('rfc1459', HOSTSERV_NICK), 'hostserv'],
|
|
1451
|
+
[caseFold('rfc1459', MEMOSERV_NICK), 'memoserv'],
|
|
1452
|
+
[caseFold('rfc1459', OPERSERV_NICK), 'operserv'],
|
|
1453
|
+
]);
|
|
1454
|
+
|
|
1419
1455
|
/**
|
|
1420
1456
|
* Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
|
|
1421
1457
|
* future single-server-aware query verb). Returns `undefined` when the
|