serverless-ircd 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,582 @@
1
+ /**
2
+ * D1-backed {@link PersistentServicesStore} for the Cloudflare adapter.
3
+ *
4
+ * Exercises the real D1 round-trip via miniflare: every services mutator
5
+ * enqueues a write-behind op; `flush()` drains it into D1; a fresh
6
+ * `loadD1ServicesStore` against the same binding rehydrates the snapshot.
7
+ * This is the services-state persistence contract — a deploy (cold start)
8
+ * must not lose NickServ / ChanServ / HostServ / MemoServ / OperServ state.
9
+ */
10
+
11
+ import { env } from 'cloudflare:test';
12
+ import { FakeClock, type ServicesSnapshot } from '@serverless-ircd/irc-core';
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
+ import {
15
+ CREATE_SERVICES_TABLES_SQL,
16
+ D1ServicesStore,
17
+ loadD1ServicesStore,
18
+ } from '../src/d1-services-store';
19
+
20
+ declare global {
21
+ namespace Cloudflare {
22
+ interface Env {
23
+ ACCOUNTS_DB: D1Database;
24
+ }
25
+ }
26
+ }
27
+
28
+ const NOW = 1_700_000_000_000;
29
+
30
+ async function resetDb(): Promise<void> {
31
+ for (const table of [
32
+ 'nickserv_accounts',
33
+ 'chanserv_channels',
34
+ 'chanserv_access',
35
+ 'chanserv_levels',
36
+ 'hostserv_vhosts',
37
+ 'hostserv_pending_vhosts',
38
+ 'memoserv_memos',
39
+ 'read_markers',
40
+ 'operserv_akills',
41
+ 'operserv_jupes',
42
+ ]) {
43
+ await env.ACCOUNTS_DB.exec(`DROP TABLE IF EXISTS ${table}`);
44
+ }
45
+ for (const sql of CREATE_SERVICES_TABLES_SQL) {
46
+ await env.ACCOUNTS_DB.exec(sql);
47
+ }
48
+ }
49
+
50
+ describe('loadD1ServicesStore — empty binding', () => {
51
+ it('returns undefined when the D1 binding is absent', async () => {
52
+ const store = await loadD1ServicesStore(undefined);
53
+ expect(store).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe('loadD1ServicesStore — read-from-primary consistency', () => {
58
+ /**
59
+ * Builds a fake D1 binding that records the constraint passed to
60
+ * `withSession`. Every prepare/batch resolves to empty results so the
61
+ * load completes; the point is to assert the snapshot hydrate runs
62
+ * through a primary-forcing session, not to exercise D1 itself (miniflare
63
+ * has no replicas, so replica lag cannot be reproduced end-to-end here).
64
+ */
65
+ function makeRecordingFakeD1(): { db: unknown; constraints: string[] } {
66
+ const constraints: string[] = [];
67
+ const stmt = {
68
+ bind(): unknown {
69
+ return stmt;
70
+ },
71
+ async all(): Promise<{ results: unknown[] }> {
72
+ return { results: [] };
73
+ },
74
+ async run(): Promise<{ results: unknown[] }> {
75
+ return { results: [] };
76
+ },
77
+ };
78
+ const client = {
79
+ prepare(): unknown {
80
+ return stmt;
81
+ },
82
+ async batch(): Promise<unknown[]> {
83
+ return [];
84
+ },
85
+ };
86
+ const db = {
87
+ ...client,
88
+ withSession(constraint: string): unknown {
89
+ constraints.push(constraint);
90
+ return client;
91
+ },
92
+ };
93
+ return { db, constraints };
94
+ }
95
+
96
+ it('hydrates the snapshot through a first-primary D1 session', async () => {
97
+ const { db, constraints } = makeRecordingFakeD1();
98
+ const store = await loadD1ServicesStore(db as D1Database, new FakeClock(NOW));
99
+ expect(store).toBeDefined();
100
+ // Forces the first read to the primary so a reconnecting client cannot
101
+ // hydrate from a lagging replica and cache it for the connection's life.
102
+ expect(constraints).toContain('first-primary');
103
+ });
104
+ });
105
+
106
+ describe('D1ServicesStore — schema migration (password → algorithm/salt/hash)', () => {
107
+ const LEGACY_NICKSERV_DDL =
108
+ 'CREATE TABLE nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, password TEXT NOT NULL)';
109
+
110
+ beforeEach(async () => {
111
+ // Start from the pre-unification schema (single `password` column).
112
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
113
+ await env.ACCOUNTS_DB.exec(LEGACY_NICKSERV_DDL);
114
+ });
115
+
116
+ afterEach(async () => {
117
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
118
+ });
119
+
120
+ it('migrates the legacy nickserv_accounts schema on load', async () => {
121
+ // Seed a legacy row (unusable under the scrypt verify path).
122
+ await env.ACCOUNTS_DB.prepare(
123
+ 'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, password) VALUES (?, ?, ?, ?, ?, ?, ?)',
124
+ )
125
+ .bind('alice', 'alice', 'alice', 'a@x', 1, 'none', 'legacy-hash')
126
+ .run();
127
+
128
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
129
+ expect(store).toBeDefined();
130
+
131
+ // The table now has the new credential columns and no `password`.
132
+ const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
133
+ name: string;
134
+ }>();
135
+ const cols = info.results.map((r) => r.name);
136
+ expect(cols).toContain('algorithm');
137
+ expect(cols).toContain('salt');
138
+ expect(cols).toContain('hash');
139
+ expect(cols).not.toContain('password');
140
+
141
+ // Legacy rows (non-scrypt credentials) are dropped during migration.
142
+ const rows = await env.ACCOUNTS_DB.prepare('SELECT COUNT(*) AS n FROM nickserv_accounts').all<{
143
+ n: number;
144
+ }>();
145
+ expect(rows.results[0]?.n).toBe(0);
146
+
147
+ // A fresh registration round-trips through the migrated schema.
148
+ store?.registerNick('bob', 'hunter2', 'b@example.com');
149
+ await store?.flush();
150
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1000));
151
+ expect(reloaded?.isRegisteredNick('bob')).toBe(true);
152
+ });
153
+
154
+ it('is idempotent — does not drop data on an already-migrated schema', async () => {
155
+ const first = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
156
+ first?.registerNick('carol', 'pw', 'c@x');
157
+ await first?.flush();
158
+
159
+ // Second load: migration is a no-op (`algorithm` present); carol survives.
160
+ const second = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1));
161
+ expect(second?.isRegisteredNick('carol')).toBe(true);
162
+ });
163
+ });
164
+
165
+ describe('D1ServicesStore — NickServ round-trip across reload', () => {
166
+ beforeEach(async () => {
167
+ await resetDb();
168
+ });
169
+
170
+ it('persists a NickServ registration and verifies after reload', async () => {
171
+ const clock = new FakeClock(NOW);
172
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, clock);
173
+ expect(store).toBeDefined();
174
+ store?.registerNick('alice', 'hunter2', 'a@example.com');
175
+ await store?.flush();
176
+
177
+ // Simulate a deploy: brand-new store rehydrating from D1.
178
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1000));
179
+ expect(reloaded).toBeDefined();
180
+ expect(reloaded?.isRegisteredNick('alice')).toBe(true);
181
+ // Case-insensitive lookup survives the round-trip.
182
+ expect(reloaded?.isRegisteredNick('Alice')).toBe(true);
183
+ expect(reloaded?.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
184
+ expect(reloaded?.verifyNick('alice', 'wrong').ok).toBe(false);
185
+ });
186
+
187
+ it('persists SET ENFORCE and round-trips the policy', async () => {
188
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
189
+ store?.registerNick('alice', 'pw', 'a@x');
190
+ store?.setNickEnforce('alice', 'kill');
191
+ await store?.flush();
192
+
193
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
194
+ expect(reloaded?.getNickEnforce('alice')).toBe('kill');
195
+ });
196
+
197
+ it('drops a NickServ registration after flush + reload', async () => {
198
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
199
+ store?.registerNick('alice', 'pw', 'a@x');
200
+ await store?.flush();
201
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
202
+ expect(mid?.isRegisteredNick('alice')).toBe(true);
203
+ mid?.dropNick('alice');
204
+ await mid?.flush();
205
+
206
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
207
+ expect(reloaded?.isRegisteredNick('alice')).toBe(false);
208
+ });
209
+ });
210
+
211
+ describe('D1ServicesStore — ChanServ round-trip across reload', () => {
212
+ beforeEach(async () => {
213
+ await resetDb();
214
+ });
215
+
216
+ it('persists channel registration + access + levels + topic', async () => {
217
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
218
+ store?.registerNick('alice', 'pw', 'a@x');
219
+ store?.registerChannel('#chan', 'alice');
220
+ store?.setChannelAccess('#chan', 'bob', 5);
221
+ store?.setChannelLevel('#chan', 'AUTOOP', 7);
222
+ store?.setChannelRestricted('#chan', true);
223
+ store?.setChannelKeepTopic('#chan', true);
224
+ store?.setChannelTopic('#chan', { text: 'hello', setter: 'alice', setAt: NOW });
225
+ await store?.flush();
226
+
227
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
228
+ expect(reloaded?.getChannelFounder('#chan')).toBe('alice');
229
+ expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(5);
230
+ expect(reloaded?.getChannelLevel('#chan', 'AUTOOP')).toBe(7);
231
+ expect(reloaded?.getChannel('#chan')?.restricted).toBe(true);
232
+ expect(reloaded?.getChannelTopic('#chan')).toEqual({
233
+ text: 'hello',
234
+ setter: 'alice',
235
+ setAt: NOW,
236
+ });
237
+ });
238
+
239
+ it('drops a channel + its access + levels on dropChannel', async () => {
240
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
241
+ store?.registerChannel('#chan', 'alice');
242
+ store?.setChannelAccess('#chan', 'bob', 5);
243
+ store?.setChannelLevel('#chan', 'AUTOOP', 7);
244
+ await store?.flush();
245
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
246
+ mid?.dropChannel('#chan');
247
+ await mid?.flush();
248
+
249
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
250
+ expect(reloaded?.getChannelFounder('#chan')).toBeUndefined();
251
+ expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(0);
252
+ expect(reloaded?.getChannelLevel('#chan', 'AUTOOP')).toBeUndefined();
253
+ });
254
+
255
+ it('removes access on setChannelAccess level<=0', async () => {
256
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
257
+ store?.registerChannel('#chan', 'alice');
258
+ store?.setChannelAccess('#chan', 'bob', 5);
259
+ store?.setChannelAccess('#chan', 'bob', 0);
260
+ await store?.flush();
261
+
262
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
263
+ expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(0);
264
+ });
265
+
266
+ it('resetChannelLevels wipes only levels, leaving access intact', async () => {
267
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
268
+ store?.registerChannel('#chan', 'alice');
269
+ store?.setChannelAccess('#chan', 'bob', 5);
270
+ store?.setChannelLevel('#chan', 'AUTOOP', 7);
271
+ await store?.flush();
272
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
273
+ mid?.resetChannelLevels('#chan');
274
+ await mid?.flush();
275
+
276
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
277
+ expect(reloaded?.getChannelLevel('#chan', 'AUTOOP')).toBeUndefined();
278
+ expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(5);
279
+ });
280
+ });
281
+
282
+ describe('D1ServicesStore — HostServ / MemoServ / read-marker round-trip', () => {
283
+ beforeEach(async () => {
284
+ await resetDb();
285
+ });
286
+
287
+ it('persists a vhost and clears it on empty', async () => {
288
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
289
+ store?.setVhost('alice', 'cloak.example');
290
+ await store?.flush();
291
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
292
+ expect(mid?.getVhost('alice')).toBe('cloak.example');
293
+ mid?.setVhost('alice', '');
294
+ await mid?.flush();
295
+
296
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
297
+ expect(reloaded?.getVhost('alice')).toBeUndefined();
298
+ });
299
+
300
+ it('persists a pending vhost request and round-trips it across reload', async () => {
301
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
302
+ store?.requestVhost('alice', 'queued.example');
303
+ await store?.flush();
304
+ expect(store?.getVhost('alice')).toBeUndefined();
305
+
306
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
307
+ expect(mid?.getPendingVhost('alice')).toBe('queued.example');
308
+ expect(mid?.getVhost('alice')).toBeUndefined();
309
+
310
+ // approveVhost activates the request and clears the pending entry.
311
+ mid?.approveVhost('alice');
312
+ await mid?.flush();
313
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
314
+ expect(reloaded?.getPendingVhost('alice')).toBeUndefined();
315
+ expect(reloaded?.getVhost('alice')).toBe('queued.example');
316
+ });
317
+
318
+ it('persists a rejected (dropped) pending request after flush + reload', async () => {
319
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
320
+ store?.requestVhost('alice', 'queued.example');
321
+ await store?.flush();
322
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
323
+ mid?.rejectVhost('alice');
324
+ await mid?.flush();
325
+
326
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
327
+ expect(reloaded?.getPendingVhost('alice')).toBeUndefined();
328
+ expect(reloaded?.getVhost('alice')).toBeUndefined();
329
+ });
330
+
331
+ it('delivers a queued memo after identify-style reload', async () => {
332
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
333
+ expect(store).toBeDefined();
334
+ if (!store) throw new Error('services store failed to load');
335
+ const id = store.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
336
+ store.markMemoRead('alice', id);
337
+ await store.flush();
338
+
339
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
340
+ expect(reloaded).toBeDefined();
341
+ if (!reloaded) throw new Error('reloaded services store failed to load');
342
+ const memos = reloaded.listMemos('alice');
343
+ expect(memos).toHaveLength(1);
344
+ expect(memos[0]).toMatchObject({ id, from: 'bob', body: 'hi', read: true });
345
+ // Next id continues after the reloaded max.
346
+ expect(reloaded.recordMemo({ from: 'x', to: 'alice', body: 'y', sentAt: NOW })).toBe(id + 1);
347
+ });
348
+
349
+ it('deletes a memo after flush + reload', async () => {
350
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
351
+ expect(store).toBeDefined();
352
+ if (!store) throw new Error('services store failed to load');
353
+ const id = store.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
354
+ await store.flush();
355
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
356
+ expect(mid).toBeDefined();
357
+ if (!mid) throw new Error('mid services store failed to load');
358
+ mid.deleteMemo('alice', id);
359
+ await mid.flush();
360
+
361
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
362
+ expect(reloaded?.listMemos('alice')).toEqual([]);
363
+ });
364
+
365
+ it('persists a draft/read-marker', async () => {
366
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
367
+ store?.setLastReadMarker('alice', '#chan', 'msgid-9');
368
+ await store?.flush();
369
+
370
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
371
+ expect(reloaded?.getLastReadMarker('alice', '#chan')).toBe('msgid-9');
372
+ });
373
+ });
374
+
375
+ describe('D1ServicesStore — OperServ round-trip', () => {
376
+ beforeEach(async () => {
377
+ await resetDb();
378
+ });
379
+
380
+ it('persists + matches an AKILL and removes it', async () => {
381
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
382
+ store?.addAkill('*!*@bad.example', 'flooding');
383
+ await store?.flush();
384
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
385
+ expect(mid?.matchAkill('evil!spam@bad.example')?.reason).toBe('flooding');
386
+ mid?.removeAkill('*!*@bad.example');
387
+ await mid?.flush();
388
+
389
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
390
+ expect(reloaded?.matchAkill('evil!spam@bad.example')).toBeUndefined();
391
+ });
392
+
393
+ it('persists + queries a JUPE and removes it', async () => {
394
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
395
+ store?.addJupe('moo', 'blocked');
396
+ await store?.flush();
397
+ const mid = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
398
+ expect(mid?.isJuped('moo')).toBe(true);
399
+ mid?.removeJupe('moo');
400
+ await mid?.flush();
401
+
402
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
403
+ expect(reloaded?.isJuped('moo')).toBe(false);
404
+ });
405
+ });
406
+
407
+ describe('D1ServicesStore — auto-provisioning', () => {
408
+ beforeEach(async () => {
409
+ await resetDb();
410
+ });
411
+
412
+ it('creates the services tables on load so a fresh deploy persists a registration without manual DDL', async () => {
413
+ // Simulate a fresh, un-provisioned deploy: every services table absent.
414
+ for (const table of [
415
+ 'nickserv_accounts',
416
+ 'chanserv_channels',
417
+ 'chanserv_access',
418
+ 'chanserv_levels',
419
+ 'hostserv_vhosts',
420
+ 'hostserv_pending_vhosts',
421
+ 'memoserv_memos',
422
+ 'read_markers',
423
+ 'operserv_akills',
424
+ 'operserv_jupes',
425
+ ]) {
426
+ await env.ACCOUNTS_DB.exec(`DROP TABLE IF EXISTS ${table}`);
427
+ }
428
+
429
+ // load must self-provision the schema; flush must not throw.
430
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
431
+ expect(store).toBeDefined();
432
+ if (!store) throw new Error('services store failed to load');
433
+ store.registerNick('alice', 'hunter2', 'a@example.com');
434
+ await expect(store.flush()).resolves.toBeUndefined();
435
+
436
+ // A new DO / cold start rehydrates the persisted registration.
437
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1000));
438
+ expect(reloaded?.isRegisteredNick('alice')).toBe(true);
439
+ expect(reloaded?.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
440
+ });
441
+ });
442
+
443
+ describe('D1ServicesStore — tolerance', () => {
444
+ it('loads an empty snapshot when the tables do not exist yet', async () => {
445
+ // Fresh deploy: no tables created. load must not throw.
446
+ for (const table of [
447
+ 'nickserv_accounts',
448
+ 'chanserv_channels',
449
+ 'chanserv_access',
450
+ 'chanserv_levels',
451
+ 'hostserv_vhosts',
452
+ 'hostserv_pending_vhosts',
453
+ 'memoserv_memos',
454
+ 'read_markers',
455
+ 'operserv_akills',
456
+ 'operserv_jupes',
457
+ ]) {
458
+ await env.ACCOUNTS_DB.exec(`DROP TABLE IF EXISTS ${table}`);
459
+ }
460
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
461
+ expect(store).toBeDefined();
462
+ expect(store?.snapshot()).toEqual(emptySnapshot());
463
+ });
464
+
465
+ it('a flush with no mutations is a no-op', async () => {
466
+ await resetDb();
467
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
468
+ await expect(store?.flush()).resolves.toBeUndefined();
469
+ });
470
+
471
+ it('skips ill-typed rows during load (defensive coercion)', async () => {
472
+ await resetDb();
473
+ // One ill-typed row per INTEGER-corruptible table → each loader's
474
+ // defensive `continue` fires. (All-TEXT tables — vhosts / read_markers —
475
+ // are covered by the permissive-table test below; SQLite TEXT affinity
476
+ // coerces inserted numbers to strings, so the string check cannot be
477
+ // exercised against a NOT NULL TEXT column.)
478
+ await env.ACCOUNTS_DB.prepare(
479
+ 'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
480
+ )
481
+ .bind('bad#nick', 'b', 'b', 'b', 'not-a-number', 'none', 'scrypt', 's', 'h')
482
+ .run();
483
+ await env.ACCOUNTS_DB.prepare(
484
+ 'INSERT INTO chanserv_channels (channel_key, channel, founder, created_at, mlock, restricted, keep_topic) VALUES (?, ?, ?, ?, ?, ?, ?)',
485
+ )
486
+ .bind('bad#chan', '#c', 'f', 1, '', 'not-a-number', 0)
487
+ .run();
488
+ await env.ACCOUNTS_DB.prepare(
489
+ 'INSERT INTO chanserv_access (channel_key, account_key, channel, account, level) VALUES (?, ?, ?, ?, ?)',
490
+ )
491
+ .bind('bad#a', 'b', '#c', 'b', 'not-a-number')
492
+ .run();
493
+ await env.ACCOUNTS_DB.prepare(
494
+ 'INSERT INTO chanserv_levels (channel_key, op, channel, level) VALUES (?, ?, ?, ?)',
495
+ )
496
+ .bind('bad#l', 'AUTOOP', '#c', 'not-a-number')
497
+ .run();
498
+ await env.ACCOUNTS_DB.prepare(
499
+ 'INSERT INTO memoserv_memos (id, from_account, to_account, body, sent_at, read) VALUES (?, ?, ?, ?, ?, ?)',
500
+ )
501
+ .bind(9, 'x', 'y', 'b', 'not-a-number', 0)
502
+ .run();
503
+ await env.ACCOUNTS_DB.prepare(
504
+ 'INSERT INTO operserv_akills (mask_key, mask, reason, created_at) VALUES (?, ?, ?, ?)',
505
+ )
506
+ .bind('bad#ak', '*!*@x', 'r', 'not-a-number')
507
+ .run();
508
+ await env.ACCOUNTS_DB.prepare(
509
+ 'INSERT INTO operserv_jupes (name_key, name, reason, created_at) VALUES (?, ?, ?, ?)',
510
+ )
511
+ .bind('bad#jp', 'n', 'r', 'not-a-number')
512
+ .run();
513
+
514
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
515
+ expect(store).toBeDefined();
516
+ if (!store) throw new Error('services store failed to load');
517
+ const snap = store.snapshot();
518
+ expect(snap.nicks).toEqual([]);
519
+ expect(snap.channels).toEqual([]);
520
+ expect(snap.access).toEqual([]);
521
+ expect(snap.levels).toEqual([]);
522
+ expect(snap.memos).toEqual([]);
523
+ expect(snap.akills).toEqual([]);
524
+ expect(snap.jupes).toEqual([]);
525
+ });
526
+
527
+ it('skips rows with NULL fields in the all-TEXT tables (vhosts / read_markers)', async () => {
528
+ // Recreate the two all-TEXT tables without NOT NULL so a NULL field can
529
+ // survive — exercises the typeof-string defensive skip for the loaders
530
+ // whose INTEGER-corruption path is unreachable under SQLite affinity.
531
+ await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
532
+ await env.ACCOUNTS_DB.exec(
533
+ 'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
534
+ );
535
+ await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
536
+ await env.ACCOUNTS_DB.exec(
537
+ 'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
538
+ );
539
+ await env.ACCOUNTS_DB.prepare(
540
+ 'INSERT INTO hostserv_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
541
+ )
542
+ .bind('bad#v', null, 'cloak')
543
+ .run();
544
+ await env.ACCOUNTS_DB.prepare(
545
+ 'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
546
+ )
547
+ .bind('bad#rm', '#c', 'a', '#c', null)
548
+ .run();
549
+
550
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
551
+ expect(store).toBeDefined();
552
+ if (!store) throw new Error('services store failed to load');
553
+ const snap = store.snapshot();
554
+ expect(snap.vhosts).toEqual([]);
555
+ expect(snap.readMarkers).toEqual([]);
556
+ });
557
+
558
+ it('constructs with defaults (no clock / no snapshot)', async () => {
559
+ await resetDb();
560
+ // Direct construction without clock/snapshot exercises the constructor's
561
+ // undefined branches (loadD1ServicesStore always passes both).
562
+ const store = new D1ServicesStore({ db: env.ACCOUNTS_DB });
563
+ store.registerNick('alice', 'pw', 'a@x');
564
+ await store.flush();
565
+ expect(store.isRegisteredNick('alice')).toBe(true);
566
+ });
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
+ }
@@ -35,6 +35,7 @@ function fullState(): ConnectionState {
35
35
  wallops: true,
36
36
  serverNotices: false,
37
37
  tls: true,
38
+ registered: false,
38
39
  };
39
40
  s.lastSeen = 200;
40
41
  return s;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Generates `src/build-info.ts` containing the build timestamp.
2
+ * Generates `src/build-info.ts` containing build-time constants.
3
+ *
4
+ * - `BUILD_DATE`: ISO timestamp of the build that produced this artifact.
5
+ * - `PACKAGE_VERSION`: the `version` field from `package.json`, so the
6
+ * default server version advertised on the wire always tracks the
7
+ * shipped package version without anyone remembering to update a
8
+ * hardcoded constant.
3
9
  *
4
10
  * Runs as a `prebuild` / `pretest` / `pretypecheck` / `prepare` hook so the
5
11
  * file always exists before tsc or vitest resolves `./build-info.js` imports.
6
12
  * The emitted file is gitignored — it is regenerated on every build so the
7
- * published `dist/index.js` carries the timestamp of the build that produced
13
+ * published `dist/index.js` carries the values of the build that produced
8
14
  * it, never a stale committed value.
9
15
  *
10
16
  * Output shape:
@@ -12,20 +18,35 @@
12
18
  * ```ts
13
19
  * // AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.
14
20
  * export const BUILD_DATE = '2024-01-01T00:00:00.000Z';
21
+ * export const PACKAGE_VERSION = '0.7.0';
15
22
  * ```
16
23
  *
17
24
  * Failures here must abort the build rather than emit a half-file: a missing
18
25
  * or malformed `build-info.ts` would break every downstream tsc invocation.
19
26
  */
20
- import { writeFileSync } from 'node:fs';
27
+ import { readFileSync, writeFileSync } from 'node:fs';
21
28
  import { dirname, resolve } from 'node:path';
22
29
  import { fileURLToPath } from 'node:url';
23
30
 
24
31
  const here = dirname(fileURLToPath(import.meta.url));
25
32
  const target = resolve(here, '..', 'src', 'build-info.ts');
33
+ const pkgPath = resolve(here, '..', 'package.json');
34
+
35
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
36
+ const pkgVersion = pkg?.version;
37
+ if (typeof pkgVersion !== 'string' || pkgVersion.length === 0) {
38
+ throw new Error(`[generate-build-info] package.json is missing a valid "version" string`);
39
+ }
26
40
 
27
41
  const iso = new Date().toISOString();
28
- const contents = `// AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.\nexport const BUILD_DATE = ${JSON.stringify(iso)};\n`;
42
+ const contents = [
43
+ '// AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.',
44
+ `export const BUILD_DATE = ${JSON.stringify(iso)};`,
45
+ `export const PACKAGE_VERSION = ${JSON.stringify(pkgVersion)};`,
46
+ '',
47
+ ].join('\n');
29
48
 
30
49
  writeFileSync(target, contents, 'utf8');
31
- console.log(`[generate-build-info] wrote ${target} (BUILD_DATE=${iso})`);
50
+ console.log(
51
+ `[generate-build-info] wrote ${target} (BUILD_DATE=${iso}, PACKAGE_VERSION=${pkgVersion})`,
52
+ );