serverless-ircd 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -7,6 +7,11 @@
7
7
  */
8
8
 
9
9
  import { caseFold } from './case-fold.js';
10
+ import {
11
+ type HashedAccountCredential,
12
+ hashAccountCredential,
13
+ verifyHashedPassword,
14
+ } from './credential-hashing.js';
10
15
  import type { ChannelState } from './state/channel.js';
11
16
  import type { ConnId, ConnectionState } from './state/connection.js';
12
17
 
@@ -414,6 +419,23 @@ export interface MessageStore {
414
419
  recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[];
415
420
  /** Returns true iff `msgid` is currently retained for `chan`. */
416
421
  hasMsgid(chan: string, msgid: string): boolean;
422
+ /**
423
+ * Returns the message currently retained for `chan` under `msgid`, or
424
+ * `undefined` when it is absent (never recorded, or evicted by the
425
+ * retention cap). Used by `draft/read-marker` `MARKREAD` to resolve a
426
+ * stored marker msgid back to its message — both for the server's
427
+ * `MARKREAD` echo (the message's `time` tag) and for the monotonic
428
+ * timestamp comparison.
429
+ */
430
+ getMsg(chan: string, msgid: string): StoredMessage | undefined;
431
+ /**
432
+ * Returns the most recent retained message in `chan` whose `time` is less
433
+ * than or equal to `ts` (epoch ms), or `undefined` when no such message
434
+ * exists. Used by `draft/read-marker` `MARKREAD` to translate a
435
+ * client-supplied server-time timestamp into the msgid the
436
+ * msgid-based marker store keys on.
437
+ */
438
+ findAtOrBefore(chan: string, ts: number): StoredMessage | undefined;
417
439
  /** Enumerates channels with activity inside `[since, until]`, newest-first. */
418
440
  targets(since: number, until: number): TargetEntry[];
419
441
  }
@@ -481,6 +503,24 @@ export class InMemoryMessageStore implements MessageStore {
481
503
  return arr.some((m) => m.msgid === msgid);
482
504
  }
483
505
 
