serverless-ircd 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
// ============================================================================
|