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
@@ -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
- exclude: ['src/**/index.ts'],
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,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -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,
@@ -150,16 +149,6 @@ export interface ConnectionActorOptions {
150
149
  * opt in.
151
150
  */
152
151
  readonly messages?: MessageStore;
153
- /**
154
- * SASL account verification source (absent when no accounts are
155
- * configured). When bound, `ctx.accounts` is defined so the
156
- * `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
157
- * Omitted (→ `ctx.accounts` undefined) preserves the existing
158
- * behaviour for callers/tests that do not opt in: a client completing
159
- * `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
160
- * can vouch for the credentials.
161
- */
162
- readonly accounts?: AccountStore;
163
152
  /**
164
153
  * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
165
154
  * is not configured). When bound, `ctx.mtlsIdentity` is defined so the
@@ -271,7 +260,6 @@ export class ConnectionActor {
271
260
  private readonly ids: IdFactory;
272
261
  private readonly motd: MotdProvider;
273
262
  private readonly messages: MessageStore | undefined;
274
- private readonly accounts: AccountStore | undefined;
275
263
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
276
264
  private readonly history: NickHistoryStore | undefined;
277
265
  private readonly away: AwayStore | undefined;
@@ -325,7 +313,6 @@ export class ConnectionActor {
325
313
  this.ids = opts.ids;
326
314
  this.motd = opts.motd ?? EmptyMotdProvider;
327
315
  this.messages = opts.messages;
328
- this.accounts = opts.accounts;
329
316
  this.mtlsIdentity = opts.mtlsIdentity;
330
317
  this.history = opts.history;
331
318
  this.away = opts.away;
@@ -511,7 +498,6 @@ export class ConnectionActor {
511
498
  motd: this.motd,
512
499
  connection: this.state,
513
500
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
514
- ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
515
501
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
516
502
  ...(this.history !== undefined ? { history: this.history } : {}),
517
503
  ...(this.away !== undefined ? { away: this.away } : {}),
@@ -1,5 +1,4 @@
1
1
  import {
2
- type AccountStore,
3
2
  type AwayStore,
4
3
  CapturingLogger,
5
4
  type ChanName,
@@ -26,8 +25,6 @@ import {
26
25
  type NickHistoryStore,
27
26
  Numerics,
28
27
  type RawLine,
29
- type SaslPayload,
30
- type SaslResult,
31
28
  SequentialIdFactory,
32
29
  type ServerConfig,
33
30
  type ServerStats,
@@ -219,7 +216,6 @@ function makeActor(opts: {
219
216
  logger?: Logger;
220
217
  traceId?: string;
221
218
  messages?: MessageStore;
222
- accounts?: AccountStore;
223
219
  mtlsIdentity?: MtlsIdentityProvider;
224
220
  history?: NickHistoryStore;
225
221
  stats?: ServerStats;
@@ -280,9 +276,6 @@ function makeActor(opts: {
280
276
  if (opts.messages !== undefined) {
281
277
  finalOpts.messages = opts.messages;
282
278
  }
283
- if (opts.accounts !== undefined) {
284
- finalOpts.accounts = opts.accounts;
285
- }
286
279
  if (opts.mtlsIdentity !== undefined) {
287
280
  finalOpts.mtlsIdentity = opts.mtlsIdentity;
288
281
  }
@@ -2129,17 +2122,7 @@ describe('ConnectionActor — CHATHISTORY', () => {
2129
2122
  // SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
2130
2123
  // ===========================================================================
2131
2124
 
2132
- describe('ConnectionActor — SASL AccountStore plumbing', () => {
2133
- /** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
2134
- class FakeAccountStore implements AccountStore {
2135
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
2136
- constructor(private readonly result: SaslResult) {}
2137
- verify(mech: string, payload: SaslPayload): SaslResult {
2138
- this.calls.push({ mech, payload });
2139
- return this.result;
2140
- }
2141
- }
2142
-
2125
+ describe('ConnectionActor — SASL ServicesStore plumbing', () => {
2143
2126
  /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
2144
2127
  function plainPayload(authcid: string, password: string): string {
2145
2128
  return encodeBase64(`\0${authcid}\0${password}`);
@@ -2154,11 +2137,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2154
2137
  };
2155
2138
  }
2156
2139
 
2157
- it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
2158
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2140
+ it('completes SASL PLAIN with 900/903 when a bound ServicesStore accepts creds', async () => {
2141
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2142
+ services.registerNick('alice', 'secret', 'alice@example.com');
2159
2143
  const { actor, runtime, conn } = makeActor({
2160
2144
  conn: saslReadyConn(),
2161
- accounts,
2145
+ services,
2162
2146
  });
2163
2147
  conn.nick = 'alice';
2164
2148
  runtime.calls.length = 0;
@@ -2173,18 +2157,14 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2173
2157
  expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
2174
2158
  // Critical: never 904 for valid creds.
2175
2159
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2176
- // The AccountStore was consulted with the parsed PLAIN credentials.
2177
- expect(accounts.calls).toEqual([
2178
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
2179
- ]);
2180
2160
  // Account name recorded on the connection for extended-join/account-tag.
2181
2161
  expect(conn.account).toBe('alice');
2182
2162
  });
2183
2163
 
2184
- it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
2164
+ it('emits 904 ERR_SASLFAIL when no ServicesStore is bound (regression guard)', async () => {
2185
2165
  const { actor, runtime, conn } = makeActor({
2186
2166
  conn: saslReadyConn(),
2187
- // No `accounts` option → ctx.accounts is undefined.
2167
+ // No `services` option → ctx.services is undefined.
2188
2168
  });
2189
2169
  conn.nick = 'alice';
2190
2170
  runtime.calls.length = 0;
@@ -2202,11 +2182,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2202
2182
  expect(conn.account).toBeUndefined();
2203
2183
  });
2204
2184
 
2205
- it('emits 904 when a bound AccountStore rejects the credentials', async () => {
2206
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
2185
+ it('emits 904 when a bound ServicesStore rejects the credentials', async () => {
2186
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2187
+ services.registerNick('alice', 'secret', 'alice@example.com');
2207
2188
  const { actor, runtime, conn } = makeActor({
2208
2189
  conn: saslReadyConn(),
2209
- accounts,
2190
+ services,
2210
2191
  });
2211
2192
  conn.nick = 'alice';
2212
2193
  runtime.calls.length = 0;
@@ -2223,16 +2204,6 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2223
2204
  });
2224
2205
 
2225
2206
  describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2226
- /** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
2227
- class FakeAccountStore implements AccountStore {
2228
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
2229
- constructor(private readonly result: SaslResult) {}
2230
- verify(mech: string, payload: SaslPayload): SaslResult {
2231
- this.calls.push({ mech, payload });
2232
- return this.result;
2233
- }
2234
- }
2235
-
2236
2207
  /** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
2237
2208
  class FakeMtlsProvider implements MtlsIdentityProvider {
2238
2209
  constructor(private readonly identity: string | undefined) {}
@@ -2250,11 +2221,13 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2250
2221
  }
2251
2222
 
2252
2223
  it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
2253
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2224
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2225
+ services.registerNick('alice', 'secret', 'alice@example.com');
2226
+ services.addCertFP('alice', 'CN=alice');
2254
2227
  const mtlsIdentity = new FakeMtlsProvider('CN=alice');
2255
2228
  const { actor, runtime, conn } = makeActor({
2256
2229
  conn: saslReadyConn(),
2257
- accounts,
2230
+ services,
2258
2231
  mtlsIdentity,
2259
2232
  });
2260
2233
  conn.nick = 'alice';
@@ -2268,17 +2241,16 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2268
2241
  expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
2269
2242
  expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
2270
2243
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2271
- expect(accounts.calls).toEqual([
2272
- { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
2273
- ]);
2274
2244
  expect(conn.account).toBe('alice');
2275
2245
  });
2276
2246
 
2277
2247
  it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
2278
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2248
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2249
+ services.registerNick('alice', 'secret', 'alice@example.com');
2250
+ services.addCertFP('alice', 'CN=alice');
2279
2251
  const { actor, runtime, conn } = makeActor({
2280
2252
  conn: saslReadyConn(),
2281
- accounts,
2253
+ services,
2282
2254
  // No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
2283
2255
  });
2284
2256
  conn.nick = 'alice';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -3,6 +3,7 @@ packages:
3
3
  - "apps/*"
4
4
  - "tools"
5
5
  - "tools/*"
6
+ - "scripts"
6
7
  linkWorkspacePackages: true
7
8
  allowBuilds:
8
9
  '@biomejs/biome': true