506
+ getMsg(chan: string, msgid: string): StoredMessage | undefined {
507
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
508
+ if (arr === undefined) return undefined;
509
+ return arr.find((m) => m.msgid === msgid);
510
+ }
511
+
512
+ findAtOrBefore(chan: string, ts: number): StoredMessage | undefined {
513
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
514
+ if (arr === undefined || arr.length === 0) return undefined;
515
+ // `arr` is chronological (oldest-first); walk from the newest back so the
516
+ // first hit is the most recent message at or before `ts`.
517
+ for (let i = arr.length - 1; i >= 0; i--) {
518
+ const m = arr[i];
519
+ if (m !== undefined && m.time <= ts) return m;
520
+ }
521
+ return undefined;
522
+ }
523
+
484
524
  recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[] {
485
525
  if (limit <= 0) return [];
486
526
  const arr = this.byChan.get(caseFold('rfc1459', chan));
@@ -587,7 +627,7 @@ function tail<T>(arr: readonly T[], n: number): T[] {
587
627
  *
588
628
  * `channel` is the lowercased channel name (the storage key); `msgid` is the
589
629
  * msgid of the most recent message the account has read in that channel.
590
- * Returned by {@link ReadMarkerStore.forAccount} when seeding a fresh
630
+ * Returned by {@link ServicesStore.listReadMarkers} when seeding a fresh
591
631
  * connection at identify time.
592
632
  */
593
633
  export interface ReadMarkerEntry {
@@ -599,93 +639,15 @@ export interface ReadMarkerEntry {
599
639
  msgid: string;
600
640
  }
601
641
 
602
- /**
603
- * Persistence port for IRCv3 `draft/read-marker`.
604
- *
605
- * The chathistory command keeps a per-`(connection, channel)`
606
- * last-read marker in {@link ConnectionState.lastReadMarkers}; this port
607
- * promotes that marker to a per-`(account, channel)` record so it survives
608
- * reconnects and is shared across an account's connections.
609
- *
610
- * Mirrors the {@link MessageStore} / {@link AccountStore} pattern: the port
611
- * is **synchronous** (reducers must stay pure and free of IO) and injected
612
- * via {@link Ctx}. Adapters that need async storage (D1, DynamoDB) pre-load
613
- * the relevant slice into a synchronously-readable store; the in-memory
614
- * reference implementation ({@link InMemoryReadMarkerStore}) ships here.
615
- *
616
- * Account and channel keys are both case-folded (rfc1459) so lookups are
617
- * case-insensitive, matching the rest of the server's comparison semantics.
618
- */
619
- export interface ReadMarkerStore {
620
- /**
621
- * Returns the stored last-read msgid for `(account, channel)`, or
622
- * `undefined` when no marker has been recorded.
623
- */
624
- get(account: string, channel: string): string | undefined;
625
- /**
626
- * Records (or replaces) the last-read msgid for `(account, channel)`.
627
- * Callers MUST only move the marker forward (towards newer messages);
628
- * the store itself does not order msgids, it stores the latest value set.
629
- */
630
- set(account: string, channel: string, msgid: string): void;
631
- /**
632
- * Enumerates every recorded marker for `account`. Used at identify time to
633
- * seed a fresh connection's {@link ConnectionState.lastReadMarkers} so a
634
- * reconnect restores the user's read position across all their channels.
635
- */
636
- forAccount(account: string): ReadonlyArray<ReadMarkerEntry>;
637
- }
638
-
639
- /**
640
- * Reference in-memory {@link ReadMarkerStore} backed by a
641
- * `foldedAccount → (foldedChannel → msgid)` nested map.
642
- *
643
- * Used by the local CLI, integration tests, and any deployment that has not
644
- * bound a distributed backend. Cloud adapters replace this with a D1 /
645
- * DynamoDB-backed implementation against the same surface.
646
- *
647
- * Both keys are folded via {@link caseFold} (`rfc1459`) so lookups are
648
- * case-insensitive; the returned {@link ReadMarkerEntry.channel} carries the
649
- * folded spelling and {@link ReadMarkerEntry.account} the canonical name
650
- * passed to {@link ReadMarkerStore.forAccount}.
651
- */
652
- export class InMemoryReadMarkerStore implements ReadMarkerStore {
653
- private readonly byAccount = new Map<string, Map<string, string>>();
654
-
655
- get(account: string, channel: string): string | undefined {
656
- return this.byAccount.get(folded(account))?.get(folded(channel));
657
- }
658
-
659
- set(account: string, channel: string, msgid: string): void {
660
- const acct = folded(account);
661
- let chanMap = this.byAccount.get(acct);
662
- if (chanMap === undefined) {
663
- chanMap = new Map<string, string>();
664
- this.byAccount.set(acct, chanMap);
665
- }
666
- chanMap.set(folded(channel), msgid);
667
- }
668
-
669
- forAccount(account: string): ReadonlyArray<ReadMarkerEntry> {
670
- const chanMap = this.byAccount.get(folded(account));
671
- if (chanMap === undefined) return [];
672
- const out: ReadMarkerEntry[] = [];
673
- for (const [channel, msgid] of chanMap) {
674
- out.push({ account, channel, msgid });
675
- }
676
- return out;
677
- }
678
- }
642
+ // ============================================================================
643
+ // Away-status persistence (IRCv3 draft/pre-away)
644
+ // ============================================================================
679
645
 
680
- /** rfc1459 case-fold used for both account and channel keys. */
646
+ /** rfc1459 case-fold used for account, nick, and channel keys. */
681
647
  function folded(value: string): string {
682
648
  return caseFold('rfc1459', value);
683
649
  }
684
650
 
685
- // ============================================================================
686
- // Away-status persistence (IRCv3 draft/pre-away)
687
- // ============================================================================
688
-
689
651
  /**
690
652
  * Persistence port for IRCv3 `draft/pre-away`.
691
653
  *
@@ -694,7 +656,7 @@ function folded(value: string): string {
694
656
  * port promotes that reason to a per-`account` record so it survives
695
657
  * reconnects and is shared across an account's connections.
696
658
  *
697
- * Mirrors the {@link ReadMarkerStore} pattern: the port is
659
+ * Mirrors the {@link ServicesStore} read-marker methods: the port is
698
660
  * **synchronous** (reducers must stay pure and free of IO) and injected
699
661
  * via {@link Ctx}. Adapters that need async storage (D1, DynamoDB) pre-load
700
662
  * the relevant slice into a synchronously-readable store; the in-memory
@@ -752,6 +714,1613 @@ export class InMemoryAwayStore implements AwayStore {
752
714
  }
753
715
  }
754
716
 
717
+ // ============================================================================
718
+ // IRC services persistence (NickServ / ChanServ / HostServ / MemoServ)
719
+ // ============================================================================
720
+
721
+ /**
722
+ * NickServ enforcement policy for a registered nick.
723
+ *
724
+ * Mirrors the Atheme/Anope `SET ENFORCE` modes. The default per registered
725
+ * account is {@link NickEnforcePolicy.None}; the NickServ reducer flips the
726
+ * policy via {@link ServicesStore.setNickEnforce} on `SET ENFORCE <mode>`.
727
+ *
728
+ * - `none` — unidentified use of the nick is permitted; NickServ merely
729
+ * sends a notice.
730
+ * - `ghost` — unidentified use is permitted for a grace period, then the
731
+ * connection is force-disconnected by the `EnforceNick`
732
+ * effect.
733
+ * - `kill` — unidentified use is refused immediately (force-disconnect
734
+ * with no grace).
735
+ */
736
+ export type NickEnforcePolicy = 'none' | 'ghost' | 'kill';
737
+
738
+ /**
739
+ * One NickServ registration record, created by `NickServ REGISTER` and
740
+ * stored against a {@link ServicesStore}.
741
+ *
742
+ * `nick` is the canonical / display spelling captured at registration;
743
+ * `account` is the SASL PLAIN username (equal to `nick` until nick grouping
744
+ * lands). Lookups against the store are case-insensitive (rfc1459-folded);
745
+ * the returned `nick` / `account` preserve the original spelling so the
746
+ * wire renders the user's preferred casing.
747
+ */
748
+ export interface RegisteredNick {
749
+ /** Display spelling stored at registration. */
750
+ nick: string;
751
+ /** Account name (SASL PLAIN username). Equal to `nick` until grouping. */
752
+ account: string;
753
+ /** Email captured at registration. */
754
+ email: string;
755
+ /** Epoch-ms the registration was recorded (from the injected Clock). */
756
+ createdAt: number;
757
+ /** Per-account nick-enforcement policy. */
758
+ enforce: NickEnforcePolicy;
759
+ }
760
+
761
+ /**
762
+ * Persisted topic snapshot for a channel with KEEPTOPIC enabled. Round-trips
763
+ * across the channel becoming empty: the TOPIC reducer writes here on every
764
+ * change, and the JOIN reducer consults it on the first join after the
765
+ * channel becomes empty so the topic is restored.
766
+ */
767
+ export interface PersistedTopic {
768
+ /** Topic text (may be empty after a clear). */
769
+ text: string;
770
+ /** Setter hostmask captured at the last topic change. */
771
+ setter: string;
772
+ /** Epoch-ms the topic was last changed. */
773
+ setAt: number;
774
+ }
775
+
776
+ /**
777
+ * One ChanServ registration record, created by `ChanServ REGISTER`.
778
+ *
779
+ * `founder` is the account name that ran REGISTER (transferable via
780
+ * `SET FOUNDER`). `mlock` is the stored mode-lock modestring (empty when
781
+ * no lock is set); `restricted` toggles the channel's `+R` mode;
782
+ * `keepTopic` toggles topic persistence across empty-recreate;
783
+ * `topic` carries the last known topic snapshot when `keepTopic` is on.
784
+ */
785
+ export interface RegisteredChannel {
786
+ /** Display spelling of the channel name stored at registration. */
787
+ channel: string;
788
+ /** Founder account (the account that ran REGISTER, or a transfer target). */
789
+ founder: string;
790
+ /** Epoch-ms the registration was recorded (from the injected Clock). */
791
+ createdAt: number;
792
+ /** Stored mode-lock modestring (empty when no lock is set). */
793
+ mlock: string;
794
+ /** Whether SET RESTRICTED is on (block join/PRIVMSG from unidentified). */
795
+ restricted: boolean;
796
+ /** Whether SET KEEPTOPIC is on (persist topic across empty-recreate). */
797
+ keepTopic: boolean;
798
+ /** Last known topic snapshot; present only while `keepTopic` is on. */
799
+ topic?: PersistedTopic;
800
+ }
801
+
802
+ /**
803
+ * Numeric ChanServ access level for an account on a channel. The numeric
804
+ * mapping follows the Atheme/Anope convention so the existing `SOP` /
805
+ * `AOP` / `VOP` / `HOP` shorthand maps cleanly:
806
+ *
807
+ * - `0` — no entry (returned by {@link ServicesStore.getChannelAccess}
808
+ * for unknown pairs).
809
+ * - `≤0` — treated as "remove" by {@link ServicesStore.setChannelAccess}.
810
+ * - `>0` — a stored access level.
811
+ */
812
+ export type ChannelAccessLevel = number;
813
+
814
+ /**
815
+ * Named ChanServ level thresholds a founder may redefine via `LEVELS SET`.
816
+ * The threshold is the numeric access level required for ChanServ to apply
817
+ * the matching channel prefix automatically on JOIN (or, for non-`AUTO*`
818
+ * ops reserved for future tickets, the level required to invoke a command).
819
+ *
820
+ * The three values below are the only thresholds the JOIN auto-prefix hook
821
+ * consults; {@link ServicesStore.getChannelLevel} returns `undefined` for
822
+ * unset ops so callers fall back to {@link DEFAULT_CHANNEL_LEVELS}.
823
+ */
824
+ export type ChannelLevelOp = 'AUTOOP' | 'AUTOHALFOP' | 'AUTOVOICE';
825
+
826
+ /**
827
+ * Outcome of {@link ServicesStore.registerNick} and
828
+ * {@link ServicesStore.verifyNick}: either the canonical account name on
829
+ * success, or a human-readable reason for the failure.
830
+ */
831
+ export type ServicesAuthResult = { ok: true; account: string } | { ok: false; reason: string };
832
+
833
+ /**
834
+ * Outcome of {@link ServicesStore.registerChannel}: either the founder
835
+ * account on success, or a human-readable reason for the failure.
836
+ */
837
+ export type ChannelRegisterResult = { ok: true; founder: string } | { ok: false; reason: string };
838
+
839
+ /**
840
+ * One queued MemoServ memo, recorded against the recipient's account and
841
+ * delivered on the recipient's next identify / SASL login.
842
+ */
843
+ export interface MemoEntry {
844
+ /** Monotonic memo id assigned by the store. */
845
+ id: number;
846
+ /** Sender account. */
847
+ from: string;
848
+ /** Recipient account (the `to` lookup key). */
849
+ to: string;
850
+ /** Memo body (single IRC-safe line; multi-line memos join with `\n`). */
851
+ body: string;
852
+ /** Epoch-ms the memo was recorded. */
853
+ sentAt: number;
854
+ /** Whether the recipient has marked the memo read. */
855
+ read: boolean;
856
+ }
857
+
858
+ /** Input shape for {@link ServicesStore.recordMemo}; the store assigns id + read. */
859
+ export type MemoInput = Omit<MemoEntry, 'id' | 'read'>;
860
+
861
+ /**
862
+ * One OperServ AKILL record: a network-wide ban mask plus the reason the
863
+ * oper recorded when adding it. Lookups against the store are
864
+ * case-insensitive on the mask via {@link matchAkill}; `mask` preserves
865
+ * the original spelling so the `AKILL LIST` reducer renders the oper's
866
+ * input verbatim.
867
+ */
868
+ export interface AkillEntry {
869
+ /** Ban mask (`nick!user@host` with `*` / `?` wildcards). */
870
+ mask: string;
871
+ /** Oper-supplied reason captured when the AKILL was added. */
872
+ reason: string;
873
+ /** Epoch-ms the AKILL was recorded (from the injected Clock). */
874
+ createdAt: number;
875
+ }
876
+
877
+ /**
878
+ * One OperServ JUPE record: a blocked nick / server name plus the reason
879
+ * the oper recorded when adding it. The NICK reducer rejects a jupe'd
880
+ * nick with `432 ERR_ERRONEUSNICKNAME`. `name` preserves the original
881
+ * spelling; lookups are case-insensitive (rfc1459-folded).
882
+ */
883
+ export interface JupeEntry {
884
+ /** Display spelling of the blocked nick / server name. */
885
+ name: string;
886
+ /** Oper-supplied reason captured when the JUPE was added. */
887
+ reason: string;
888
+ /** Epoch-ms the JUPE was recorded (from the injected Clock). */
889
+ createdAt: number;
890
+ }
891
+
892
+ /**
893
+ * Persistence port for the integrated IRC services (NickServ, ChanServ,
894
+ * HostServ, MemoServ) plus the per-account read-marker persistence for
895
+ * IRCv3 `draft/read-marker`.
896
+ *
897
+ * The services reducers ship in-process (no pseudo-server, no S2S); this
898
+ * port is the single persistence seam those reducers consult. Mirrors the
899
+ * {@link AccountStore} / {@link MessageStore} pattern: the port is
900
+ * **synchronous** (reducers must stay pure and free of IO) and is injected
901
+ * via {@link Ctx}. Adapters that need async storage (D1, DynamoDB)
902
+ * pre-load the relevant slice into a synchronously-readable store; the
903
+ * in-memory reference implementation ({@link InMemoryServicesStore}) ships
904
+ * alongside the port.
905
+ *
906
+ * All nick / account / channel keys are case-folded (rfc1459) so lookups
907
+ * are case-insensitive, matching the rest of the server's comparison
908
+ * semantics advertised via `005 RPL_ISUPPORT CASEMAPPING=rfc1459`. The
909
+ * read-marker methods ({@link ServicesStore.getLastReadMarker} /
910
+ * {@link ServicesStore.setLastReadMarker} / {@link ServicesStore.listReadMarkers})
911
+ * fold the previously standalone read-marker port into this store; the
912
+ * per-account away-reason port ({@link AwayStore}) stays independent until
913
+ * a follow-up folds it in too.
914
+ *
915
+ * Persistent adapter backends (D1 / DynamoDB) ship separately; the surface
916
+ * here is the minimum primitives the services reducers build on.
917
+ */
918
+ export interface ServicesStore {
919
+ // ----- NickServ -----
920
+
921
+ /**
922
+ * Registers `nick` against a new SASL account. Returns `{ ok: true }`
923
+ * with the canonical account name on success; `{ ok: false, reason }`
924
+ * when the nick is already registered (case-insensitive collision).
925
+ */
926
+ registerNick(nick: string, password: string, email: string): ServicesAuthResult;
927
+ /**
928
+ * Verifies `nick` / `password` against a prior registration. Returns
929
+ * `{ ok: true, account }` on a match (the canonical spelling), or
930
+ * `{ ok: false, reason }` when the nick is unknown or the password is
931
+ * wrong.
932
+ */
933
+ verifyNick(nick: string, password: string): ServicesAuthResult;
934
+ /**
935
+ * Removes the nick registration. Returns `true` when a registration was
936
+ * removed, `false` when the nick was not registered.
937
+ */
938
+ dropNick(nick: string): boolean;
939
+ /** `true` iff `nick` has a registration (case-insensitive). */
940
+ isRegisteredNick(nick: string): boolean;
941
+ /**
942
+ * Updates the nick-enforcement policy for `nick`. No-op when the nick is
943
+ * not registered (the policy stays {@link NickEnforcePolicy.None}).
944
+ */
945
+ setNickEnforce(nick: string, policy: NickEnforcePolicy): void;
946
+ /**
947
+ * Returns the nick-enforcement policy for `nick`, or
948
+ * {@link NickEnforcePolicy.None} when the nick is not registered.
949
+ */
950
+ getNickEnforce(nick: string): NickEnforcePolicy;
951
+ /**
952
+ * Returns the {@link RegisteredNick} record for `nick`, or `undefined`
953
+ * when the nick is not registered. Used by `NickServ INFO` and the
954
+ * enforcement hook to read the canonical account bound to a
955
+ * (case-insensitive) nick.
956
+ */
957
+ getNick(nick: string): RegisteredNick | undefined;
958
+
959
+ // ----- ChanServ -----
960
+
961
+ /**
962
+ * Registers `channel` with `founder` as the founder account. Returns
963
+ * `{ ok: true, founder }` on success; `{ ok: false, reason }` when the
964
+ * channel is already registered (case-insensitive collision).
965
+ */
966
+ registerChannel(channel: string, founder: string): ChannelRegisterResult;
967
+ /**
968
+ * Removes the channel registration (and its access list). Returns `true`
969
+ * when a registration was removed, `false` otherwise.
970
+ */
971
+ dropChannel(channel: string): boolean;
972
+ /**
973
+ * Returns the founder account for `channel`, or `undefined` when the
974
+ * channel is not registered.
975
+ */
976
+ getChannelFounder(channel: string): string | undefined;
977
+ /**
978
+ * Records (or replaces) the ChanServ access level for `account` on
979
+ * `channel`. A level ≤ 0 removes the entry.
980
+ */
981
+ setChannelAccess(channel: string, account: string, level: ChannelAccessLevel): void;
982
+ /**
983
+ * Returns the ChanServ access level for `account` on `channel`, or `0`
984
+ * when no entry exists.
985
+ */
986
+ getChannelAccess(channel: string, account: string): ChannelAccessLevel;
987
+ /**
988
+ * Enumerates every `(account, level)` access entry recorded for
989
+ * `channel`, oldest-first by insertion. Used by the ChanServ
990
+ * `ACCESS LIST` reducer to render the roster without leaking the
991
+ * store's internal map shape.
992
+ */
993
+ listChannelAccess(channel: string): ReadonlyArray<{ account: string; level: number }>;
994
+
995
+ /**
996
+ * Returns the founder-redefined threshold for `op` on `channel`, or
997
+ * `undefined` when no override is stored (the caller falls back to the
998
+ * default threshold). Used by the JOIN hook to decide whether to grant
999
+ * the channel prefix that matches `op` (e.g. `AUTOOP` → `@`).
1000
+ */
1001
+ getChannelLevel(channel: string, op: ChannelLevelOp): number | undefined;
1002
+ /**
1003
+ * Records (or replaces) the founder-redefined threshold for `op` on
1004
+ * `channel`. Called by the `LEVELS SET` reducer.
1005
+ */
1006
+ setChannelLevel(channel: string, op: ChannelLevelOp, level: number): void;
1007
+ /**
1008
+ * Returns every founder-redefined threshold for `channel` (oldest-first
1009
+ * by insertion). Used by the `LEVELS LIST` reducer; entries that the
1010
+ * founder never overrode are absent and the caller fills them in with
1011
+ * the defaults.
1012
+ */
1013
+ listChannelLevels(channel: string): ReadonlyArray<{ op: ChannelLevelOp; level: number }>;
1014
+ /**
1015
+ * Drops every founder-redefined threshold for `channel` so the defaults
1016
+ * take effect again. Called by the `LEVELS RESET` reducer and on
1017
+ * {@link dropChannel}.
1018
+ */
1019
+ resetChannelLevels(channel: string): void;
1020
+
1021
+ /**
1022
+ * ChanServ registration-record getters / mutators. Each corresponds to a
1023
+ * ChanServ `SET` subcommand. `setChannel*` calls are no-ops when the
1024
+ * channel is not registered (the reducer pre-validates founder).
1025
+ */
1026
+ /**
1027
+ * Returns the full {@link RegisteredChannel} record for `channel`, or
1028
+ * `undefined` when the channel is not registered.
1029
+ */
1030
+ getChannel(channel: string): RegisteredChannel | undefined;
1031
+ /** Transfers the founder of `channel` to `account` (SET FOUNDER). */
1032
+ setChannelFounder(channel: string, account: string): void;
1033
+ /** Records (or replaces) the MLOCK modestring for `channel`. */
1034
+ setChannelMlock(channel: string, modestring: string): void;
1035
+ /** Returns the MLOCK modestring for `channel`, or `''` when none is set. */
1036
+ getChannelMlock(channel: string): string;
1037
+ /** Toggles the restricted (block unidentified) flag for `channel`. */
1038
+ setChannelRestricted(channel: string, on: boolean): void;
1039
+ /** Toggles the KEEPTOPIC flag for `channel`. */
1040
+ setChannelKeepTopic(channel: string, on: boolean): void;
1041
+ /**
1042
+ * Records (or replaces) the persisted topic snapshot for `channel`. Called
1043
+ * by the TOPIC reducer when KEEPTOPIC is on. Pass `undefined` to clear.
1044
+ */
1045
+ setChannelTopic(channel: string, topic: PersistedTopic | undefined): void;
1046
+ /** Returns the persisted topic snapshot for `channel`, or `undefined`. */
1047
+ getChannelTopic(channel: string): PersistedTopic | undefined;
1048
+
1049
+ // ----- HostServ -----
1050
+
1051
+ /**
1052
+ * Records (or replaces) the vhost for `account`. An empty string clears
1053
+ * any previously-recorded vhost.
1054
+ */
1055
+ setVhost(account: string, vhost: string): void;
1056
+ /** Returns the vhost for `account`, or `undefined` when none is set. */
1057
+ getVhost(account: string): string | undefined;
1058
+
1059
+ /**
1060
+ * Records (or replaces) a **pending** vhost request for `account`. The
1061
+ * request is held until an oper approves it via {@link approveVhost} or
1062
+ * drops it via {@link rejectVhost}; the vhost is NOT activated (no
1063
+ * {@link setVhost} call) while it remains pending.
1064
+ */
1065
+ requestVhost(account: string, vhost: string): void;
1066
+ /**
1067
+ * Returns the pending vhost request for `account`, or `undefined` when
1068
+ * no request is queued.
1069
+ */
1070
+ getPendingVhost(account: string): string | undefined;
1071
+ /**
1072
+ * Enumerates every pending `(account, vhost)` request, oldest-first by
1073
+ * insertion. Used by the HostServ `LIST` (oper-only) reducer.
1074
+ */
1075
+ listPendingVhosts(): ReadonlyArray<{ account: string; vhost: string }>;
1076
+ /**
1077
+ * Approves the pending vhost request for `account`: removes the
1078
+ * pending entry and activates the vhost via {@link setVhost}. Returns
1079
+ * the activated vhost, or `undefined` when no request was pending.
1080
+ */
1081
+ approveVhost(account: string): string | undefined;
1082
+ /**
1083
+ * Rejects (drops) the pending vhost request for `account`. The vhost
1084
+ * is NOT activated. Returns the rejected vhost, or `undefined` when
1085
+ * no request was pending.
1086
+ */
1087
+ rejectVhost(account: string): string | undefined;
1088
+
1089
+ // ----- MemoServ -----
1090
+
1091
+ /**
1092
+ * Records a memo against the recipient's account and returns the
1093
+ * freshly-assigned monotonic id.
1094
+ */
1095
+ recordMemo(memo: MemoInput): number;
1096
+ /**
1097
+ * Returns every memo addressed to `account` (oldest-first), or an empty
1098
+ * array when none are recorded.
1099
+ */
1100
+ listMemos(account: string): ReadonlyArray<MemoEntry>;
1101
+ /**
1102
+ * Marks memo `id` as read for `account`. Returns `true` when the memo
1103
+ * existed (and is now read), `false` otherwise.
1104
+ */
1105
+ markMemoRead(account: string, id: number): boolean;
1106
+ /**
1107
+ * Removes memo `id` for `account`. Returns `true` when a memo was
1108
+ * removed, `false` when the memo did not exist (or belonged to a
1109
+ * different recipient). Called by the MemoServ `DEL` reducer.
1110
+ */
1111
+ deleteMemo(account: string, id: number): boolean;
1112
+
1113
+ // ----- Read-marker (IRCv3 draft/read-marker) -----
1114
+
1115
+ /**
1116
+ * Returns the stored last-read msgid for `(account, channel)`, or
1117
+ * `undefined` when no marker has been recorded. Keys are case-folded
1118
+ * (rfc1459) so lookups are case-insensitive.
1119
+ */
1120
+ getLastReadMarker(account: string, channel: string): string | undefined;
1121
+ /**
1122
+ * Records (or replaces) the last-read msgid for `(account, channel)`.
1123
+ * Callers MUST only move the marker forward (towards newer messages);
1124
+ * the store itself does not order msgids, it stores the latest value set.
1125
+ */
1126
+ setLastReadMarker(account: string, channel: string, msgid: string): void;
1127
+ /**
1128
+ * Enumerates every recorded marker for `account`. Used at identify time
1129
+ * to seed a fresh connection's `lastReadMarkers` so a reconnect restores
1130
+ * the user's read position across all their channels.
1131
+ */
1132
+ listReadMarkers(account: string): ReadonlyArray<ReadMarkerEntry>;
1133
+
1134
+ // ----- OperServ -----
1135
+
1136
+ /**
1137
+ * Records (or replaces) a network-wide ban mask. Subsequent
1138
+ * {@link matchAkill} calls consult it against incoming hostmasks at the
1139
+ * admission boundary and on every PRIVMSG.
1140
+ */
1141
+ addAkill(mask: string, reason: string): void;
1142
+ /**
1143
+ * Removes the AKILL with the matching mask (case-insensitive). Returns
1144
+ * `true` when an entry was removed, `false` otherwise.
1145
+ */
1146
+ removeAkill(mask: string): boolean;
1147
+ /**
1148
+ * Enumerates every recorded AKILL, oldest-first by insertion. Used by
1149
+ * the OperServ `AKILL LIST` reducer to render the roster.
1150
+ */
1151
+ listAkills(): ReadonlyArray<AkillEntry>;
1152
+ /**
1153
+ * Returns the first AKILL whose mask matches `hostmask` (case-insensitive
1154
+ * wildcard comparison), or `undefined` when none match. Called by the
1155
+ * `$connect` / admission path and the per-PRIVMSG enforcement hook so a
1156
+ * connection whose hostmask fits a banned mask is rejected at the gate.
1157
+ */
1158
+ matchAkill(hostmask: string): AkillEntry | undefined;
1159
+
1160
+ /**
1161
+ * Records (or replaces) a JUPE for the given nick / server name. The
1162
+ * NICK reducer consults {@link isJuped} on every NICK change and
1163
+ * returns `432 ERR_ERRONEUSNICKNAME` for a jupe'd target.
1164
+ */
1165
+ addJupe(name: string, reason?: string): void;
1166
+ /**
1167
+ * Removes the JUPE on `name` (case-insensitive). Returns `true` when a
1168
+ * jupe was removed, `false` otherwise.
1169
+ */
1170
+ removeJupe(name: string): boolean;
1171
+ /** `true` iff `name` is currently juped (case-insensitive). */
1172
+ isJuped(name: string): boolean;
1173
+ /**
1174
+ * Returns the {@link JupeEntry} record for `name`, or `undefined` when
1175
+ * no jupe is set. Used by the OperServ `JUPE LIST` reducer and by
1176
+ * logging paths.
1177
+ */
1178
+ getJupe(name: string): JupeEntry | undefined;
1179
+ /** Enumerates every recorded jupe, oldest-first by insertion. */
1180
+ listJupes(): ReadonlyArray<JupeEntry>;
1181
+ }
1182
+
1183
+ /**
1184
+ * Snapshot row for a NickServ registration, persisted by adapter backends
1185
+ * (D1 / DynamoDB) and round-tripped through {@link ServicesSnapshot}.
1186
+ *
1187
+ * Mirrors {@link RegisteredNick} with the **scrypt-hashed credential**
1188
+ * captured at registration appended. The plaintext password NEVER lives on
1189
+ * this row: `registerNick` runs the password through
1190
+ * {@link hashAccountCredential} (scrypt + random salt) before storing, and
1191
+ * `verifyNick` re-derives via {@link verifyHashedPassword}. A services
1192
+ * backend compromise (D1 export, DynamoDB scan) therefore does not
1193
+ * disclose any NickServ password — the credential is in the same hashed
1194
+ * shape the SASL `AccountStore` already uses, so a NickServ registration
1195
+ * IS a SASL account.
1196
+ *
1197
+ * The credential never leaves the store via
1198
+ * {@link InMemoryServicesStore.getNick} (which returns a defensive copy
1199
+ * without it), but it MUST be present in the snapshot so the
1200
+ * `verifyNick` round-trip keeps working across a reload.
1201
+ */
1202
+ export type ServicesNickRow = RegisteredNick & { credential: HashedAccountCredential };
1203
+
1204
+ /**
1205
+ * Snapshot row for a ChanServ access entry: `(channel, account, level)`.
1206
+ * `channel` / `account` preserve the display spelling captured at
1207
+ * `setChannelAccess` time; the storage key is the rfc1459-folded form so
1208
+ * case-insensitive lookups survive a reload.
1209
+ */
1210
+ export interface ServicesAccessRow {
1211
+ channel: string;
1212
+ account: string;
1213
+ level: ChannelAccessLevel;
1214
+ }
1215
+
1216
+ /**
1217
+ * Snapshot row for a founder-redefined ChanServ level threshold.
1218
+ */
1219
+ export interface ServicesLevelRow {
1220
+ channel: string;
1221
+ op: ChannelLevelOp;
1222
+ level: number;
1223
+ }
1224
+
1225
+ /**
1226
+ * Snapshot row for a HostServ vhost: `(account, vhost)`.
1227
+ */
1228
+ export interface ServicesVhostRow {
1229
+ account: string;
1230
+ vhost: string;
1231
+ }
1232
+
1233
+ /**
1234
+ * Snapshot row for a pending HostServ vhost request: `(account, vhost)`.
1235
+ * Round-trips through {@link ServicesSnapshot.pendingVhosts} so a request
1236
+ * queued before a deploy survives the reload and stays pending.
1237
+ */
1238
+ export interface ServicesPendingVhostRow {
1239
+ account: string;
1240
+ vhost: string;
1241
+ }
1242
+
1243
+ /**
1244
+ * Serializable snapshot of every services record in the store, used to
1245
+ * hydrate a fresh {@link InMemoryServicesStore} (or a
1246
+ * {@link PersistentServicesStore} cache) from an adapter backend at boot.
1247
+ *
1248
+ * The shape is the union of the per-service row types so a D1 / DynamoDB
1249
+ * loader can produce it directly from a table scan without adapter-specific
1250
+ * translation. Round-trips through {@link InMemoryServicesStore.snapshot}
1251
+ * / the `snapshot` constructor option; the in-memory reference impl is the
1252
+ * source of truth for the canonical layout.
1253
+ */
1254
+ export interface ServicesSnapshot {
1255
+ /** NickServ registrations (with scrypt-hashed credential). */
1256
+ nicks: ServicesNickRow[];
1257
+ /** ChanServ channel registrations. */
1258
+ channels: RegisteredChannel[];
1259
+ /** ChanServ `(channel, account, level)` access entries. */
1260
+ access: ServicesAccessRow[];
1261
+ /** ChanServ founder-redefined level thresholds. */
1262
+ levels: ServicesLevelRow[];
1263
+ /** HostServ `(account, vhost)` entries. */
1264
+ vhosts: ServicesVhostRow[];
1265
+ /** HostServ pending `(account, vhost)` requests awaiting oper approval. */
1266
+ pendingVhosts: ServicesPendingVhostRow[];
1267
+ /** MemoServ queued memos. */
1268
+ memos: MemoEntry[];
1269
+ /** Per-`(account, channel)` draft/read-marker entries. */
1270
+ readMarkers: ReadMarkerEntry[];
1271
+ /** OperServ AKILL roster. */
1272
+ akills: AkillEntry[];
1273
+ /** OperServ JUPE roster. */
1274
+ jupes: JupeEntry[];
1275
+ }
1276
+
1277
+ /**
1278
+ * One write-behind mutation emitted by {@link PersistentServicesStore} on
1279
+ * `flush()`. The discriminated `kind` maps 1:1 to a per-table upsert /
1280
+ * delete so a D1 / DynamoDB backend can dispatch without parsing the row.
1281
+ *
1282
+ * - `upsert*` rows carry the full record so a backend can `INSERT OR
1283
+ * REPLACE` / `PutItem` without a prior read.
1284
+ * - `delete*` rows carry the minimum key.
1285
+ * - `insertMemo` / `updateMemoRead` / `deleteMemo` keep the memo id stable
1286
+ * so the in-memory counter and the persisted rows agree.
1287
+ *
1288
+ * No-ops (e.g. `dropNick` on an unregistered nick, `removeAkill` on an
1289
+ * unknown mask) enqueue nothing — the persistent base class only emits a
1290
+ * write when the underlying `InMemoryServicesStore` mutation changed
1291
+ * state.
1292
+ */
1293
+ export type ServicesWrite =
1294
+ | { kind: 'upsertNick'; row: ServicesNickRow }
1295
+ | { kind: 'deleteNick'; nick: string }
1296
+ | { kind: 'upsertChannel'; row: RegisteredChannel }
1297
+ | { kind: 'deleteChannel'; channel: string }
1298
+ | { kind: 'upsertChannelAccess'; row: ServicesAccessRow }
1299
+ | { kind: 'deleteChannelAccess'; channel: string; account: string }
1300
+ | { kind: 'upsertChannelLevel'; row: ServicesLevelRow }
1301
+ | { kind: 'resetChannelLevels'; channel: string }
1302
+ | { kind: 'upsertVhost'; row: ServicesVhostRow }
1303
+ | { kind: 'deleteVhost'; account: string }
1304
+ | { kind: 'upsertPendingVhost'; row: ServicesPendingVhostRow }
1305
+ | { kind: 'deletePendingVhost'; account: string }
1306
+ | { kind: 'insertMemo'; row: MemoEntry }
1307
+ | { kind: 'updateMemoRead'; account: string; id: number }
1308
+ | { kind: 'deleteMemo'; account: string; id: number }
1309
+ | { kind: 'upsertReadMarker'; row: ReadMarkerEntry }
1310
+ | { kind: 'upsertAkill'; row: AkillEntry }
1311
+ | { kind: 'deleteAkill'; mask: string }
1312
+ | { kind: 'upsertJupe'; row: JupeEntry }
1313
+ | { kind: 'deleteJupe'; name: string };
1314
+
1315
+ /**
1316
+ * Internal stored nick record: {@link ServicesNickRow} (the
1317
+ * {@link RegisteredNick} shape plus the scrypt-hashed credential captured
1318
+ * at registration). The credential never leaves the store via
1319
+ * {@link InMemoryServicesStore.getNick} (which returns a defensive copy
1320
+ * without it).
1321
+ */
1322
+ type StoredNick = ServicesNickRow;
1323
+
1324
+ /**
1325
+ * Internal stored AKILL record. The public {@link AkillEntry} shape with
1326
+ * the case-folded mask as a separate field so the
1327
+ * {@link InMemoryServicesStore.matchAkill} fast path can short-circuit on
1328
+ * a structural comparison without re-folding per probe.
1329
+ */
1330
+ type StoredAkill = AkillEntry & { maskKey: string };
1331
+
1332
+ /**
1333
+ * Internal stored JUPE record. The public {@link JupeEntry} shape with
1334
+ * the case-folded name as a separate field so the
1335
+ * {@link InMemoryServicesStore.isJuped} fast path can short-circuit on
1336
+ * a structural comparison without re-folding per probe.
1337
+ */
1338
+ type StoredJupe = JupeEntry & { nameKey: string };
1339
+
1340
+ /**
1341
+ * Reference in-memory {@link ServicesStore} used by the local CLI,
1342
+ * integration tests, and any deployment that has not bound a distributed
1343
+ * backend.
1344
+ *
1345
+ * Backed by plain `Map`s keyed by rfc1459-folded nick / account / channel.
1346
+ * Password verification uses the same scrypt path as the SASL
1347
+ * {@link HashedAccountStore} ({@link verifyHashedPassword}) so a NickServ
1348
+ * registration IS a SASL account — both stores carry credentials in the
1349
+ * same hashed shape, and SASL PLAIN's services fallback verifies against
1350
+ * a row created by `NickServ REGISTER` without a second registration.
1351
+ * Returned records are defensive copies so callers cannot mutate the
1352
+ * store by editing a returned {@link RegisteredNick} or {@link MemoEntry}.
1353
+ *
1354
+ * Cloud adapters replace this with a D1 / DynamoDB-backed implementation
1355
+ * against the same surface; until then every services reducer runs against
1356
+ * this reference impl.
1357
+ */
1358
+ export class InMemoryServicesStore implements ServicesStore {
1359
+ private readonly nicks = new Map<string, StoredNick>();
1360
+ private readonly channels = new Map<string, RegisteredChannel>();
1361
+ private readonly access = new Map<string, Map<string, ChannelAccessLevel>>();
1362
+ private readonly levels = new Map<string, Map<ChannelLevelOp, number>>();
1363
+ private readonly vhosts = new Map<string, string>();
1364
+ /**
1365
+ * Pending HostServ vhost requests, keyed by folded account. Insertion
1366
+ * order is preserved by the underlying `Map` so {@link listPendingVhosts}
1367
+ * renders requests in the order they arrived.
1368
+ */
1369
+ private readonly pendingVhosts = new Map<string, string>();
1370
+ private readonly memosByAccount = new Map<string, MemoEntry[]>();
1371
+ private readonly readMarkersByAccount = new Map<string, Map<string, string>>();
1372
+ /**
1373
+ * AKILL roster, kept in insertion order so the `AKILL LIST` reducer
1374
+ * renders the masks in the order the opers added them. Lookups by mask
1375
+ * consult {@link akillsByKey} for an O(1) existence check.
1376
+ */
1377
+ private readonly akills: StoredAkill[] = [];
1378
+ private readonly akillsByKey = new Map<string, StoredAkill>();
1379
+ /**
1380
+ * JUPE roster, kept in insertion order so the `JUPE LIST` reducer
1381
+ * renders the names in the order the opers added them. Lookups by name
1382
+ * consult {@link jupesByKey} for an O(1) existence check.
1383
+ */
1384
+ private readonly jupes: StoredJupe[] = [];
1385
+ private readonly jupesByKey = new Map<string, StoredJupe>();
1386
+ private readonly clock: Clock;
1387
+ private memoCounter = 0;
1388
+
1389
+ constructor(opts: { clock?: Clock; snapshot?: ServicesSnapshot } = {}) {
1390
+ this.clock = opts.clock ?? SystemClock;
1391
+ if (opts.snapshot !== undefined) this.hydrate(opts.snapshot);
1392
+ }
1393
+
1394
+ /**
1395
+ * Reloads every service record from {@link snap} into the in-memory maps.
1396
+ *
1397
+ * Used by the constructor's `snapshot` option and by
1398
+ * {@link PersistentServicesStore} subclasses (which feed the pre-loaded
1399
+ * adapter snapshot through the same path). Keys are rfc1459-folded so
1400
+ * case-insensitive lookups survive a reload; the {@link StoredAkill} /
1401
+ * {@link StoredJupe} fast-path maps are rebuilt from the public rows.
1402
+ *
1403
+ * The memo counter resumes after the highest reloaded id so the next
1404
+ * `recordMemo` does not collide with a persisted row.
1405
+ */
1406
+ protected hydrate(snap: ServicesSnapshot): void {
1407
+ for (const row of snap.nicks) {
1408
+ this.nicks.set(folded(row.nick), { ...row, credential: { ...row.credential } });
1409
+ }
1410
+ for (const row of snap.channels) {
1411
+ const stored: RegisteredChannel = { ...row };
1412
+ if (row.topic !== undefined) stored.topic = { ...row.topic };
1413
+ this.channels.set(folded(row.channel), stored);
1414
+ }
1415
+ for (const row of snap.access) {
1416
+ let acctMap = this.access.get(folded(row.channel));
1417
+ if (acctMap === undefined) {
1418
+ acctMap = new Map<string, ChannelAccessLevel>();
1419
+ this.access.set(folded(row.channel), acctMap);
1420
+ }
1421
+ acctMap.set(folded(row.account), row.level);
1422
+ }
1423
+ for (const row of snap.levels) {
1424
+ let opMap = this.levels.get(folded(row.channel));
1425
+ if (opMap === undefined) {
1426
+ opMap = new Map<ChannelLevelOp, number>();
1427
+ this.levels.set(folded(row.channel), opMap);
1428
+ }
1429
+ opMap.set(row.op, row.level);
1430
+ }
1431
+ for (const row of snap.vhosts) {
1432
+ this.vhosts.set(folded(row.account), row.vhost);
1433
+ }
1434
+ for (const row of snap.pendingVhosts) {
1435
+ this.pendingVhosts.set(folded(row.account), row.vhost);
1436
+ }
1437
+ for (const memo of snap.memos) {
1438
+ let arr = this.memosByAccount.get(folded(memo.to));
1439
+ if (arr === undefined) {
1440
+ arr = [];
1441
+ this.memosByAccount.set(folded(memo.to), arr);
1442
+ }
1443
+ arr.push({ ...memo });
1444
+ if (memo.id > this.memoCounter) this.memoCounter = memo.id;
1445
+ }
1446
+ for (const row of snap.readMarkers) {
1447
+ let chanMap = this.readMarkersByAccount.get(folded(row.account));
1448
+ if (chanMap === undefined) {
1449
+ chanMap = new Map<string, string>();
1450
+ this.readMarkersByAccount.set(folded(row.account), chanMap);
1451
+ }
1452
+ chanMap.set(folded(row.channel), row.msgid);
1453
+ }
1454
+ for (const row of snap.akills) {
1455
+ const entry: StoredAkill = { ...row, maskKey: folded(row.mask) };
1456
+ this.akills.push(entry);
1457
+ this.akillsByKey.set(entry.maskKey, entry);
1458
+ }
1459
+ for (const row of snap.jupes) {
1460
+ const entry: StoredJupe = { ...row, nameKey: folded(row.name) };
1461
+ this.jupes.push(entry);
1462
+ this.jupesByKey.set(entry.nameKey, entry);
1463
+ }
1464
+ }
1465
+
1466
+ /**
1467
+ * Serializes every service record into a plain snapshot suitable for
1468
+ * adapter persistence (D1 / DynamoDB) or for round-tripping through a
1469
+ * peer {@link InMemoryServicesStore} (see
1470
+ * `persistent-services-store.test.ts`).
1471
+ *
1472
+ * Returned rows are defensive copies so callers cannot mutate the store
1473
+ * by editing the snapshot. The nick rows include the scrypt-hashed
1474
+ * credential — the snapshot is the persistence shape, not a safe public
1475
+ * view (use {@link getNick} for that).
1476
+ */
1477
+ snapshot(): ServicesSnapshot {
1478
+ const nicks: ServicesNickRow[] = [];
1479
+ for (const rec of this.nicks.values()) {
1480
+ nicks.push({ ...rec, credential: { ...rec.credential } });
1481
+ }
1482
+ const channels: RegisteredChannel[] = [];
1483
+ for (const rec of this.channels.values()) {
1484
+ const out: RegisteredChannel = { ...rec };
1485
+ if (rec.topic !== undefined) out.topic = { ...rec.topic };
1486
+ channels.push(out);
1487
+ }
1488
+ const access: ServicesAccessRow[] = [];
1489
+ for (const [chanKey, acctMap] of this.access) {
1490
+ for (const [account, level] of acctMap) {
1491
+ access.push({ channel: chanKey, account, level });
1492
+ }
1493
+ }
1494
+ const levels: ServicesLevelRow[] = [];
1495
+ for (const [chanKey, opMap] of this.levels) {
1496
+ for (const [op, level] of opMap) {
1497
+ levels.push({ channel: chanKey, op, level });
1498
+ }
1499
+ }
1500
+ const vhosts: ServicesVhostRow[] = [];
1501
+ for (const [account, vhost] of this.vhosts) {
1502
+ vhosts.push({ account, vhost });
1503
+ }
1504
+ const pendingVhosts: ServicesPendingVhostRow[] = [];
1505
+ for (const [account, vhost] of this.pendingVhosts) {
1506
+ pendingVhosts.push({ account, vhost });
1507
+ }
1508
+ const memos: MemoEntry[] = [];
1509
+ for (const arr of this.memosByAccount.values()) {
1510
+ for (const memo of arr) memos.push({ ...memo });
1511
+ }
1512
+ const readMarkers: ReadMarkerEntry[] = [];
1513
+ for (const [account, chanMap] of this.readMarkersByAccount) {
1514
+ for (const [channel, msgid] of chanMap) {
1515
+ readMarkers.push({ account, channel, msgid });
1516
+ }
1517
+ }
1518
+ const akills: AkillEntry[] = this.akills.map((e) => ({
1519
+ mask: e.mask,
1520
+ reason: e.reason,
1521
+ createdAt: e.createdAt,
1522
+ }));
1523
+ const jupes: JupeEntry[] = this.jupes.map((e) => ({
1524
+ name: e.name,
1525
+ reason: e.reason,
1526
+ createdAt: e.createdAt,
1527
+ }));
1528
+ return {
1529
+ nicks,
1530
+ channels,
1531
+ access,
1532
+ levels,
1533
+ vhosts,
1534
+ pendingVhosts,
1535
+ memos,
1536
+ readMarkers,
1537
+ akills,
1538
+ jupes,
1539
+ };
1540
+ }
1541
+
1542
+ // ----- NickServ -----
1543
+
1544
+ registerNick(nick: string, password: string, email: string): ServicesAuthResult {
1545
+ const key = folded(nick);
1546
+ if (this.nicks.has(key)) {
1547
+ return { ok: false, reason: 'nick already registered' };
1548
+ }
1549
+ const now = this.clock.now();
1550
+ const credential = hashAccountCredential(nick, password);
1551
+ this.nicks.set(key, {
1552
+ nick,
1553
+ account: nick,
1554
+ email,
1555
+ createdAt: now,
1556
+ enforce: 'none',
1557
+ credential,
1558
+ });
1559
+ return { ok: true, account: nick };
1560
+ }
1561
+
1562
+ verifyNick(nick: string, password: string): ServicesAuthResult {
1563
+ const rec = this.nicks.get(folded(nick));
1564
+ if (rec === undefined) {
1565
+ return { ok: false, reason: 'invalid credentials' };
1566
+ }
1567
+ if (!verifyHashedPassword(password, rec.credential)) {
1568
+ return { ok: false, reason: 'invalid credentials' };
1569
+ }
1570
+ return { ok: true, account: rec.account };
1571
+ }
1572
+
1573
+ dropNick(nick: string): boolean {
1574
+ const key = folded(nick);
1575
+ return this.nicks.delete(key);
1576
+ }
1577
+
1578
+ isRegisteredNick(nick: string): boolean {
1579
+ return this.nicks.has(folded(nick));
1580
+ }
1581
+
1582
+ setNickEnforce(nick: string, policy: NickEnforcePolicy): void {
1583
+ const rec = this.nicks.get(folded(nick));
1584
+ if (rec === undefined) return;
1585
+ rec.enforce = policy;
1586
+ }
1587
+
1588
+ getNickEnforce(nick: string): NickEnforcePolicy {
1589
+ return this.nicks.get(folded(nick))?.enforce ?? 'none';
1590
+ }
1591
+
1592
+ getNick(nick: string): RegisteredNick | undefined {
1593
+ const rec = this.nicks.get(folded(nick));
1594
+ if (rec === undefined) return undefined;
1595
+ // Defensive copy with the credential stripped so callers cannot mutate
1596
+ // the stored record or read the credential.
1597
+ return {
1598
+ nick: rec.nick,
1599
+ account: rec.account,
1600
+ email: rec.email,
1601
+ createdAt: rec.createdAt,
1602
+ enforce: rec.enforce,
1603
+ };
1604
+ }
1605
+
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
+ });
1640
+ return true;
1641
+ }
1642
+
1643
+ /**
1644
+ * Returns the full stored nick row (including the scrypt-hashed
1645
+ * credential), or `undefined` when the nick is not registered. Defensive
1646
+ * copy so the caller cannot mutate the cache by editing the returned
1647
+ * row.
1648
+ *
1649
+ * Used by {@link PersistentServicesStore} to re-persist the full row on
1650
+ * a `setNickEnforce` change (the public {@link getNick} strips the
1651
+ * credential, but the write-behind op needs it so a backend reload
1652
+ * keeps `verifyNick` working). The helper is protected so only the
1653
+ * write-behind subclass reaches for the credential.
1654
+ */
1655
+ protected getStoredNick(nick: string): ServicesNickRow | undefined {
1656
+ const rec = this.nicks.get(folded(nick));
1657
+ return rec === undefined ? undefined : { ...rec, credential: { ...rec.credential } };
1658
+ }
1659
+
1660
+ // ----- ChanServ -----
1661
+
1662
+ registerChannel(channel: string, founder: string): ChannelRegisterResult {
1663
+ const key = folded(channel);
1664
+ if (this.channels.has(key)) {
1665
+ return { ok: false, reason: 'channel already registered' };
1666
+ }
1667
+ this.channels.set(key, {
1668
+ channel,
1669
+ founder,
1670
+ createdAt: this.clock.now(),
1671
+ mlock: '',
1672
+ restricted: false,
1673
+ keepTopic: false,
1674
+ });
1675
+ return { ok: true, founder };
1676
+ }
1677
+
1678
+ dropChannel(channel: string): boolean {
1679
+ const key = folded(channel);
1680
+ this.access.delete(key);
1681
+ this.levels.delete(key);
1682
+ return this.channels.delete(key);
1683
+ }
1684
+
1685
+ getChannelFounder(channel: string): string | undefined {
1686
+ return this.channels.get(folded(channel))?.founder;
1687
+ }
1688
+
1689
+ getChannel(channel: string): RegisteredChannel | undefined {
1690
+ const rec = this.channels.get(folded(channel));
1691
+ if (rec === undefined) return undefined;
1692
+ // Defensive copy with the persisted-topic field copied by value.
1693
+ const out: RegisteredChannel = {
1694
+ channel: rec.channel,
1695
+ founder: rec.founder,
1696
+ createdAt: rec.createdAt,
1697
+ mlock: rec.mlock,
1698
+ restricted: rec.restricted,
1699
+ keepTopic: rec.keepTopic,
1700
+ };
1701
+ if (rec.topic !== undefined) out.topic = { ...rec.topic };
1702
+ return out;
1703
+ }
1704
+
1705
+ setChannelFounder(channel: string, account: string): void {
1706
+ const rec = this.channels.get(folded(channel));
1707
+ if (rec === undefined) return;
1708
+ rec.founder = account;
1709
+ }
1710
+
1711
+ setChannelMlock(channel: string, modestring: string): void {
1712
+ const rec = this.channels.get(folded(channel));
1713
+ if (rec === undefined) return;
1714
+ rec.mlock = modestring;
1715
+ }
1716
+
1717
+ getChannelMlock(channel: string): string {
1718
+ return this.channels.get(folded(channel))?.mlock ?? '';
1719
+ }
1720
+
1721
+ setChannelRestricted(channel: string, on: boolean): void {
1722
+ const rec = this.channels.get(folded(channel));
1723
+ if (rec === undefined) return;
1724
+ rec.restricted = on;
1725
+ }
1726
+
1727
+ setChannelKeepTopic(channel: string, on: boolean): void {
1728
+ const rec = this.channels.get(folded(channel));
1729
+ if (rec === undefined) return;
1730
+ rec.keepTopic = on;
1731
+ // Clearing keepTopic drops the persisted snapshot so a later re-enable
1732
+ // does not restore a stale topic.
1733
+ if (!on && rec.topic !== undefined) {
1734
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
1735
+ delete rec.topic;
1736
+ }
1737
+ }
1738
+
1739
+ setChannelTopic(channel: string, topic: PersistedTopic | undefined): void {
1740
+ const rec = this.channels.get(folded(channel));
1741
+ if (rec === undefined) return;
1742
+ if (topic === undefined) {
1743
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
1744
+ delete rec.topic;
1745
+ return;
1746
+ }
1747
+ rec.topic = { ...topic };
1748
+ }
1749
+
1750
+ getChannelTopic(channel: string): PersistedTopic | undefined {
1751
+ const rec = this.channels.get(folded(channel));
1752
+ if (rec === undefined || rec.topic === undefined) return undefined;
1753
+ return { ...rec.topic };
1754
+ }
1755
+
1756
+ setChannelAccess(channel: string, account: string, level: ChannelAccessLevel): void {
1757
+ const chanKey = folded(channel);
1758
+ let acctMap = this.access.get(chanKey);
1759
+ if (acctMap === undefined) {
1760
+ acctMap = new Map<string, ChannelAccessLevel>();
1761
+ this.access.set(chanKey, acctMap);
1762
+ }
1763
+ if (level <= 0) {
1764
+ acctMap.delete(folded(account));
1765
+ // Drop the per-channel map when it ends up empty so the store does
1766
+ // not leak empty maps across long-lived deployments.
1767
+ if (acctMap.size === 0) this.access.delete(chanKey);
1768
+ return;
1769
+ }
1770
+ acctMap.set(folded(account), level);
1771
+ }
1772
+
1773
+ getChannelAccess(channel: string, account: string): ChannelAccessLevel {
1774
+ return this.access.get(folded(channel))?.get(folded(account)) ?? 0;
1775
+ }
1776
+
1777
+ listChannelAccess(channel: string): ReadonlyArray<{ account: string; level: number }> {
1778
+ // The reference store does not retain the original spelling of the
1779
+ // account alongside the level (the key is rfc1459-folded). Until a
1780
+ // persistent backend ships with a canonical-spelling column, LIST
1781
+ // renders the folded key verbatim — IRC nicks are case-insensitive
1782
+ // under CASEMAPPING=rfc1459, so the rendered text still resolves to
1783
+ // the same identity.
1784
+ const acctMap = this.access.get(folded(channel));
1785
+ if (acctMap === undefined) return [];
1786
+ const out: { account: string; level: number }[] = [];
1787
+ for (const [account, level] of acctMap) out.push({ account, level });
1788
+ return out;
1789
+ }
1790
+
1791
+ getChannelLevel(channel: string, op: ChannelLevelOp): number | undefined {
1792
+ return this.levels.get(folded(channel))?.get(op);
1793
+ }
1794
+
1795
+ setChannelLevel(channel: string, op: ChannelLevelOp, level: number): void {
1796
+ const chanKey = folded(channel);
1797
+ let opMap = this.levels.get(chanKey);
1798
+ if (opMap === undefined) {
1799
+ opMap = new Map<ChannelLevelOp, number>();
1800
+ this.levels.set(chanKey, opMap);
1801
+ }
1802
+ opMap.set(op, level);
1803
+ }
1804
+
1805
+ listChannelLevels(channel: string): ReadonlyArray<{ op: ChannelLevelOp; level: number }> {
1806
+ const opMap = this.levels.get(folded(channel));
1807
+ if (opMap === undefined) return [];
1808
+ const out: { op: ChannelLevelOp; level: number }[] = [];
1809
+ for (const [op, level] of opMap) out.push({ op, level });
1810
+ return out;
1811
+ }
1812
+
1813
+ resetChannelLevels(channel: string): void {
1814
+ this.levels.delete(folded(channel));
1815
+ }
1816
+
1817
+ // ----- HostServ -----
1818
+
1819
+ setVhost(account: string, vhost: string): void {
1820
+ const key = folded(account);
1821
+ if (vhost === '') {
1822
+ this.vhosts.delete(key);
1823
+ return;
1824
+ }
1825
+ this.vhosts.set(key, vhost);
1826
+ }
1827
+
1828
+ getVhost(account: string): string | undefined {
1829
+ return this.vhosts.get(folded(account));
1830
+ }
1831
+
1832
+ requestVhost(account: string, vhost: string): void {
1833
+ this.pendingVhosts.set(folded(account), vhost);
1834
+ }
1835
+
1836
+ getPendingVhost(account: string): string | undefined {
1837
+ return this.pendingVhosts.get(folded(account));
1838
+ }
1839
+
1840
+ listPendingVhosts(): ReadonlyArray<{ account: string; vhost: string }> {
1841
+ const out: { account: string; vhost: string }[] = [];
1842
+ for (const [account, vhost] of this.pendingVhosts) out.push({ account, vhost });
1843
+ return out;
1844
+ }
1845
+
1846
+ approveVhost(account: string): string | undefined {
1847
+ const key = folded(account);
1848
+ const vhost = this.pendingVhosts.get(key);
1849
+ if (vhost === undefined) return undefined;
1850
+ this.pendingVhosts.delete(key);
1851
+ this.setVhost(account, vhost);
1852
+ return vhost;
1853
+ }
1854
+
1855
+ rejectVhost(account: string): string | undefined {
1856
+ const key = folded(account);
1857
+ const vhost = this.pendingVhosts.get(key);
1858
+ if (vhost === undefined) return undefined;
1859
+ this.pendingVhosts.delete(key);
1860
+ return vhost;
1861
+ }
1862
+
1863
+ // ----- MemoServ -----
1864
+
1865
+ recordMemo(memo: MemoInput): number {
1866
+ const id = ++this.memoCounter;
1867
+ const entry: MemoEntry = { ...memo, id, read: false };
1868
+ const key = folded(memo.to);
1869
+ let arr = this.memosByAccount.get(key);
1870
+ if (arr === undefined) {
1871
+ arr = [];
1872
+ this.memosByAccount.set(key, arr);
1873
+ }
1874
+ arr.push(entry);
1875
+ return id;
1876
+ }
1877
+
1878
+ listMemos(account: string): ReadonlyArray<MemoEntry> {
1879
+ const arr = this.memosByAccount.get(folded(account));
1880
+ if (arr === undefined) return [];
1881
+ // Defensive copy of each entry so callers cannot mutate the stored
1882
+ // memo records (e.g. flipping `read`) by editing the returned array.
1883
+ return arr.map((m) => ({ ...m }));
1884
+ }
1885
+
1886
+ markMemoRead(account: string, id: number): boolean {
1887
+ const arr = this.memosByAccount.get(folded(account));
1888
+ if (arr === undefined) return false;
1889
+ const memo = arr.find((m) => m.id === id);
1890
+ if (memo === undefined) return false;
1891
+ memo.read = true;
1892
+ return true;
1893
+ }
1894
+
1895
+ deleteMemo(account: string, id: number): boolean {
1896
+ const key = folded(account);
1897
+ const arr = this.memosByAccount.get(key);
1898
+ if (arr === undefined) return false;
1899
+ const idx = arr.findIndex((m) => m.id === id);
1900
+ if (idx === -1) return false;
1901
+ arr.splice(idx, 1);
1902
+ if (arr.length === 0) {
1903
+ this.memosByAccount.delete(key);
1904
+ }
1905
+ return true;
1906
+ }
1907
+
1908
+ // ----- Read-marker (IRCv3 draft/read-marker) -----
1909
+
1910
+ getLastReadMarker(account: string, channel: string): string | undefined {
1911
+ return this.readMarkersByAccount.get(folded(account))?.get(folded(channel));
1912
+ }
1913
+
1914
+ setLastReadMarker(account: string, channel: string, msgid: string): void {
1915
+ const acct = folded(account);
1916
+ let chanMap = this.readMarkersByAccount.get(acct);
1917
+ if (chanMap === undefined) {
1918
+ chanMap = new Map<string, string>();
1919
+ this.readMarkersByAccount.set(acct, chanMap);
1920
+ }
1921
+ chanMap.set(folded(channel), msgid);
1922
+ }
1923
+
1924
+ listReadMarkers(account: string): ReadonlyArray<ReadMarkerEntry> {
1925
+ const chanMap = this.readMarkersByAccount.get(folded(account));
1926
+ if (chanMap === undefined) return [];
1927
+ const out: ReadMarkerEntry[] = [];
1928
+ for (const [channel, msgid] of chanMap) {
1929
+ out.push({ account, channel, msgid });
1930
+ }
1931
+ return out;
1932
+ }
1933
+
1934
+ // ----- OperServ -----
1935
+
1936
+ addAkill(mask: string, reason: string): void {
1937
+ const maskKey = folded(mask);
1938
+ const now = this.clock.now();
1939
+ const existing = this.akillsByKey.get(maskKey);
1940
+ if (existing !== undefined) {
1941
+ // Replace-in-place preserves insertion order while updating the
1942
+ // reason + timestamp so a re-add does not silently demote the mask.
1943
+ existing.reason = reason;
1944
+ existing.createdAt = now;
1945
+ existing.mask = mask;
1946
+ return;
1947
+ }
1948
+ const entry: StoredAkill = { mask, reason, createdAt: now, maskKey };
1949
+ this.akills.push(entry);
1950
+ this.akillsByKey.set(maskKey, entry);
1951
+ }
1952
+
1953
+ removeAkill(mask: string): boolean {
1954
+ const maskKey = folded(mask);
1955
+ const existing = this.akillsByKey.get(maskKey);
1956
+ if (existing === undefined) return false;
1957
+ this.akillsByKey.delete(maskKey);
1958
+ // `existing` is guaranteed present in `akills`: every `addAkill` path
1959
+ // that populates `akillsByKey` also pushes to the array (replace-in-place
1960
+ // mutates the shared reference), and the two are mutated in lockstep.
1961
+ this.akills.splice(this.akills.indexOf(existing), 1);
1962
+ return true;
1963
+ }
1964
+
1965
+ listAkills(): ReadonlyArray<AkillEntry> {
1966
+ return this.akills.map((e) => ({ mask: e.mask, reason: e.reason, createdAt: e.createdAt }));
1967
+ }
1968
+
1969
+ matchAkill(hostmask: string): AkillEntry | undefined {
1970
+ for (const entry of this.akills) {
1971
+ if (matchesAkillMask(entry.mask, hostmask)) {
1972
+ // Defensive copy so a caller cannot mutate the stored record by
1973
+ // editing the returned entry.
1974
+ return { mask: entry.mask, reason: entry.reason, createdAt: entry.createdAt };
1975
+ }
1976
+ }
1977
+ return undefined;
1978
+ }
1979
+
1980
+ addJupe(name: string, reason?: string): void {
1981
+ const nameKey = folded(name);
1982
+ const now = this.clock.now();
1983
+ const existing = this.jupesByKey.get(nameKey);
1984
+ if (existing !== undefined) {
1985
+ existing.reason = reason ?? '';
1986
+ existing.createdAt = now;
1987
+ existing.name = name;
1988
+ return;
1989
+ }
1990
+ const entry: StoredJupe = { name, reason: reason ?? '', createdAt: now, nameKey };
1991
+ this.jupes.push(entry);
1992
+ this.jupesByKey.set(nameKey, entry);
1993
+ }
1994
+
1995
+ removeJupe(name: string): boolean {
1996
+ const nameKey = folded(name);
1997
+ const existing = this.jupesByKey.get(nameKey);
1998
+ if (existing === undefined) return false;
1999
+ this.jupesByKey.delete(nameKey);
2000
+ // `existing` is guaranteed present in `jupes`: every `addJupe` path
2001
+ // that populates `jupesByKey` also pushes to the array (replace-in-place
2002
+ // mutates the shared reference), and the two are mutated in lockstep.
2003
+ this.jupes.splice(this.jupes.indexOf(existing), 1);
2004
+ return true;
2005
+ }
2006
+
2007
+ isJuped(name: string): boolean {
2008
+ return this.jupesByKey.has(folded(name));
2009
+ }
2010
+
2011
+ getJupe(name: string): JupeEntry | undefined {
2012
+ const entry = this.jupesByKey.get(folded(name));
2013
+ if (entry === undefined) return undefined;
2014
+ return { name: entry.name, reason: entry.reason, createdAt: entry.createdAt };
2015
+ }
2016
+
2017
+ listJupes(): ReadonlyArray<JupeEntry> {
2018
+ return this.jupes.map((e) => ({ name: e.name, reason: e.reason, createdAt: e.createdAt }));
2019
+ }
2020
+ }
2021
+
2022
+ /**
2023
+ * Write-behind base class for persistent {@link ServicesStore} backends
2024
+ * (D1, DynamoDB). Subclasses pre-load a {@link ServicesSnapshot} at boot
2025
+ * via the constructor option and implement {@link applyWrites} to drain
2026
+ * queued {@link ServicesWrite} ops into the adapter backend.
2027
+ *
2028
+ * The services port is synchronous (reducers stay free of IO); this base
2029
+ * keeps that contract by mutating the inherited
2030
+ * {@link InMemoryServicesStore} cache synchronously and enqueueing a
2031
+ * matching write-behind op per mutation. The reducer sees the new state
2032
+ * immediately; the backend lags behind until `flush()` is awaited (the
2033
+ * adapter calls `flush()` at the end of each connection turn / invocation).
2034
+ *
2035
+ * No-op mutations (e.g. `dropNick` on an unregistered nick, `removeAkill`
2036
+ * on an unknown mask) enqueue nothing — only mutations that changed the
2037
+ * in-memory cache produce a write. Concurrent `flush()` calls coalesce
2038
+ * into a single {@link applyWrites} invocation so a turn that triggers
2039
+ * many reducers persists one batch, not N.
2040
+ */
2041
+ export abstract class PersistentServicesStore extends InMemoryServicesStore {
2042
+ /**
2043
+ * Pending write-behind ops accumulated since the last `flush()`. Drained
2044
+ * in insertion order so a backend that cares about ordering (e.g. an
2045
+ * upsert followed by a delete on the same key) sees the ops in the
2046
+ * order the reducers produced them.
2047
+ */
2048
+ private readonly pending: ServicesWrite[] = [];
2049
+ /**
2050
+ * The in-flight {@link applyWrites} promise, or `undefined` when no
2051
+ * flush is running. Concurrent `flush()` callers receive this same
2052
+ * promise so the backend is asked to persist once, not once per caller.
2053
+ */
2054
+ private inFlight: Promise<void> | undefined = undefined;
2055
+
2056
+ constructor(opts: { clock?: Clock; snapshot?: ServicesSnapshot } = {}) {
2057
+ super(opts);
2058
+ }
2059
+
2060
+ /**
2061
+ * Drains `writes` into the adapter backend. Subclasses translate each
2062
+ * {@link ServicesWrite} into the backend's native upsert / delete
2063
+ * (D1 `INSERT OR REPLACE`, DynamoDB `PutItem` / `DeleteItem`). The
2064
+ * base class never calls this with an empty array.
2065
+ */
2066
+ protected abstract applyWrites(writes: ServicesWrite[]): Promise<void>;
2067
+
2068
+ /**
2069
+ * Appends `write` to the pending queue. Called by every overridden
2070
+ * mutator after the synchronous cache update has landed.
2071
+ */
2072
+ private enqueue(write: ServicesWrite): void {
2073
+ this.pending.push(write);
2074
+ }
2075
+
2076
+ /**
2077
+ * Drains the pending queue into the backend.
2078
+ *
2079
+ * Returns immediately with `Promise.resolve()` when no mutations are
2080
+ * pending and no flush is in flight (the common case on a quiescent
2081
+ * turn). When mutations are pending, drains them into a single
2082
+ * {@link applyWrites} batch and tracks the promise on
2083
+ * {@link inFlight} so concurrent callers coalesce onto the same drain.
2084
+ */
2085
+ flush(): Promise<void> {
2086
+ if (this.inFlight !== undefined) return this.inFlight;
2087
+ if (this.pending.length === 0) return Promise.resolve();
2088
+ const writes = this.pending.splice(0);
2089
+ const p = this.applyWrites(writes);
2090
+ this.inFlight = p;
2091
+ // Clear the slot once the backend settles so a later mutation can
2092
+ // start a fresh drain. `void` — the returned promise is independent
2093
+ // of `p`, which is what callers await.
2094
+ void p.finally(() => {
2095
+ this.inFlight = undefined;
2096
+ });
2097
+ return p;
2098
+ }
2099
+
2100
+ // ----- NickServ -----
2101
+
2102
+ override registerNick(nick: string, password: string, email: string): ServicesAuthResult {
2103
+ const result = super.registerNick(nick, password, email);
2104
+ if (result.ok) {
2105
+ // `result.ok` guarantees the nick is registered, so `getStoredNick`
2106
+ // is defined; re-persist the full row (hashed credential included)
2107
+ // for the upsert.
2108
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2109
+ this.enqueue({ kind: 'upsertNick', row });
2110
+ }
2111
+ return result;
2112
+ }
2113
+
2114
+ override dropNick(nick: string): boolean {
2115
+ const removed = super.dropNick(nick);
2116
+ if (removed) this.enqueue({ kind: 'deleteNick', nick });
2117
+ return removed;
2118
+ }
2119
+
2120
+ override setNickEnforce(nick: string, policy: NickEnforcePolicy): void {
2121
+ if (!this.isRegisteredNick(nick)) return;
2122
+ super.setNickEnforce(nick, policy);
2123
+ // The `isRegisteredNick` guard above guarantees the nick is present, so
2124
+ // `getStoredNick` is defined — read it back to capture the exact stored
2125
+ // row (hashed credential included) for the upsert.
2126
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2127
+ this.enqueue({ kind: 'upsertNick', row });
2128
+ }
2129
+
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 });
2135
+ }
2136
+ return created;
2137
+ }
2138
+
2139
+ // ----- ChanServ -----
2140
+
2141
+ override registerChannel(channel: string, founder: string): ChannelRegisterResult {
2142
+ const result = super.registerChannel(channel, founder);
2143
+ if (result.ok) {
2144
+ // `result.ok` guarantees the channel is registered, so `getChannel`
2145
+ // is defined — read it back to capture the full row super stored.
2146
+ const rec = this.getChannel(channel) as RegisteredChannel;
2147
+ this.enqueue({ kind: 'upsertChannel', row: rec });
2148
+ }
2149
+ return result;
2150
+ }
2151
+
2152
+ override dropChannel(channel: string): boolean {
2153
+ const removed = super.dropChannel(channel);
2154
+ if (removed) this.enqueue({ kind: 'deleteChannel', channel });
2155
+ return removed;
2156
+ }
2157
+
2158
+ override setChannelFounder(channel: string, account: string): void {
2159
+ super.setChannelFounder(channel, account);
2160
+ const rec = this.getChannel(channel);
2161
+ if (rec !== undefined) this.enqueue({ kind: 'upsertChannel', row: rec });
2162
+ }
2163
+
2164
+ override setChannelMlock(channel: string, modestring: string): void {
2165
+ super.setChannelMlock(channel, modestring);
2166
+ const rec = this.getChannel(channel);
2167
+ if (rec !== undefined) this.enqueue({ kind: 'upsertChannel', row: rec });
2168
+ }
2169
+
2170
+ override setChannelRestricted(channel: string, on: boolean): void {
2171
+ super.setChannelRestricted(channel, on);
2172
+ const rec = this.getChannel(channel);
2173
+ if (rec !== undefined) this.enqueue({ kind: 'upsertChannel', row: rec });
2174
+ }
2175
+
2176
+ override setChannelKeepTopic(channel: string, on: boolean): void {
2177
+ super.setChannelKeepTopic(channel, on);
2178
+ const rec = this.getChannel(channel);
2179
+ if (rec !== undefined) this.enqueue({ kind: 'upsertChannel', row: rec });
2180
+ }
2181
+
2182
+ override setChannelTopic(channel: string, topic: PersistedTopic | undefined): void {
2183
+ super.setChannelTopic(channel, topic);
2184
+ const rec = this.getChannel(channel);
2185
+ if (rec !== undefined) this.enqueue({ kind: 'upsertChannel', row: rec });
2186
+ }
2187
+
2188
+ override setChannelAccess(channel: string, account: string, level: ChannelAccessLevel): void {
2189
+ const existed = this.getChannelAccess(channel, account) > 0;
2190
+ super.setChannelAccess(channel, account, level);
2191
+ if (level > 0) {
2192
+ this.enqueue({ kind: 'upsertChannelAccess', row: { channel, account, level } });
2193
+ } else if (existed) {
2194
+ this.enqueue({ kind: 'deleteChannelAccess', channel, account });
2195
+ }
2196
+ }
2197
+
2198
+ override setChannelLevel(channel: string, op: ChannelLevelOp, level: number): void {
2199
+ super.setChannelLevel(channel, op, level);
2200
+ this.enqueue({ kind: 'upsertChannelLevel', row: { channel, op, level } });
2201
+ }
2202
+
2203
+ override resetChannelLevels(channel: string): void {
2204
+ super.resetChannelLevels(channel);
2205
+ this.enqueue({ kind: 'resetChannelLevels', channel });
2206
+ }
2207
+
2208
+ // ----- HostServ -----
2209
+
2210
+ override setVhost(account: string, vhost: string): void {
2211
+ const existed = this.getVhost(account) !== undefined;
2212
+ super.setVhost(account, vhost);
2213
+ if (vhost !== '') {
2214
+ this.enqueue({ kind: 'upsertVhost', row: { account, vhost } });
2215
+ } else if (existed) {
2216
+ this.enqueue({ kind: 'deleteVhost', account });
2217
+ }
2218
+ }
2219
+
2220
+ override requestVhost(account: string, vhost: string): void {
2221
+ super.requestVhost(account, vhost);
2222
+ this.enqueue({ kind: 'upsertPendingVhost', row: { account, vhost } });
2223
+ }
2224
+
2225
+ override approveVhost(account: string): string | undefined {
2226
+ const vhost = super.approveVhost(account);
2227
+ if (vhost !== undefined) {
2228
+ this.enqueue({ kind: 'deletePendingVhost', account });
2229
+ this.enqueue({ kind: 'upsertVhost', row: { account, vhost } });
2230
+ }
2231
+ return vhost;
2232
+ }
2233
+
2234
+ override rejectVhost(account: string): string | undefined {
2235
+ const vhost = super.rejectVhost(account);
2236
+ if (vhost !== undefined) {
2237
+ this.enqueue({ kind: 'deletePendingVhost', account });
2238
+ }
2239
+ return vhost;
2240
+ }
2241
+
2242
+ // ----- MemoServ -----
2243
+
2244
+ override recordMemo(memo: MemoInput): number {
2245
+ const id = super.recordMemo(memo);
2246
+ this.enqueue({ kind: 'insertMemo', row: { ...memo, id, read: false } });
2247
+ return id;
2248
+ }
2249
+
2250
+ override markMemoRead(account: string, id: number): boolean {
2251
+ const marked = super.markMemoRead(account, id);
2252
+ if (marked) this.enqueue({ kind: 'updateMemoRead', account, id });
2253
+ return marked;
2254
+ }
2255
+
2256
+ override deleteMemo(account: string, id: number): boolean {
2257
+ const removed = super.deleteMemo(account, id);
2258
+ if (removed) this.enqueue({ kind: 'deleteMemo', account, id });
2259
+ return removed;
2260
+ }
2261
+
2262
+ // ----- Read-marker (IRCv3 draft/read-marker) -----
2263
+
2264
+ override setLastReadMarker(account: string, channel: string, msgid: string): void {
2265
+ super.setLastReadMarker(account, channel, msgid);
2266
+ this.enqueue({ kind: 'upsertReadMarker', row: { account, channel, msgid } });
2267
+ }
2268
+
2269
+ // ----- OperServ -----
2270
+
2271
+ override addAkill(mask: string, reason: string): void {
2272
+ super.addAkill(mask, reason);
2273
+ // super.addAkill always inserts (or replaces in place), so the entry is
2274
+ // guaranteed present; read it back to capture the exact `createdAt`
2275
+ // super stored (avoids a second `clock.now()` call drifting from the
2276
+ // in-memory value).
2277
+ const row = this.listAkills().find((a) => folded(a.mask) === folded(mask)) as AkillEntry;
2278
+ this.enqueue({ kind: 'upsertAkill', row });
2279
+ }
2280
+
2281
+ override removeAkill(mask: string): boolean {
2282
+ const removed = super.removeAkill(mask);
2283
+ if (removed) this.enqueue({ kind: 'deleteAkill', mask });
2284
+ return removed;
2285
+ }
2286
+
2287
+ override addJupe(name: string, reason?: string): void {
2288
+ super.addJupe(name, reason);
2289
+ const row = this.listJupes().find((j) => folded(j.name) === folded(name)) as JupeEntry;
2290
+ this.enqueue({ kind: 'upsertJupe', row });
2291
+ }
2292
+
2293
+ override removeJupe(name: string): boolean {
2294
+ const removed = super.removeJupe(name);
2295
+ if (removed) this.enqueue({ kind: 'deleteJupe', name });
2296
+ return removed;
2297
+ }
2298
+ }
2299
+
2300
+ /**
2301
+ * Returns true iff `hostmask` matches the IRC ban mask `mask`. Identical
2302
+ * to the implementation in `privmsg.ts` / `join.ts`; duplicated so the
2303
+ * services layer stays self-contained and the matching rule is testable
2304
+ * in isolation. Comparison is case-insensitive (the `i` regex flag) so a
2305
+ * `*!*@Bad.Example.Net` mask catches `evil!spam@bad.example.net`.
2306
+ */
2307
+ function matchesAkillMask(mask: string, hostmask: string): boolean {
2308
+ let pattern = '^';
2309
+ for (const ch of mask) {
2310
+ if (ch === '*') {
2311
+ pattern += '.*';
2312
+ } else if (ch === '?') {
2313
+ pattern += '.';
2314
+ } else if (/[A-Za-z0-9]/.test(ch)) {
2315
+ pattern += ch;
2316
+ } else {
2317
+ pattern += `\\${ch}`;
2318
+ }
2319
+ }
2320
+ pattern += '$';
2321
+ return new RegExp(pattern, 'i').test(hostmask);
2322
+ }
2323
+
755
2324
  // ============================================================================
756
2325
  // Nick history (WHOWAS)
757
2326
  // ============================================================================