serverless-ircd 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -528,3 +528,66 @@ describe('InMemoryMessageStore — query defensive branches', () => {
528
528
  ).toEqual([]);
529
529
  });
530
530
  });
531
+
532
+ // ============================================================================
533
+ // findAtOrBefore / getMsg — timestamp<->msgid translation for draft/read-marker
534
+ // MARKREAD support. findAtOrBefore resolves a client-supplied server-time
535
+ // timestamp to the most recent retained message at or before it; getMsg
536
+ // resolves a stored marker msgid back to its message (for the echo + the
537
+ // monotonicity check).
538
+ // ============================================================================
539
+ describe('InMemoryMessageStore — findAtOrBefore (timestamp -> message)', () => {
540
+ it('returns the message whose time equals the timestamp exactly', () => {
541
+ const store = seededStore(); // m1..m5 at 1_000..5_000
542
+ expect(store.findAtOrBefore('#foo', 3_000)?.msgid).toBe('m3');
543
+ });
544
+
545
+ it('returns the most recent message at or before an in-between timestamp', () => {
546
+ const store = seededStore();
547
+ // 3_500 is between m3 (3_000) and m4 (4_000); the marker resolves to m3.
548
+ expect(store.findAtOrBefore('#foo', 3_500)?.msgid).toBe('m3');
549
+ });
550
+
551
+ it('returns the newest message when the timestamp is at/after the last', () => {
552
+ const store = seededStore();
553
+ expect(store.findAtOrBefore('#foo', 9_999)?.msgid).toBe('m5');
554
+ });
555
+
556
+ it('returns undefined when the timestamp predates every retained message', () => {
557
+ const store = seededStore();
558
+ expect(store.findAtOrBefore('#foo', 500)).toBeUndefined();
559
+ });
560
+
561
+ it('returns undefined for a channel with no retained messages', () => {
562
+ const store = new InMemoryMessageStore();
563
+ expect(store.findAtOrBefore('#ghost', 1_000)).toBeUndefined();
564
+ });
565
+
566
+ it('folds the channel name case-insensitively', () => {
567
+ const store = seededStore();
568
+ expect(store.findAtOrBefore('#FOO', 2_000)?.msgid).toBe('m2');
569
+ });
570
+ });
571
+
572
+ describe('InMemoryMessageStore — getMsg (msgid -> message)', () => {
573
+ it('returns the message for a retained msgid', () => {
574
+ const store = seededStore();
575
+ expect(store.getMsg('#foo', 'm3')?.text).toBe('three');
576
+ });
577
+
578
+ it('returns undefined for an unknown msgid', () => {
579
+ const store = seededStore();
580
+ expect(store.getMsg('#foo', 'ghost')).toBeUndefined();
581
+ });
582
+
583
+ it('returns undefined for an evicted (no-longer-retained) msgid', () => {
584
+ const store = seededStore(3); // keeps m3..m5, evicts m1..m2
585
+ expect(store.getMsg('#foo', 'm1')).toBeUndefined();
586
+ expect(store.getMsg('#foo', 'm4')?.msgid).toBe('m4');
587
+ });
588
+
589
+ it('folds the channel name case-insensitively', () => {
590
+ const store = seededStore();
591
+ expect(store.getMsg('#FoO', 'm2')?.msgid).toBe('m2');
592
+ });
593
+ });
@@ -0,0 +1,582 @@
1
+ /**
2
+ * `PersistentServicesStore` — the shared write-behind base class used by the
3
+ * D1 (Cloudflare) and DynamoDB (AWS) backends.
4
+ *
5
+ * The services port is synchronous (reducers stay free of IO); persistent
6
+ * adapters pre-load a snapshot into the in-memory cache and enqueue
7
+ * write-behind ops on every mutation. This suite verifies the enqueue /
8
+ * flush contract and the snapshot hydration round-trip using a recording
9
+ * fake backend — no real database is touched.
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import { hashAccountCredential } from '../src/credential-hashing';
14
+ import {
15
+ FakeClock,
16
+ InMemoryServicesStore,
17
+ type MemoInput,
18
+ PersistentServicesStore,
19
+ type ServicesNickRow,
20
+ type ServicesSnapshot,
21
+ type ServicesWrite,
22
+ } from '../src/ports';
23
+
24
+ const NOW = 1_700_000_000_000;
25
+
26
+ /**
27
+ * Concrete recording subclass: collects every flushed batch in order so the
28
+ * tests can assert which ops each mutator produced.
29
+ */
30
+ class RecordingStore extends PersistentServicesStore {
31
+ readonly batches: ServicesWrite[][] = [];
32
+ protected async applyWrites(writes: ServicesWrite[]): Promise<void> {
33
+ this.batches.push(writes);
34
+ }
35
+ /** Test-only exposure of the protected helper so its `undefined` branch is covered. */
36
+ peekStoredNick(nick: string): ServicesNickRow | undefined {
37
+ return this.getStoredNick(nick);
38
+ }
39
+ }
40
+
41
+ function recordingStore(): RecordingStore {
42
+ return new RecordingStore({ clock: new FakeClock(NOW) });
43
+ }
44
+
45
+ // ============================================================================
46
+ // Snapshot hydration
47
+ // ============================================================================
48
+
49
+ describe('PersistentServicesStore — snapshot hydration', () => {
50
+ it('hydrates from an empty snapshot into an empty store', () => {
51
+ const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: emptySnapshot() });
52
+ expect(s.listAkills()).toEqual([]);
53
+ expect(s.listMemos('alice')).toEqual([]);
54
+ });
55
+
56
+ it('round-trips a full snapshot through a peer InMemoryServicesStore', () => {
57
+ const original = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
58
+ original.registerNick('alice', 'pw', 'a@example.com');
59
+ original.setNickEnforce('alice', 'ghost');
60
+ original.registerChannel('#chan', 'alice');
61
+ original.setChannelAccess('#chan', 'bob', 5);
62
+ original.setChannelLevel('#chan', 'AUTOOP', 10);
63
+ original.setVhost('alice', 'hidden.example');
64
+ original.requestVhost('bob', 'pending.example');
65
+ original.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
66
+ original.setLastReadMarker('alice', '#chan', 'msgid-1');
67
+ original.addAkill('*!*@bad.example', 'flooding');
68
+ original.addJupe('jupedNick', 'reason');
69
+
70
+ const snap = original.snapshot();
71
+ const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
72
+
73
+ // NickServ
74
+ expect(reloaded.isRegisteredNick('Alice')).toBe(true);
75
+ expect(reloaded.getNickEnforce('alice')).toBe('ghost');
76
+ expect(reloaded.verifyNick('alice', 'pw')).toEqual({ ok: true, account: 'alice' });
77
+ // ChanServ
78
+ expect(reloaded.getChannelFounder('#chan')).toBe('alice');
79
+ expect(reloaded.getChannelAccess('#chan', 'bob')).toBe(5);
80
+ expect(reloaded.getChannelLevel('#chan', 'AUTOOP')).toBe(10);
81
+ // HostServ
82
+ expect(reloaded.getVhost('alice')).toBe('hidden.example');
83
+ expect(reloaded.getPendingVhost('bob')).toBe('pending.example');
84
+ // MemoServ — id preserved across reload
85
+ const memos = reloaded.listMemos('alice');
86
+ expect(memos).toHaveLength(1);
87
+ expect(memos[0]).toMatchObject({ id: 1, from: 'bob', body: 'hi' });
88
+ // Next memo id continues after the reloaded max.
89
+ expect(reloaded.recordMemo({ from: 'x', to: 'alice', body: 'y', sentAt: NOW })).toBe(2);
90
+ // Read marker
91
+ expect(reloaded.getLastReadMarker('alice', '#chan')).toBe('msgid-1');
92
+ // OperServ
93
+ expect(reloaded.matchAkill('user!~u@bad.example')?.reason).toBe('flooding');
94
+ expect(reloaded.isJuped('JupedNick')).toBe(true);
95
+ });
96
+
97
+ it('snapshot omits topic for a channel that never had KEEPTOPIC set', () => {
98
+ const s = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
99
+ s.registerChannel('#notopic', 'alice');
100
+ const snap = s.snapshot();
101
+ expect(snap.channels).toHaveLength(1);
102
+ expect(snap.channels[0]?.topic).toBeUndefined();
103
+ // Hydrating a no-topic channel round-trips (covers the hydrate false branch).
104
+ const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
105
+ expect(reloaded.getChannel('#notopic')?.topic).toBeUndefined();
106
+ expect(reloaded.getChannelFounder('#notopic')).toBe('alice');
107
+ });
108
+
109
+ it('hydrate reuses existing maps for multi-entry groups + round-trips a topic', () => {
110
+ // A snapshot with multiple entries per channel / account exercises every
111
+ // "map already exists, reuse it" branch in hydrate (access, levels,
112
+ // memos, readMarkers) plus the topic-present branch in snapshot+hydrate.
113
+ const original = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
114
+ original.registerChannel('#chan', 'alice');
115
+ original.setChannelKeepTopic('#chan', true);
116
+ original.setChannelTopic('#chan', { text: 't', setter: 'alice', setAt: NOW });
117
+ original.setChannelAccess('#chan', 'bob', 5);
118
+ original.setChannelAccess('#chan', 'carol', 3); // second entry → reuse map
119
+ original.setChannelLevel('#chan', 'AUTOOP', 10);
120
+ original.setChannelLevel('#chan', 'AUTOVOICE', 1); // second entry → reuse map
121
+ original.recordMemo({ from: 'x', to: 'alice', body: 'm1', sentAt: NOW });
122
+ original.recordMemo({ from: 'y', to: 'alice', body: 'm2', sentAt: NOW }); // second → reuse arr
123
+ original.setLastReadMarker('alice', '#chan', 'a');
124
+ original.setLastReadMarker('alice', '#other', 'b'); // second → reuse chanMap
125
+
126
+ const snap = original.snapshot();
127
+ const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
128
+ expect(reloaded.getChannelTopic('#chan')?.text).toBe('t');
129
+ expect(reloaded.getChannelAccess('#chan', 'carol')).toBe(3);
130
+ expect(reloaded.getChannelLevel('#chan', 'AUTOVOICE')).toBe(1);
131
+ expect(reloaded.listMemos('alice')).toHaveLength(2);
132
+ expect(reloaded.getLastReadMarker('alice', '#other')).toBe('b');
133
+ });
134
+
135
+ it('hydrate handles non-monotonic memo ids (counter max is idempotent)', () => {
136
+ // A snapshot whose memos are out of order exercises the
137
+ // `memo.id > memoCounter === false` branch in hydrate.
138
+ const snap: ServicesSnapshot = {
139
+ nicks: [],
140
+ channels: [],
141
+ access: [],
142
+ levels: [],
143
+ vhosts: [],
144
+ pendingVhosts: [],
145
+ memos: [
146
+ { id: 5, from: 'x', to: 'alice', body: 'big', sentAt: NOW, read: false },
147
+ { id: 2, from: 'y', to: 'alice', body: 'small', sentAt: NOW, read: false },
148
+ ],
149
+ readMarkers: [],
150
+ akills: [],
151
+ jupes: [],
152
+ };
153
+ const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
154
+ expect(s.listMemos('alice')).toHaveLength(2);
155
+ // Counter resumes after the max id (5), not the last-seen.
156
+ expect(s.recordMemo({ from: 'z', to: 'alice', body: 'next', sentAt: NOW })).toBe(6);
157
+ });
158
+
159
+ it('getStoredNick returns undefined for an unregistered nick', () => {
160
+ const s = recordingStore();
161
+ expect(s.peekStoredNick('ghost')).toBeUndefined();
162
+ s.registerNick('alice', 'pw', 'a@x');
163
+ // The credential is hashed scrypt; the plaintext password is not stored.
164
+ const stored = s.peekStoredNick('alice');
165
+ expect(stored).toBeDefined();
166
+ expect(stored?.credential.algorithm).toBe('scrypt');
167
+ expect(stored?.credential.account).toBe('alice');
168
+ expect((stored as unknown as Record<string, unknown>).password).toBeUndefined();
169
+ });
170
+ });
171
+
172
+ // ============================================================================
173
+ // Write-behind enqueue / flush
174
+ // ============================================================================
175
+
176
+ describe('PersistentServicesStore — enqueue + flush', () => {
177
+ it('flush is a no-op when no mutations occurred', async () => {
178
+ const s = recordingStore();
179
+ await s.flush();
180
+ expect(s.batches).toEqual([]);
181
+ });
182
+
183
+ it('enqueues a single batch covering many mutations and drains it on flush', async () => {
184
+ const s = recordingStore();
185
+ s.registerNick('alice', 'pw', 'a@example.com');
186
+ s.registerChannel('#chan', 'alice');
187
+ s.addAkill('*!*@bad', 'reason');
188
+ await s.flush();
189
+ expect(s.batches).toHaveLength(1);
190
+ const ops = s.batches[0]?.map((w) => w.kind) ?? [];
191
+ expect(ops).toContain('upsertNick');
192
+ expect(ops).toContain('upsertChannel');
193
+ expect(ops).toContain('upsertAkill');
194
+ // Queue is drained after flush.
195
+ await s.flush();
196
+ expect(s.batches).toHaveLength(1);
197
+ });
198
+
199
+ it('coalesces concurrent flush calls into a single applyWrites invocation', async () => {
200
+ const s = recordingStore();
201
+ s.registerNick('alice', 'pw', 'a@x');
202
+ const [a, b] = await Promise.all([s.flush(), s.flush()]);
203
+ expect(a).toBe(b);
204
+ expect(s.batches).toHaveLength(1);
205
+ });
206
+ });
207
+
208
+ // ============================================================================
209
+ // Per-mutator op coverage
210
+ // ============================================================================
211
+
212
+ describe('PersistentServicesStore — per-mutator ops', () => {
213
+ function ops(s: RecordingStore): ServicesWrite[] {
214
+ return s.batches.flat();
215
+ }
216
+
217
+ it('registerNick → upsertNick (with hashed credential + createdAt)', async () => {
218
+ const s = recordingStore();
219
+ s.registerNick('alice', 'hunter2', 'a@x');
220
+ await s.flush();
221
+ const op = ops(s).find((w) => w.kind === 'upsertNick');
222
+ expect(op).toBeDefined();
223
+ if (op?.kind === 'upsertNick') {
224
+ expect(op.row).toMatchObject({ nick: 'alice', email: 'a@x' });
225
+ expect(op.row.createdAt).toBe(NOW);
226
+ // The credential is scrypt-hashed; the plaintext is not on the row.
227
+ expect(op.row.credential.algorithm).toBe('scrypt');
228
+ expect(op.row.credential.account).toBe('alice');
229
+ expect((op.row as unknown as Record<string, unknown>).password).toBeUndefined();
230
+ expect(JSON.stringify(op.row)).not.toContain('hunter2');
231
+ }
232
+ });
233
+
234
+ it('a duplicate registerNick enqueues nothing', async () => {
235
+ const s = recordingStore();
236
+ s.registerNick('alice', 'pw', 'a@x');
237
+ s.registerNick('alice', 'pw', 'a@x'); // rejected
238
+ await s.flush();
239
+ expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
240
+ });
241
+
242
+ it('dropNick → deleteNick only when something was removed', async () => {
243
+ const s = recordingStore();
244
+ s.dropNick('ghost'); // nothing to remove
245
+ s.registerNick('alice', 'pw', 'a@x');
246
+ s.dropNick('alice');
247
+ await s.flush();
248
+ expect(ops(s).some((w) => w.kind === 'deleteNick')).toBe(true);
249
+ });
250
+
251
+ it('setNickEnforce → upsertNick (full row re-persisted)', async () => {
252
+ const s = recordingStore();
253
+ s.registerNick('alice', 'pw', 'a@x');
254
+ s.setNickEnforce('alice', 'kill');
255
+ await s.flush();
256
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
257
+ const last = upserts[upserts.length - 1];
258
+ if (last?.kind === 'upsertNick') expect(last.row.enforce).toBe('kill');
259
+ });
260
+
261
+ it('setNickEnforce on an unregistered nick enqueues nothing', async () => {
262
+ const s = recordingStore();
263
+ s.setNickEnforce('ghost', 'kill');
264
+ await s.flush();
265
+ expect(s.batches).toEqual([]);
266
+ });
267
+
268
+ it('ingestAccountCredential enqueues upsertNick when a new credential is adopted', async () => {
269
+ const s = recordingStore();
270
+ const cred = hashAccountCredential('alice', 'hunter2');
271
+ s.ingestAccountCredential(cred);
272
+ await s.flush();
273
+ expect(ops(s).some((w) => w.kind === 'upsertNick')).toBe(true);
274
+ const op = ops(s).find((w) => w.kind === 'upsertNick');
275
+ if (op?.kind === 'upsertNick') {
276
+ expect(op.row.account).toBe('alice');
277
+ expect(op.row.credential.algorithm).toBe('scrypt');
278
+ }
279
+ });
280
+
281
+ it('ingestAccountCredential enqueues nothing when the nick is already registered', async () => {
282
+ const s = recordingStore();
283
+ s.registerNick('alice', 'pw', 'a@x');
284
+ const cred = hashAccountCredential('alice', 'otherpw');
285
+ s.ingestAccountCredential(cred);
286
+ await s.flush();
287
+ expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
288
+ });
289
+
290
+ it('registerChannel → upsertChannel; duplicate enqueues once', async () => {
291
+ const s = recordingStore();
292
+ s.registerChannel('#chan', 'alice');
293
+ s.registerChannel('#chan', 'alice');
294
+ await s.flush();
295
+ expect(ops(s).filter((w) => w.kind === 'upsertChannel')).toHaveLength(1);
296
+ });
297
+
298
+ it('dropChannel → deleteChannel (clears access + levels remotely)', async () => {
299
+ const s = recordingStore();
300
+ s.registerChannel('#chan', 'alice');
301
+ s.setChannelAccess('#chan', 'bob', 5);
302
+ s.setChannelLevel('#chan', 'AUTOOP', 3);
303
+ s.dropChannel('#chan');
304
+ await s.flush();
305
+ expect(ops(s).some((w) => w.kind === 'deleteChannel')).toBe(true);
306
+ });
307
+
308
+ it('dropChannel on an unregistered channel enqueues nothing', async () => {
309
+ const s = recordingStore();
310
+ s.dropChannel('#ghost');
311
+ await s.flush();
312
+ expect(s.batches).toEqual([]);
313
+ });
314
+
315
+ it.each([
316
+ 'setChannelFounder',
317
+ 'setChannelMlock',
318
+ 'setChannelRestricted',
319
+ 'setChannelKeepTopic',
320
+ ] as const)('%s → upsertChannel', async (method) => {
321
+ const s = recordingStore();
322
+ s.registerChannel('#chan', 'alice');
323
+ switch (method) {
324
+ case 'setChannelFounder':
325
+ s.setChannelFounder('#chan', 'bob');
326
+ break;
327
+ case 'setChannelMlock':
328
+ s.setChannelMlock('#chan', '+nrt');
329
+ break;
330
+ case 'setChannelRestricted':
331
+ s.setChannelRestricted('#chan', true);
332
+ break;
333
+ case 'setChannelKeepTopic':
334
+ s.setChannelKeepTopic('#chan', true);
335
+ break;
336
+ }
337
+ await s.flush();
338
+ const upserts = ops(s).filter((w) => w.kind === 'upsertChannel');
339
+ expect(upserts.length).toBeGreaterThanOrEqual(1);
340
+ });
341
+
342
+ it('setChannelAccess level>0 → upsertChannelAccess; level<=0 → deleteChannelAccess', async () => {
343
+ const s = recordingStore();
344
+ s.registerChannel('#chan', 'alice');
345
+ s.setChannelAccess('#chan', 'bob', 5);
346
+ s.setChannelAccess('#chan', 'bob', 0);
347
+ await s.flush();
348
+ const all = ops(s);
349
+ expect(all.some((w) => w.kind === 'upsertChannelAccess')).toBe(true);
350
+ expect(all.some((w) => w.kind === 'deleteChannelAccess')).toBe(true);
351
+ });
352
+
353
+ it('setChannelLevel → upsertChannelLevel; resetChannelLevels → resetChannelLevels', async () => {
354
+ const s = recordingStore();
355
+ s.registerChannel('#chan', 'alice');
356
+ s.setChannelLevel('#chan', 'AUTOOP', 7);
357
+ s.resetChannelLevels('#chan');
358
+ await s.flush();
359
+ expect(ops(s).some((w) => w.kind === 'upsertChannelLevel')).toBe(true);
360
+ expect(ops(s).some((w) => w.kind === 'resetChannelLevels')).toBe(true);
361
+ });
362
+
363
+ it('setChannelTopic → upsertChannel (carrying the topic snapshot)', async () => {
364
+ const s = recordingStore();
365
+ s.registerChannel('#chan', 'alice');
366
+ s.setChannelKeepTopic('#chan', true);
367
+ s.setChannelTopic('#chan', { text: 'hello', setter: 'alice', setAt: NOW });
368
+ await s.flush();
369
+ const last = ops(s)
370
+ .filter((w) => w.kind === 'upsertChannel')
371
+ .pop();
372
+ if (last?.kind === 'upsertChannel') expect(last.row.topic?.text).toBe('hello');
373
+ });
374
+
375
+ it.each([
376
+ 'setChannelFounder',
377
+ 'setChannelMlock',
378
+ 'setChannelRestricted',
379
+ 'setChannelKeepTopic',
380
+ 'setChannelTopic',
381
+ ] as const)('%s on an unregistered channel enqueues nothing', async (method) => {
382
+ const s = recordingStore();
383
+ switch (method) {
384
+ case 'setChannelFounder':
385
+ s.setChannelFounder('#ghost', 'bob');
386
+ break;
387
+ case 'setChannelMlock':
388
+ s.setChannelMlock('#ghost', '+nrt');
389
+ break;
390
+ case 'setChannelRestricted':
391
+ s.setChannelRestricted('#ghost', true);
392
+ break;
393
+ case 'setChannelKeepTopic':
394
+ s.setChannelKeepTopic('#ghost', true);
395
+ break;
396
+ case 'setChannelTopic':
397
+ s.setChannelTopic('#ghost', { text: 'x', setter: 'y', setAt: NOW });
398
+ break;
399
+ }
400
+ await s.flush();
401
+ expect(s.batches).toEqual([]);
402
+ });
403
+
404
+ it('setChannelAccess level<=0 on a non-existent entry enqueues nothing', async () => {
405
+ const s = recordingStore();
406
+ s.setChannelAccess('#ghost', 'bob', 0);
407
+ await s.flush();
408
+ expect(s.batches).toEqual([]);
409
+ });
410
+
411
+ it('setVhost non-empty → upsertVhost; empty → deleteVhost', async () => {
412
+ const s = recordingStore();
413
+ s.setVhost('alice', 'cloak.example');
414
+ s.setVhost('alice', '');
415
+ await s.flush();
416
+ expect(ops(s).some((w) => w.kind === 'upsertVhost')).toBe(true);
417
+ expect(ops(s).some((w) => w.kind === 'deleteVhost')).toBe(true);
418
+ });
419
+
420
+ it('setVhost empty on an account with no prior vhost enqueues nothing', async () => {
421
+ const s = recordingStore();
422
+ s.setVhost('ghost', '');
423
+ await s.flush();
424
+ expect(s.batches).toEqual([]);
425
+ });
426
+
427
+ it('requestVhost → upsertPendingVhost', async () => {
428
+ const s = recordingStore();
429
+ s.requestVhost('alice', 'queued.example');
430
+ await s.flush();
431
+ expect(ops(s).some((w) => w.kind === 'upsertPendingVhost')).toBe(true);
432
+ const op = ops(s).find((w) => w.kind === 'upsertPendingVhost');
433
+ if (op?.kind === 'upsertPendingVhost') {
434
+ expect(op.row).toEqual({ account: 'alice', vhost: 'queued.example' });
435
+ }
436
+ });
437
+
438
+ it('approveVhost → deletePendingVhost + upsertVhost (activation)', async () => {
439
+ const s = recordingStore();
440
+ s.requestVhost('alice', 'queued.example');
441
+ s.approveVhost('alice');
442
+ await s.flush();
443
+ expect(ops(s).some((w) => w.kind === 'upsertPendingVhost')).toBe(true);
444
+ expect(ops(s).some((w) => w.kind === 'deletePendingVhost')).toBe(true);
445
+ expect(ops(s).some((w) => w.kind === 'upsertVhost')).toBe(true);
446
+ const upsert = ops(s)
447
+ .filter((w) => w.kind === 'upsertVhost')
448
+ .pop();
449
+ if (upsert?.kind === 'upsertVhost') {
450
+ expect(upsert.row).toEqual({ account: 'alice', vhost: 'queued.example' });
451
+ }
452
+ });
453
+
454
+ it('approveVhost on an unknown account enqueues nothing', async () => {
455
+ const s = recordingStore();
456
+ s.approveVhost('ghost');
457
+ await s.flush();
458
+ expect(s.batches).toEqual([]);
459
+ });
460
+
461
+ it('rejectVhost → deletePendingVhost only (no activation)', async () => {
462
+ const s = recordingStore();
463
+ s.requestVhost('alice', 'queued.example');
464
+ s.rejectVhost('alice');
465
+ await s.flush();
466
+ expect(ops(s).some((w) => w.kind === 'upsertPendingVhost')).toBe(true);
467
+ expect(ops(s).some((w) => w.kind === 'deletePendingVhost')).toBe(true);
468
+ expect(ops(s).some((w) => w.kind === 'upsertVhost')).toBe(false);
469
+ });
470
+
471
+ it('rejectVhost on an unknown account enqueues nothing', async () => {
472
+ const s = recordingStore();
473
+ s.rejectVhost('ghost');
474
+ await s.flush();
475
+ expect(s.batches).toEqual([]);
476
+ });
477
+
478
+ it('snapshot round-trips pending vhost requests across a peer store', () => {
479
+ const s = recordingStore();
480
+ s.requestVhost('alice', 'one.example');
481
+ s.requestVhost('bob', 'two.example');
482
+ const snap = s.snapshot();
483
+ expect(snap.pendingVhosts).toContainEqual({ account: 'alice', vhost: 'one.example' });
484
+ expect(snap.pendingVhosts).toContainEqual({ account: 'bob', vhost: 'two.example' });
485
+ const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
486
+ expect(reloaded.getPendingVhost('alice')).toBe('one.example');
487
+ expect(reloaded.getPendingVhost('bob')).toBe('two.example');
488
+ expect(reloaded.listPendingVhosts()).toHaveLength(2);
489
+ });
490
+
491
+ it('recordMemo → insertMemo with the assigned id', async () => {
492
+ const s = recordingStore();
493
+ const memo: MemoInput = { from: 'bob', to: 'alice', body: 'hi', sentAt: NOW };
494
+ const id = s.recordMemo(memo);
495
+ await s.flush();
496
+ const op = ops(s).find((w) => w.kind === 'insertMemo');
497
+ if (op?.kind === 'insertMemo') expect(op.row.id).toBe(id);
498
+ });
499
+
500
+ it('markMemoRead → updateMemoRead only when the memo existed', async () => {
501
+ const s = recordingStore();
502
+ s.markMemoRead('alice', 9); // no such memo
503
+ s.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
504
+ s.markMemoRead('alice', 1);
505
+ await s.flush();
506
+ expect(ops(s).some((w) => w.kind === 'updateMemoRead')).toBe(true);
507
+ });
508
+
509
+ it('deleteMemo → deleteMemo only when something was deleted', async () => {
510
+ const s = recordingStore();
511
+ s.deleteMemo('alice', 9); // no such memo
512
+ s.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
513
+ s.deleteMemo('alice', 1);
514
+ await s.flush();
515
+ expect(ops(s).some((w) => w.kind === 'deleteMemo')).toBe(true);
516
+ });
517
+
518
+ it('setLastReadMarker → upsertReadMarker', async () => {
519
+ const s = recordingStore();
520
+ s.setLastReadMarker('alice', '#chan', 'm1');
521
+ await s.flush();
522
+ expect(ops(s).some((w) => w.kind === 'upsertReadMarker')).toBe(true);
523
+ });
524
+
525
+ it('addAkill → upsertAkill; removeAkill → deleteAkill', async () => {
526
+ const s = recordingStore();
527
+ s.addAkill('*!*@bad', 'reason');
528
+ s.removeAkill('*!*@bad');
529
+ await s.flush();
530
+ expect(ops(s).some((w) => w.kind === 'upsertAkill')).toBe(true);
531
+ expect(ops(s).some((w) => w.kind === 'deleteAkill')).toBe(true);
532
+ });
533
+
534
+ it('removeAkill on an unknown mask enqueues nothing', async () => {
535
+ const s = recordingStore();
536
+ s.removeAkill('*!*@unknown');
537
+ await s.flush();
538
+ expect(s.batches).toEqual([]);
539
+ });
540
+
541
+ it('addJupe → upsertJupe; removeJupe → deleteJupe', async () => {
542
+ const s = recordingStore();
543
+ s.addJupe('bad', 'reason');
544
+ s.removeJupe('bad');
545
+ await s.flush();
546
+ expect(ops(s).some((w) => w.kind === 'upsertJupe')).toBe(true);
547
+ expect(ops(s).some((w) => w.kind === 'deleteJupe')).toBe(true);
548
+ });
549
+
550
+ it('removeJupe on an unknown name enqueues nothing', async () => {
551
+ const s = recordingStore();
552
+ s.removeJupe('unknown');
553
+ await s.flush();
554
+ expect(s.batches).toEqual([]);
555
+ });
556
+
557
+ it('mutated state is visible synchronously (before flush)', () => {
558
+ const s = recordingStore();
559
+ s.registerNick('alice', 'pw', 'a@x');
560
+ expect(s.isRegisteredNick('alice')).toBe(true);
561
+ expect(s.verifyNick('alice', 'pw')).toEqual({ ok: true, account: 'alice' });
562
+ });
563
+ });
564
+
565
+ // ============================================================================
566
+ // helpers
567
+ // ============================================================================
568
+
569
+ function emptySnapshot(): ServicesSnapshot {
570
+ return {
571
+ nicks: [],
572
+ channels: [],
573
+ access: [],
574
+ levels: [],
575
+ vhosts: [],
576
+ pendingVhosts: [],
577
+ memos: [],
578
+ readMarkers: [],
579
+ akills: [],
580
+ jupes: [],
581
+ };
582
+ }