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
@@ -63,10 +63,11 @@ export interface IdFactory {
63
63
  }
64
64
 
65
65
  /**
66
- * Production id factory. Generates UUIDv4-shaped strings without relying
67
- * on `crypto` so the same code runs on Node 24, Cloudflare Workers, and
68
- * Lambda without per-platform type wrangling. Uniqueness is statistical;
69
- * for cryptographic IDs, adapters can supply their own {@link IdFactory}.
66
+ * Production id factory. Generates UUIDv4-shaped strings from a CSPRNG
67
+ * (`globalThis.crypto.getRandomValues`), which is available on Node 19+
68
+ * and Cloudflare Workers as a global. The randomness is cryptographic-
69
+ * grade, so generated ids are suitable as unforgeable msgids / session
70
+ * ids as well as opaque batch references.
70
71
  */
71
72
  export class UuidIdFactory implements IdFactory {
72
73
  batchId(): string {
@@ -83,22 +84,29 @@ export class UuidIdFactory implements IdFactory {
83
84
  }
84
85
  }
85
86
 
86
- // RFC 4122 v4 shape (random). Six fixed bits identify the version/variant;
87
- // the remaining 122 bits come from Math.random. Good enough for non-security
88
- // identifiers like BATCH ref ids and msgid tags.
89
87
  const HEX = '0123456789abcdef';
88
+
89
+ /**
90
+ * RFC 4122 v4 (random) UUID. Six fixed bits identify the version/variant;
91
+ * the remaining 122 bits are sourced from `globalThis.crypto.getRandomValues`
92
+ * as a single 16-byte buffer (one syscall per id). The global is used — not
93
+ * `node:crypto` — so the same code runs unmodified on Node and Cloudflare
94
+ * Workers.
95
+ */
90
96
  function uuidV4(): string {
97
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
91
98
  let out = '';
92
- for (let i = 0; i < 36; i++) {
93
- if (i === 8 || i === 13 || i === 18 || i === 23) {
94
- out += '-';
95
- } else if (i === 14) {
96
- out += '4';
97
- } else if (i === 19) {
98
- out += HEX[(Math.random() * 4) | 0 | 0x8];
99
- } else {
100
- out += HEX[(Math.random() * 16) | 0];
101
- }
99
+ let i = 0;
100
+ for (let b of bytes) {
101
+ // Version nibble (bits 48-51 of the 128-bit id) = 0b0100 (v4).
102
+ if (i === 6) b = (b & 0x0f) | 0x40;
103
+ // Variant nibble (bits 64-65) = 0b10 (RFC 4122 / Leach-Salz).
104
+ else if (i === 8) b = (b & 0x3f) | 0x80;
105
+ // Dashes sit at byte offsets 4, 6, 8, 10 between the 4-2-2-2-6 byte groups.
106
+ if (i === 4 || i === 6 || i === 8 || i === 10) out += '-';
107
+ out += HEX.charAt(b >> 4);
108
+ out += HEX.charAt(b & 0x0f);
109
+ i++;
102
110
  }
103
111
  return out;
104
112
  }
@@ -169,121 +177,6 @@ export class StaticMotdProvider implements MotdProvider {
169
177
  /** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
170
178
  export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
171
179
 
172
- /**
173
- * Parsed SASL credentials handed to an {@link AccountStore}.
174
- *
175
- * `PLAIN` is the structured form produced by base64-decoding and splitting
176
- * the `\0authzid\0authcid\0passwd` payload. `RAW` carries the decoded bytes
177
- * for any other mechanism the server wants to forward verbatim.
178
- */
179
- export type SaslPayload =
180
- | { kind: 'PLAIN'; username: string; password: string }
181
- | { kind: 'RAW'; data: string }
182
- | { kind: 'EXTERNAL'; identity: string };
183
-
184
- /**
185
- * Outcome of an {@link AccountStore.verify} call: either the canonical
186
- * account name to record on the connection, or a human-readable reason
187
- * for the failure that surfaces as `904 ERR_SASLFAIL`.
188
- */
189
- export type SaslResult = { ok: true; account: string } | { ok: false; reason: string };
190
-
191
- /**
192
- * Port that verifies SASL credentials against the deployment's account
193
- * backend.
194
- *
195
- * The method is **synchronous** on purpose: reducers must stay pure and
196
- * cannot await. Adapters that need an async lookup (D1, DynamoDB, Secrets
197
- * Manager) pre-load the credentials they need into a synchronously-readable
198
- * store (mirroring the {@link MotdProvider} pattern). For Phase 1 the
199
- * in-memory reference adapter and tests inject a fake directly.
200
- */
201
- export interface AccountStore {
202
- verify(mech: string, payload: SaslPayload): SaslResult;
203
- }
204
-
205
- /**
206
- * One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
207
- * The `username` is also the canonical account name recorded on the
208
- * connection for `extended-join` / `account-tag`.
209
- */
210
- export interface SaslAccountCredential {
211
- username: string;
212
- password: string;
213
- /**
214
- * Optional mTLS client-certificate subject (e.g. `CN=alice` or a
215
- * fingerprint hash). When set, SASL `EXTERNAL` authentications that
216
- * present this subject are mapped to {@link username} as the account.
217
- */
218
- certSubject?: string;
219
- }
220
-
221
- /**
222
- * Reference in-memory {@link AccountStore} backed by a static list of PLAIN
223
- * credentials seeded at construction.
224
- *
225
- * Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
226
- * pattern: the port is synchronous, so adapters that need an async backend
227
- * (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
228
- * at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
229
- * viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
230
- * is a documented follow-up that swaps in by replacing the construction call.
231
- *
232
- * Password comparison is constant-time to avoid the early-return timing
233
- * oracle a naive `===` would expose. Only `PLAIN` is verified; any other
234
- * mechanism returns failure (matching the reducer's mech gating).
235
- */
236
- export class InMemoryAccountStore implements AccountStore {
237
- private readonly creds: ReadonlyMap<string, string>;
238
- private readonly certMap: ReadonlyMap<string, string>;
239
-
240
- constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
241
- this.creds = new Map(credentials.map((c) => [c.username, c.password]));
242
- this.certMap = new Map(
243
- credentials
244
- .filter((c) => c.certSubject !== undefined)
245
- .map((c) => [c.certSubject as string, c.username]),
246
- );
247
- }
248
-
249
- verify(mech: string, payload: SaslPayload): SaslResult {
250
- const m = mech.toUpperCase();
251
- if (m === 'PLAIN' && payload.kind === 'PLAIN') {
252
- const expected = this.creds.get(payload.username);
253
- if (expected === undefined) {
254
- return { ok: false, reason: 'invalid credentials' };
255
- }
256
- if (!constantTimeEquals(payload.password, expected)) {
257
- return { ok: false, reason: 'invalid credentials' };
258
- }
259
- return { ok: true, account: payload.username };
260
- }
261
- if (m === 'EXTERNAL' && payload.kind === 'EXTERNAL') {
262
- const account = this.certMap.get(payload.identity);
263
- if (account === undefined) {
264
- return { ok: false, reason: 'untrusted certificate' };
265
- }
266
- return { ok: true, account };
267
- }
268
- return { ok: false, reason: `unsupported mechanism: ${mech}` };
269
- }
270
- }
271
-
272
- /**
273
- * Constant-time string equality. Compares every byte regardless of early
274
- * mismatches so a remote attacker cannot short-circuit the comparison via a
275
- * timing side-channel. Returns `false` when the lengths differ (the length
276
- * is not considered secret for SASL PLAIN passwords).
277
- */
278
- function constantTimeEquals(a: string, b: string): boolean {
279
- if (a.length !== b.length) return false;
280
- let diff = 0;
281
- for (let i = 0; i < a.length; i++) {
282
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
283
- }
284
- return diff === 0;
285
- }
286
-
287
180
  // ============================================================================
288
181
  // mTLS identity (SASL EXTERNAL)
289
182
  // ============================================================================
@@ -395,7 +288,7 @@ export interface TargetEntry {
395
288
  /**
396
289
  * Persistence port for IRCv3 `draft/chathistory` playback.
397
290
  *
398
- * Mirrors the {@link MotdProvider} / {@link AccountStore} pattern: the port
291
+ * Mirrors the {@link MotdProvider} pattern: the port
399
292
  * is **synchronous** (reducers must stay pure and free of IO) and injected
400
293
  * via {@link Ctx}. Adapters that need async storage (D1, R2, DynamoDB)
401
294
  * pre-load the relevant slice into a synchronously-readable store; Phase 1
@@ -744,6 +637,13 @@ export type NickEnforcePolicy = 'none' | 'ghost' | 'kill';
744
637
  * lands). Lookups against the store are case-insensitive (rfc1459-folded);
745
638
  * the returned `nick` / `account` preserve the original spelling so the
746
639
  * wire renders the user's preferred casing.
640
+ *
641
+ * `certSubjects` is the list of mTLS client-certificate subjects (e.g.
642
+ * `CN=alice`) attached to the account via {@link ServicesStore.addCertFP}.
643
+ * SASL EXTERNAL resolves the verified edge subject against this list
644
+ * (byte-exact comparison; subjects are NOT case-folded). Defaults to an
645
+ * empty array on a fresh registration; an upcoming NickServ `CERT` command
646
+ * surfaces the mutators to the user (Atheme parity).
747
647
  */
748
648
  export interface RegisteredNick {
749
649
  /** Display spelling stored at registration. */
@@ -756,6 +656,12 @@ export interface RegisteredNick {
756
656
  createdAt: number;
757
657
  /** Per-account nick-enforcement policy. */
758
658
  enforce: NickEnforcePolicy;
659
+ /**
660
+ * Client-certificate subjects attached to this account for SASL EXTERNAL
661
+ * (CertFP). Byte-exact comparisons; never case-folded. Empty on a fresh
662
+ * registration; populated via {@link ServicesStore.addCertFP}.
663
+ */
664
+ certSubjects: string[];
759
665
  }
760
666
 
761
667
  /**
@@ -943,6 +849,14 @@ export interface ServicesStore {
943
849
  * not registered (the policy stays {@link NickEnforcePolicy.None}).
944
850
  */
945
851
  setNickEnforce(nick: string, policy: NickEnforcePolicy): void;
852
+ /**
853
+ * Overwrites the stored credential for `nick` with a fresh scrypt hash of
854
+ * `newPassword` (the plaintext is NEVER persisted). Used by NickServ
855
+ * `SET PASSWORD`. No-op when the nick is not registered. Write-behind
856
+ * persistent subclasses re-persist the full nick row (hashed credential
857
+ * included) so a backend reload keeps `verifyNick` working.
858
+ */
859
+ setNickPassword(nick: string, newPassword: string): void;
946
860
  /**
947
861
  * Returns the nick-enforcement policy for `nick`, or
948
862
  * {@link NickEnforcePolicy.None} when the nick is not registered.
@@ -956,6 +870,34 @@ export interface ServicesStore {
956
870
  */
957
871
  getNick(nick: string): RegisteredNick | undefined;
958
872
 
873
+ /**
874
+ * Verifies a client-certificate subject (CertFP) for SASL EXTERNAL.
875
+ * Returns `{ ok: true, account }` for the account whose
876
+ * {@link RegisteredNick.certSubjects} contains `subject` (byte-exact,
877
+ * case-sensitive comparison — cert subjects are NOT folded), or
878
+ * `{ ok: false, reason }` when no registered nick carries `subject`.
879
+ *
880
+ * The edge (CF API Shield / AWS APIGW mTLS) still resolves the verified
881
+ * subject via {@link MtlsIdentityProvider}; only the subject→account
882
+ * lookup lives here.
883
+ */
884
+ verifyCertFP(subject: string): ServicesAuthResult;
885
+ /**
886
+ * Attaches a client-certificate subject to `nick` for SASL EXTERNAL.
887
+ * No-op (and returns `false`) when the nick is not registered.
888
+ * Byte-exact: a duplicate subject (case-sensitive) is a no-op (idempotent
889
+ * returns `true`). Future NickServ `CERT ADD` will surface this.
890
+ * Returns `true` when the subject was newly attached (or already
891
+ * present), `false` when the nick is unknown.
892
+ */
893
+ addCertFP(nick: string, subject: string): boolean;
894
+ /**
895
+ * Detaches a client-certificate subject from `nick`. Returns `true` when
896
+ * a subject was removed, `false` when the nick is unknown or the subject
897
+ * was not attached. Future NickServ `CERT DEL` will surface this.
898
+ */
899
+ removeCertFP(nick: string, subject: string): boolean;
900
+
959
901
  // ----- ChanServ -----
960
902
 
961
903
  /**
@@ -1344,7 +1286,7 @@ type StoredJupe = JupeEntry & { nameKey: string };
1344
1286
  *
1345
1287
  * Backed by plain `Map`s keyed by rfc1459-folded nick / account / channel.
1346
1288
  * Password verification uses the same scrypt path as the SASL
1347
- * {@link HashedAccountStore} ({@link verifyHashedPassword}) so a NickServ
1289
+ * {@link verifyHashedPassword} so a NickServ
1348
1290
  * registration IS a SASL account — both stores carry credentials in the
1349
1291
  * same hashed shape, and SASL PLAIN's services fallback verifies against
1350
1292
  * a row created by `NickServ REGISTER` without a second registration.
@@ -1405,7 +1347,11 @@ export class InMemoryServicesStore implements ServicesStore {
1405
1347
  */
1406
1348
  protected hydrate(snap: ServicesSnapshot): void {
1407
1349
  for (const row of snap.nicks) {
1408
- this.nicks.set(folded(row.nick), { ...row, credential: { ...row.credential } });
1350
+ this.nicks.set(folded(row.nick), {
1351
+ ...row,
1352
+ certSubjects: [...(row.certSubjects ?? [])],
1353
+ credential: { ...row.credential },
1354
+ });
1409
1355
  }
1410
1356
  for (const row of snap.channels) {
1411
1357
  const stored: RegisteredChannel = { ...row };
@@ -1477,7 +1423,11 @@ export class InMemoryServicesStore implements ServicesStore {
1477
1423
  snapshot(): ServicesSnapshot {
1478
1424
  const nicks: ServicesNickRow[] = [];
1479
1425
  for (const rec of this.nicks.values()) {
1480
- nicks.push({ ...rec, credential: { ...rec.credential } });
1426
+ nicks.push({
1427
+ ...rec,
1428
+ certSubjects: [...rec.certSubjects],
1429
+ credential: { ...rec.credential },
1430
+ });
1481
1431
  }
1482
1432
  const channels: RegisteredChannel[] = [];
1483
1433
  for (const rec of this.channels.values()) {
@@ -1554,6 +1504,7 @@ export class InMemoryServicesStore implements ServicesStore {
1554
1504
  email,
1555
1505
  createdAt: now,
1556
1506
  enforce: 'none',
1507
+ certSubjects: [],
1557
1508
  credential,
1558
1509
  });
1559
1510
  return { ok: true, account: nick };
@@ -1585,6 +1536,12 @@ export class InMemoryServicesStore implements ServicesStore {
1585
1536
  rec.enforce = policy;
1586
1537
  }
1587
1538
 
1539
+ setNickPassword(nick: string, newPassword: string): void {
1540
+ const rec = this.nicks.get(folded(nick));
1541
+ if (rec === undefined) return;
1542
+ rec.credential = hashAccountCredential(rec.account, newPassword);
1543
+ }
1544
+
1588
1545
  getNickEnforce(nick: string): NickEnforcePolicy {
1589
1546
  return this.nicks.get(folded(nick))?.enforce ?? 'none';
1590
1547
  }
@@ -1600,43 +1557,33 @@ export class InMemoryServicesStore implements ServicesStore {
1600
1557
  email: rec.email,
1601
1558
  createdAt: rec.createdAt,
1602
1559
  enforce: rec.enforce,
1560
+ certSubjects: [...rec.certSubjects],
1603
1561
  };
1604
1562
  }
1605
1563
 
1606
- /**
1607
- * Adopts a pre-hashed SASL account credential as a registered nick.
1608
- *
1609
- * This is the unification seam: an account seeded into the SASL
1610
- * `AccountStore` (the `accounts`/`Accounts` table, populated by the
1611
- * seed tooling) is treated as a NickServ registration so `INFO`
1612
- * resolves it, `SET ENFORCE` applies, and NickServ `IDENTIFY` succeeds
1613
- * with the seeded password. The resulting {@link RegisteredNick} carries
1614
- * an empty email, the default `'none'` enforcement policy, and
1615
- * `createdAt = clock.now()`; the supplied {@link HashedAccountCredential}
1616
- * is stored verbatim so `verifyNick` re-derives via the same scrypt
1617
- * path as `registerNick`.
1618
- *
1619
- * Returns `true` when a new registration was created; `false` when the
1620
- * nick (case-insensitive) is already registered — the existing record
1621
- * wins and the supplied credential is NOT overwritten. Idempotent in
1622
- * the sense that re-ingesting the same credential is a no-op.
1623
- *
1624
- * Adapters call this once per cold start, after both the SASL store and
1625
- * the services store have been loaded, to merge the SASL account set
1626
- * into the unified services account set.
1627
- */
1628
- ingestAccountCredential(credential: HashedAccountCredential): boolean {
1629
- const key = folded(credential.account);
1630
- if (this.nicks.has(key)) return false;
1631
- const account = credential.account;
1632
- this.nicks.set(key, {
1633
- nick: account,
1634
- account,
1635
- email: '',
1636
- createdAt: this.clock.now(),
1637
- enforce: 'none',
1638
- credential: { ...credential },
1639
- });
1564
+ verifyCertFP(subject: string): ServicesAuthResult {
1565
+ for (const rec of this.nicks.values()) {
1566
+ if (rec.certSubjects.includes(subject)) {
1567
+ return { ok: true, account: rec.account };
1568
+ }
1569
+ }
1570
+ return { ok: false, reason: 'untrusted certificate' };
1571
+ }
1572
+
1573
+ addCertFP(nick: string, subject: string): boolean {
1574
+ const rec = this.nicks.get(folded(nick));
1575
+ if (rec === undefined) return false;
1576
+ if (rec.certSubjects.includes(subject)) return true;
1577
+ rec.certSubjects.push(subject);
1578
+ return true;
1579
+ }
1580
+
1581
+ removeCertFP(nick: string, subject: string): boolean {
1582
+ const rec = this.nicks.get(folded(nick));
1583
+ if (rec === undefined) return false;
1584
+ const idx = rec.certSubjects.indexOf(subject);
1585
+ if (idx === -1) return false;
1586
+ rec.certSubjects.splice(idx, 1);
1640
1587
  return true;
1641
1588
  }
1642
1589
 
@@ -1654,7 +1601,13 @@ export class InMemoryServicesStore implements ServicesStore {
1654
1601
  */
1655
1602
  protected getStoredNick(nick: string): ServicesNickRow | undefined {
1656
1603
  const rec = this.nicks.get(folded(nick));
1657
- return rec === undefined ? undefined : { ...rec, credential: { ...rec.credential } };
1604
+ return rec === undefined
1605
+ ? undefined
1606
+ : {
1607
+ ...rec,
1608
+ certSubjects: [...rec.certSubjects],
1609
+ credential: { ...rec.credential },
1610
+ };
1658
1611
  }
1659
1612
 
1660
1613
  // ----- ChanServ -----
@@ -2127,13 +2080,40 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
2127
2080
  this.enqueue({ kind: 'upsertNick', row });
2128
2081
  }
2129
2082
 
2130
- override ingestAccountCredential(credential: HashedAccountCredential): boolean {
2131
- const created = super.ingestAccountCredential(credential);
2132
- if (created) {
2133
- const row = this.getStoredNick(credential.account) as ServicesNickRow | undefined;
2134
- if (row !== undefined) this.enqueue({ kind: 'upsertNick', row });
2083
+ override setNickPassword(nick: string, newPassword: string): void {
2084
+ if (!this.isRegisteredNick(nick)) return;
2085
+ super.setNickPassword(nick, newPassword);
2086
+ // The `isRegisteredNick` guard above guarantees the nick is present, so
2087
+ // `getStoredNick` is defined read it back to capture the freshly-hashed
2088
+ // credential so a backend reload keeps `verifyNick` working.
2089
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2090
+ this.enqueue({ kind: 'upsertNick', row });
2091
+ }
2092
+
2093
+ override addCertFP(nick: string, subject: string): boolean {
2094
+ if (!this.isRegisteredNick(nick)) return false;
2095
+ // Suppress the spurious write on an idempotent re-add (the subject is
2096
+ // already attached → super is a no-op). `getNick` returns a defensive
2097
+ // copy, so this check reflects the pre-call state. The
2098
+ // `isRegisteredNick` guard above guarantees the nick is present, so
2099
+ // `getNick` returns a defined record.
2100
+ const before = this.getNick(nick);
2101
+ const alreadyPresent = before?.certSubjects.includes(subject) === true;
2102
+ const attached = super.addCertFP(nick, subject);
2103
+ if (attached && !alreadyPresent) {
2104
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2105
+ this.enqueue({ kind: 'upsertNick', row });
2135
2106
  }
2136
- return created;
2107
+ return attached;
2108
+ }
2109
+
2110
+ override removeCertFP(nick: string, subject: string): boolean {
2111
+ const removed = super.removeCertFP(nick, subject);
2112
+ if (removed) {
2113
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2114
+ this.enqueue({ kind: 'upsertNick', row });
2115
+ }
2116
+ return removed;
2137
2117
  }
2138
2118
 
2139
2119
  // ----- ChanServ -----
@@ -2454,7 +2434,7 @@ export interface MonitorPush {
2454
2434
  * (nick → set of watcher connIds) and the **online set** the actor consults
2455
2435
  * when synchronously resolving `MONITOR +` / `MONITOR S`.
2456
2436
  *
2457
- * Mirrors the {@link MessageStore} / {@link AccountStore} pattern: the port
2437
+ * Mirrors the {@link MessageStore} pattern: the port
2458
2438
  * is **synchronous** (reducers must stay pure and free of IO). The actor
2459
2439
  * layer drains pending pushes after each command via {@link drainPushes};
2460
2440
  * adapters that need to fan notifications out across processes (CF
@@ -2681,7 +2661,7 @@ export interface ServerStatsSnapshot {
2681
2661
  /**
2682
2662
  * Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
2683
2663
  *
2684
- * Unlike the synchronous {@link MotdProvider} / {@link AccountStore} ports
2664
+ * Unlike the synchronous {@link MotdProvider} ports
2685
2665
  * (which reducers consume directly via {@link Ctx}), this port is
2686
2666
  * **asynchronous** (`Promise`-returning) because the aggregation crosses
2687
2667
  * every connection and channel in the deployment — it cannot be pre-loaded
@@ -9,7 +9,6 @@
9
9
  import type { Effect } from './effects.js';
10
10
  import type { FloodControlConfig } from './flood-control.js';
11
11
  import type {
12
- AccountStore,
13
12
  AwayStore,
14
13
  Clock,
15
14
  IdFactory,
@@ -63,6 +62,24 @@ export interface OperCred {
63
62
  readonly password: string;
64
63
  }
65
64
 
65
+ /**
66
+ * NickServ-specific tunables as seen by reducers. Mirrors the
67
+ * `nickServ` sub-schema of {@link ServerConfigSchema}; the NickServ
68
+ * `SET PASSWORD` reducer reads `minPasswordLength` (falling back to
69
+ * {@link DEFAULT_MIN_PASSWORD_LENGTH} when unset).
70
+ *
71
+ * Optional fields are widened to `T | undefined` to mirror Zod's
72
+ * `.optional()` output under `exactOptionalPropertyTypes: true`.
73
+ */
74
+ export interface NickServConfig {
75
+ /**
76
+ * Minimum length a new password must meet to be accepted by
77
+ * `SET PASSWORD`. Omitted → the reducer falls back to
78
+ * {@link DEFAULT_MIN_PASSWORD_LENGTH} (8).
79
+ */
80
+ readonly minPasswordLength?: number | undefined;
81
+ }
82
+
66
83
  /**
67
84
  * Per-deployment server configuration. Adapters load this from a KV store
68
85
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
@@ -209,6 +226,19 @@ export interface ServerConfig {
209
226
  * `exactOptionalPropertyTypes: true`.
210
227
  */
211
228
  readonly nickEnforceGraceMs?: number | undefined;
229
+ /**
230
+ * NickServ-specific tunables consulted by the NickServ reducer. Currently
231
+ * only the minimum length enforced by `SET PASSWORD` is configurable
232
+ * (`minPasswordLength`); the maximum (256) is fixed to bound the scrypt
233
+ * input budget.
234
+ *
235
+ * Typed `NickServConfig | undefined` to mirror Zod's optional output
236
+ * shape under `exactOptionalPropertyTypes: true` so inline
237
+ * `ServerConfig` literals in tests may omit it (the reducer falls back
238
+ * to {@link DEFAULT_MIN_PASSWORD_LENGTH} when `minPasswordLength` is
239
+ * unset).
240
+ */
241
+ readonly nickServ?: NickServConfig | undefined;
212
242
  /**
213
243
  * HostServ approval mode for `REQUEST <vhost>`. `true` (the parsed
214
244
  * default) preserves the legacy auto-approve behaviour — the vhost is
@@ -235,8 +265,6 @@ export interface Ctx {
235
265
  readonly ids: IdFactory;
236
266
  /** Pre-loaded MOTD source for the `MOTD` reducer. */
237
267
  readonly motd: MotdProvider;
238
- /** SASL account verification source (absent when no accounts are configured). */
239
- readonly accounts?: AccountStore;
240
268
  /**
241
269
  * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
242
270
  * is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
@@ -280,11 +308,13 @@ export interface Ctx {
280
308
  * marker via {@link ServicesStore.setLastReadMarker}, SASL identify seeds
281
309
  * the connection's `lastReadMarkers` from
282
310
  * {@link ServicesStore.listReadMarkers} so a reconnect restores the user's
283
- * read position, and SASL PLAIN falls back to this store for nicks
284
- * registered via NickServ. Omitted (→ `ctx.services` undefined) preserves
285
- * the no-services behaviour: `PRIVMSG NickServ :…` is not intercepted,
286
- * read-marker persistence stays session-only, and SASL PLAIN consults
287
- * only {@link Ctx.accounts}.
311
+ * read position, SASL PLAIN consults `services.verifyNick`, SASL EXTERNAL
312
+ * consults `services.verifyCertFP`, and PASS-based account auth
313
+ * (`PASS <nick>:<password>`) consults `services.verifyNick`. Omitted (→
314
+ * `ctx.services` undefined) preserves the no-services behaviour:
315
+ * `PRIVMSG NickServ :…` is not intercepted, read-marker persistence stays
316
+ * session-only, and every SASL/PASS credential verify fails with
317
+ * `904` / silent no-op.
288
318
  */
289
319
  readonly services?: ServicesStore;
290
320
  /** The connection invoking the command. Reducers mutate this freely. */
@@ -323,7 +353,6 @@ export interface BuildCtxOptions {
323
353
  clock: Clock;
324
354
  ids: IdFactory;
325
355
  motd: MotdProvider;
326
- accounts?: AccountStore;
327
356
  mtlsIdentity?: MtlsIdentityProvider;
328
357
  messages?: MessageStore;
329
358
  history?: NickHistoryStore;
@@ -349,7 +378,6 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
349
378
  motd: opts.motd,
350
379
  connection: opts.connection,
351
380
  connId: opts.connection.id,
352
- ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
353
381
  ...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
354
382
  ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
355
383
  ...(opts.history !== undefined ? { history: opts.history } : {}),