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
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Pure unit tests for `DynamoServicesStore`.
3
+ *
4
+ * The DynamoDB-Local-backed round-trip lives in
5
+ * `dynamo-services-store-dynamo.test.ts` (gated on `DDB_AVAILABLE`). This
6
+ * suite runs everywhere by injecting a **recording fake** document client
7
+ * that answers `Scan` / `Query` / `BatchWriteItem` / `UpdateItem` against
8
+ * an in-process JS store keyed by `(pk, sk)`. Every `applyWrites` branch
9
+ * and every `scanSnapshot` coercion is exercised here, so the new code is
10
+ * covered locally even on hosts without Docker / Java.
11
+ */
12
+
13
+ import type { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb';
14
+ import { FakeClock } from '@serverless-ircd/irc-core';
15
+ import { describe, expect, it } from 'vitest';
16
+ import { DynamoServicesStore, loadDynamoServicesStore } from '../src/dynamo-services-store';
17
+
18
+ const NOW = 1_700_000_000_000;
19
+
20
+ /**
21
+ * Minimal fake `DynamoDBDocumentClient`. Implements only `send` for the
22
+ * command shapes the store emits. Items live in a `Map<"pk\x00sk", item>`.
23
+ */
24
+ function makeFakeClient(initial: Array<Record<string, unknown>> = []): {
25
+ client: { send: (cmd: { input: unknown; constructor: { name: string } }) => Promise<unknown> };
26
+ items: Map<string, Record<string, unknown>>;
27
+ } {
28
+ const items = new Map<string, Record<string, unknown>>();
29
+ for (const item of initial) {
30
+ items.set(`${String(item.pk)}\x00${String(item.sk)}`, { ...item });
31
+ }
32
+ const scan = (input: { TableName?: string; ExclusiveStartKey?: unknown }): {
33
+ Items: Record<string, unknown>[];
34
+ LastEvaluatedKey: undefined;
35
+ } => {
36
+ void input.TableName;
37
+ void input.ExclusiveStartKey;
38
+ return { Items: [...items.values()].map((i) => ({ ...i })), LastEvaluatedKey: undefined };
39
+ };
40
+ const query = (input: {
41
+ KeyConditionExpression?: string;
42
+ ExpressionAttributeValues?: Record<string, unknown>;
43
+ ProjectionExpression?: string;
44
+ }): { Items: Array<{ pk: string; sk: string }> } => {
45
+ const pk = String(input.ExpressionAttributeValues?.[':pk']);
46
+ const prefix = input.ExpressionAttributeValues?.[':prefix'];
47
+ const out: Array<{ pk: string; sk: string }> = [];
48
+ for (const item of items.values()) {
49
+ if (String(item.pk) !== pk) continue;
50
+ if (typeof prefix === 'string' && !String(item.sk).startsWith(prefix)) continue;
51
+ out.push({ pk: String(item.pk), sk: String(item.sk) });
52
+ }
53
+ void input.KeyConditionExpression;
54
+ void input.ProjectionExpression;
55
+ return { Items: out };
56
+ };
57
+ const client = {
58
+ async send(cmd: { input: unknown; constructor: { name: string } }): Promise<unknown> {
59
+ switch (cmd.constructor.name) {
60
+ case 'ScanCommand':
61
+ return scan(cmd.input as Parameters<typeof scan>[0]);
62
+ case 'QueryCommand':
63
+ return query(cmd.input as Parameters<typeof query>[0]);
64
+ case 'BatchWriteCommand': {
65
+ const reqs = (cmd.input as BatchWriteCommandInput).RequestItems ?? {};
66
+ for (const [table, list] of Object.entries(reqs)) {
67
+ void table;
68
+ for (const req of list) {
69
+ if (req.PutRequest !== undefined) {
70
+ const item = req.PutRequest.Item as Record<string, unknown>;
71
+ items.set(`${String(item.pk)}\x00${String(item.sk)}`, { ...item });
72
+ } else if (req.DeleteRequest !== undefined) {
73
+ const key = req.DeleteRequest.Key as { pk: string; sk: string };
74
+ items.delete(`${key.pk}\x00${key.sk}`);
75
+ }
76
+ }
77
+ }
78
+ return { UnprocessedItems: {} };
79
+ }
80
+ case 'UpdateCommand': {
81
+ const input = cmd.input as {
82
+ Key: { pk: string; sk: string };
83
+ UpdateExpression?: string;
84
+ ExpressionAttributeValues?: Record<string, unknown>;
85
+ };
86
+ const k = `${input.Key.pk}\x00${input.Key.sk}`;
87
+ const item = items.get(k);
88
+ if (item !== undefined && input.ExpressionAttributeValues?.[':r'] !== undefined) {
89
+ item.read = input.ExpressionAttributeValues[':r'];
90
+ }
91
+ return {};
92
+ }
93
+ default:
94
+ throw new Error(`fake client: unhandled command ${cmd.constructor.name}`);
95
+ }
96
+ },
97
+ };
98
+ return { client, items };
99
+ }
100
+
101
+ // Cast helper: the store types against `DynamoDBDocumentClient`; the fake
102
+ // only implements `send`. The runtime contract is send-only.
103
+ type DocClient = ConstructorParameters<typeof DynamoServicesStore>[0]['docClient'];
104
+ const asClient = (fake: { send: unknown }): DocClient => fake as unknown as DocClient;
105
+
106
+ describe('loadDynamoServicesStore', () => {
107
+ it('returns undefined when the table name is absent', async () => {
108
+ const { client } = makeFakeClient();
109
+ const store = await loadDynamoServicesStore(asClient(client), undefined, new FakeClock(NOW));
110
+ expect(store).toBeUndefined();
111
+ });
112
+
113
+ it('returns an empty store on a fresh scan', async () => {
114
+ const { client } = makeFakeClient();
115
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
116
+ expect(store).toBeDefined();
117
+ expect(store?.snapshot()).toEqual({
118
+ nicks: [],
119
+ channels: [],
120
+ access: [],
121
+ levels: [],
122
+ vhosts: [],
123
+ pendingVhosts: [],
124
+ memos: [],
125
+ readMarkers: [],
126
+ akills: [],
127
+ jupes: [],
128
+ });
129
+ });
130
+ });
131
+
132
+ describe('DynamoServicesStore — round-trip via fake DDB', () => {
133
+ async function fresh(): Promise<{
134
+ store: DynamoServicesStore;
135
+ items: Map<string, Record<string, unknown>>;
136
+ client: { send: unknown };
137
+ }> {
138
+ const { client, items } = makeFakeClient();
139
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
140
+ if (store === undefined) throw new Error('store not loaded');
141
+ return { store, items, client };
142
+ }
143
+
144
+ async function reload(client: { send: unknown }): Promise<DynamoServicesStore> {
145
+ const store = await loadDynamoServicesStore(
146
+ asClient(client),
147
+ 'Services',
148
+ new FakeClock(NOW + 1),
149
+ );
150
+ if (store === undefined) throw new Error('store not loaded');
151
+ return store;
152
+ }
153
+
154
+ it('persists a NickServ registration + enforce + verifies after reload', async () => {
155
+ const { store, client } = await fresh();
156
+ store.registerNick('alice', 'hunter2', 'a@x');
157
+ store.setNickEnforce('alice', 'kill');
158
+ await store.flush();
159
+
160
+ const reloaded = await reload(client);
161
+ expect(reloaded.isRegisteredNick('Alice')).toBe(true);
162
+ expect(reloaded.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
163
+ expect(reloaded.getNickEnforce('alice')).toBe('kill');
164
+ });
165
+
166
+ it('drops a NickServ registration after flush + reload', async () => {
167
+ const { store, client } = await fresh();
168
+ store.registerNick('alice', 'pw', 'a@x');
169
+ await store.flush();
170
+ const mid = await reload(client);
171
+ mid.dropNick('alice');
172
+ await mid.flush();
173
+ expect((await reload(client)).isRegisteredNick('alice')).toBe(false);
174
+ });
175
+
176
+ it('persists channel + access + levels + topic, then drops the whole subtree', async () => {
177
+ const { store, client } = await fresh();
178
+ store.registerChannel('#chan', 'alice');
179
+ store.setChannelAccess('#chan', 'bob', 5);
180
+ store.setChannelLevel('#chan', 'AUTOOP', 7);
181
+ store.setChannelKeepTopic('#chan', true);
182
+ store.setChannelTopic('#chan', { text: 'hi', setter: 'alice', setAt: NOW });
183
+ await store.flush();
184
+
185
+ const reloaded = await reload(client);
186
+ expect(reloaded.getChannelFounder('#chan')).toBe('alice');
187
+ expect(reloaded.getChannelAccess('#chan', 'bob')).toBe(5);
188
+ expect(reloaded.getChannelLevel('#chan', 'AUTOOP')).toBe(7);
189
+ expect(reloaded.getChannelTopic('#chan')?.text).toBe('hi');
190
+
191
+ reloaded.dropChannel('#chan');
192
+ await reloaded.flush();
193
+ const after = await reload(client);
194
+ expect(after.getChannelFounder('#chan')).toBeUndefined();
195
+ expect(after.getChannelAccess('#chan', 'bob')).toBe(0);
196
+ expect(after.getChannelLevel('#chan', 'AUTOOP')).toBeUndefined();
197
+ });
198
+
199
+ it('resetChannelLevels wipes only levels, leaving access intact', async () => {
200
+ const { store, client } = await fresh();
201
+ store.registerChannel('#chan', 'alice');
202
+ store.setChannelAccess('#chan', 'bob', 5);
203
+ store.setChannelLevel('#chan', 'AUTOOP', 7);
204
+ await store.flush();
205
+ const mid = await reload(client);
206
+ mid.resetChannelLevels('#chan');
207
+ await mid.flush();
208
+
209
+ const after = await reload(client);
210
+ expect(after.getChannelLevel('#chan', 'AUTOOP')).toBeUndefined();
211
+ expect(after.getChannelAccess('#chan', 'bob')).toBe(5);
212
+ });
213
+
214
+ it('removes access on setChannelAccess level<=0', async () => {
215
+ const { store, client } = await fresh();
216
+ store.registerChannel('#chan', 'alice');
217
+ store.setChannelAccess('#chan', 'bob', 5);
218
+ store.setChannelAccess('#chan', 'bob', 0);
219
+ await store.flush();
220
+ expect((await reload(client)).getChannelAccess('#chan', 'bob')).toBe(0);
221
+ });
222
+
223
+ it('persists + clears a vhost', async () => {
224
+ const { store, client } = await fresh();
225
+ store.setVhost('alice', 'cloak.example');
226
+ await store.flush();
227
+ const mid = await reload(client);
228
+ expect(mid.getVhost('alice')).toBe('cloak.example');
229
+ mid.setVhost('alice', '');
230
+ await mid.flush();
231
+ expect((await reload(client)).getVhost('alice')).toBeUndefined();
232
+ });
233
+
234
+ it('persists a pending vhost and round-trips approve across reload', async () => {
235
+ const { store, client } = await fresh();
236
+ store.requestVhost('alice', 'queued.example');
237
+ await store.flush();
238
+ const mid = await reload(client);
239
+ expect(mid.getPendingVhost('alice')).toBe('queued.example');
240
+ expect(mid.getVhost('alice')).toBeUndefined();
241
+ // approveVhost activates the request and clears the pending entry.
242
+ mid.approveVhost('alice');
243
+ await mid.flush();
244
+ const approved = await reload(client);
245
+ expect(approved.getPendingVhost('alice')).toBeUndefined();
246
+ expect(approved.getVhost('alice')).toBe('queued.example');
247
+ });
248
+
249
+ it('persists a rejected pending vhost across reload', async () => {
250
+ const { store, client } = await fresh();
251
+ store.requestVhost('alice', 'queued.example');
252
+ await store.flush();
253
+ const mid = await reload(client);
254
+ mid.rejectVhost('alice');
255
+ await mid.flush();
256
+ const rejected = await reload(client);
257
+ expect(rejected.getPendingVhost('alice')).toBeUndefined();
258
+ expect(rejected.getVhost('alice')).toBeUndefined();
259
+ });
260
+
261
+ it('persists a memo, marks it read, deletes it', async () => {
262
+ const { store, client } = await fresh();
263
+ const id = store.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
264
+ await store.flush();
265
+ const mid = await reload(client);
266
+ expect(mid.listMemos('alice')).toHaveLength(1);
267
+ mid.markMemoRead('alice', id);
268
+ await mid.flush();
269
+ const read = await reload(client);
270
+ expect(read.listMemos('alice')[0]?.read).toBe(true);
271
+ // next id continues after reloaded max (the counter resumes from id).
272
+ expect(read.recordMemo({ from: 'x', to: 'alice', body: 'y', sentAt: NOW })).toBe(id + 1);
273
+ await read.flush();
274
+
275
+ const deleter = await reload(client);
276
+ deleter.deleteMemo('alice', id);
277
+ await deleter.flush();
278
+ const after = await reload(client);
279
+ // The deleted memo (id=1) is gone; the one created by the id-continuation
280
+ // check (id=2) remains — the assertion is "id was deleted", not "empty".
281
+ expect(after.listMemos('alice').find((m) => m.id === id)).toBeUndefined();
282
+ expect(after.listMemos('alice').find((m) => m.id === id + 1)).toBeDefined();
283
+ });
284
+
285
+ it('persists a read-marker', async () => {
286
+ const { store, client } = await fresh();
287
+ store.setLastReadMarker('alice', '#chan', 'm1');
288
+ await store.flush();
289
+ expect((await reload(client)).getLastReadMarker('alice', '#chan')).toBe('m1');
290
+ });
291
+
292
+ it('persists + matches + removes an AKILL', async () => {
293
+ const { store, client } = await fresh();
294
+ store.addAkill('*!*@bad.example', 'flooding');
295
+ await store.flush();
296
+ const mid = await reload(client);
297
+ expect(mid.matchAkill('evil!spam@bad.example')?.reason).toBe('flooding');
298
+ mid.removeAkill('*!*@bad.example');
299
+ await mid.flush();
300
+ expect((await reload(client)).matchAkill('evil!spam@bad.example')).toBeUndefined();
301
+ });
302
+
303
+ it('persists + queries + removes a JUPE', async () => {
304
+ const { store, client } = await fresh();
305
+ store.addJupe('moo', 'blocked');
306
+ await store.flush();
307
+ const mid = await reload(client);
308
+ expect(mid.isJuped('moo')).toBe(true);
309
+ mid.removeJupe('moo');
310
+ await mid.flush();
311
+ expect((await reload(client)).isJuped('moo')).toBe(false);
312
+ });
313
+ });
314
+
315
+ describe('DynamoServicesStore — snapshot coercion tolerance', () => {
316
+ it('skips items with unknown or ill-typed fields', async () => {
317
+ // Hand-craft malformed items directly in the fake table.
318
+ const { client, items } = makeFakeClient();
319
+ items.set('NICK:b\x00#', {
320
+ pk: 'NICK:b',
321
+ sk: '#',
322
+ type: 'nick',
323
+ nick: 'b',
324
+ account: 'b',
325
+ email: 7,
326
+ }); // ill-typed email
327
+ items.set('NICK:c\x00#', { pk: 'NICK:c', sk: '#' }); // no type discriminator
328
+ items.set('CHAN:x\x00#META', {
329
+ pk: 'CHAN:x',
330
+ sk: '#META',
331
+ type: 'channel',
332
+ channel: '#x',
333
+ founder: 'f',
334
+ createdAt: NOW,
335
+ mlock: '',
336
+ restricted: 'yes', // ill-typed boolean
337
+ keepTopic: false,
338
+ });
339
+ items.set('MEMO:9\x00#', {
340
+ pk: 'MEMO:9',
341
+ sk: '#',
342
+ type: 'memo',
343
+ id: 9,
344
+ from: 'a',
345
+ to: 'b',
346
+ body: 'x',
347
+ sentAt: NOW,
348
+ read: 'no', // ill-typed boolean
349
+ });
350
+ // Ill-typed items for the remaining sections (covers each loader's skip).
351
+ items.set('VH:bad\x00#', { pk: 'VH:bad', sk: '#', type: 'vhost', account: 7, vhost: 'x' }); // account non-string
352
+ items.set('PV:bad\x00#', {
353
+ pk: 'PV:bad',
354
+ sk: '#',
355
+ type: 'pendingvhost',
356
+ account: 'a',
357
+ vhost: 7,
358
+ }); // vhost non-string
359
+ items.set('RM:bad\x00#c', {
360
+ pk: 'RM:bad',
361
+ sk: '#c',
362
+ type: 'readmarker',
363
+ account: 'a',
364
+ channel: '#c',
365
+ msgid: 7,
366
+ }); // msgid non-string
367
+ items.set('ACC:bad\x00ACC:b', {
368
+ pk: 'CHAN:bad\x00ACC:b',
369
+ sk: 'ACC:b',
370
+ type: 'chanaccess',
371
+ channel: '#c',
372
+ account: 'b',
373
+ level: 'big',
374
+ }); // level non-number
375
+ items.set('LVL:bad\x00LVL:AUTOOP', {
376
+ pk: 'CHAN:bad\x00LVL:AUTOOP',
377
+ sk: 'LVL:AUTOOP',
378
+ type: 'chanlevel',
379
+ channel: '#c',
380
+ op: 'AUTOOP',
381
+ level: 'big',
382
+ }); // level non-number
383
+ items.set('AK:bad\x00#', {
384
+ pk: 'AK:bad',
385
+ sk: '#',
386
+ type: 'akill',
387
+ mask: '*!*@x',
388
+ reason: 'r',
389
+ createdAt: 'when',
390
+ }); // createdAt non-number
391
+ items.set('JP:bad\x00#', {
392
+ pk: 'JP:bad',
393
+ sk: '#',
394
+ type: 'jupe',
395
+ name: 'n',
396
+ reason: 'r',
397
+ createdAt: 'when',
398
+ }); // createdAt non-number
399
+ items.set('UNKNOWN\x00#', { pk: 'UNKNOWN', sk: '#', type: 'something-else' });
400
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
401
+ expect(store).toBeDefined();
402
+ if (!store) throw new Error('services store failed to load');
403
+ const snap = store.snapshot();
404
+ expect(snap.nicks).toEqual([]);
405
+ expect(snap.channels).toEqual([]);
406
+ expect(snap.memos).toEqual([]);
407
+ expect(snap.vhosts).toEqual([]);
408
+ expect(snap.pendingVhosts).toEqual([]);
409
+ expect(snap.readMarkers).toEqual([]);
410
+ expect(snap.access).toEqual([]);
411
+ expect(snap.levels).toEqual([]);
412
+ expect(snap.akills).toEqual([]);
413
+ expect(snap.jupes).toEqual([]);
414
+ });
415
+
416
+ it('hydrates a channel topic only when all three topic fields are present', async () => {
417
+ const { client, items } = makeFakeClient();
418
+ // channel with partial topic (missing topicSetAt) — topic should be dropped.
419
+ items.set('CHAN:t\x00#META', {
420
+ pk: 'CHAN:t',
421
+ sk: '#META',
422
+ type: 'channel',
423
+ channel: '#t',
424
+ founder: 'f',
425
+ createdAt: NOW,
426
+ mlock: '',
427
+ restricted: false,
428
+ keepTopic: true,
429
+ topicText: 'hi',
430
+ topicSetter: 'f',
431
+ });
432
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
433
+ const ch = store?.getChannel('#t');
434
+ expect(ch).toBeDefined();
435
+ expect(ch?.topic).toBeUndefined();
436
+ });
437
+ });
438
+
439
+ describe('DynamoServicesStore — batch chunking', () => {
440
+ it('flushes more than 25 puts across multiple BatchWriteItem calls', async () => {
441
+ const { client, items } = makeFakeClient();
442
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
443
+ expect(store).toBeDefined();
444
+ // 30 distinct channel access rows → 30 PutRequests → 2 batches (25 + 5).
445
+ for (let i = 0; i < 30; i++) {
446
+ store?.registerChannel(`#c${i}`, 'alice');
447
+ }
448
+ await store?.flush();
449
+ // Each channel produced exactly one item (#META). 30 channels → 30 items.
450
+ let channelItems = 0;
451
+ for (const item of items.values()) {
452
+ if (item.type === 'channel') channelItems++;
453
+ }
454
+ expect(channelItems).toBe(30);
455
+ });
456
+ });
457
+
458
+ describe('DynamoServicesStore — no-op flush', () => {
459
+ it('a flush with no mutations completes without writing', async () => {
460
+ const { client } = makeFakeClient();
461
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
462
+ await expect(store?.flush()).resolves.toBeUndefined();
463
+ });
464
+ });
465
+
466
+ describe('loadDynamoServicesStore — scan failure tolerance', () => {
467
+ it('returns an empty store when the initial scan throws', async () => {
468
+ // A client whose ScanCommand rejects → loadDynamoServicesStore swallows
469
+ // the failure into an empty snapshot (the deployer provisions the table;
470
+ // a flush against the missing table surfaces the real error).
471
+ const throwingClient = {
472
+ async send(cmd: { constructor: { name: string } }): Promise<unknown> {
473
+ if (cmd.constructor.name === 'ScanCommand') {
474
+ throw new Error('TableNotFound');
475
+ }
476
+ throw new Error(`unhandled command ${cmd.constructor.name}`);
477
+ },
478
+ };
479
+ const store = await loadDynamoServicesStore(
480
+ asClient(throwingClient),
481
+ 'Services',
482
+ new FakeClock(NOW),
483
+ );
484
+ expect(store).toBeDefined();
485
+ expect(store?.listAkills()).toEqual([]);
486
+ expect(store?.listMemos('alice')).toEqual([]);
487
+ });
488
+
489
+ it('constructs with defaults (no clock / no snapshot)', async () => {
490
+ const { client } = makeFakeClient();
491
+ // Direct construction without clock/snapshot exercises the constructor's
492
+ // undefined branches (loadDynamoServicesStore always passes both).
493
+ const store = new DynamoServicesStore({ docClient: asClient(client), tableName: 'Services' });
494
+ store.registerNick('alice', 'pw', 'a@x');
495
+ await store.flush();
496
+ expect(store.isRegisteredNick('alice')).toBe(true);
497
+ });
498
+
499
+ it('tolerates undefined Items in scan/query results (defensive ?? [])', async () => {
500
+ // A client that omits Items from Scan/Query output exercises the
501
+ // `result.Items ?? []` defensive fallback in scanSnapshot + queryChannelKeys.
502
+ const undefItemsClient = {
503
+ async send(cmd: { constructor: { name: string } }): Promise<unknown> {
504
+ switch (cmd.constructor.name) {
505
+ case 'ScanCommand':
506
+ return { LastEvaluatedKey: undefined }; // no Items field
507
+ case 'QueryCommand':
508
+ return {}; // no Items field
509
+ case 'BatchWriteCommand':
510
+ return { UnprocessedItems: {} };
511
+ case 'UpdateCommand':
512
+ return {};
513
+ default:
514
+ throw new Error(`unhandled command ${cmd.constructor.name}`);
515
+ }
516
+ },
517
+ };
518
+ const store = await loadDynamoServicesStore(
519
+ asClient(undefItemsClient),
520
+ 'Services',
521
+ new FakeClock(NOW),
522
+ );
523
+ expect(store?.snapshot().nicks).toEqual([]);
524
+ // deleteChannel fans out via queryChannelKeys (Query with undefined Items).
525
+ store?.registerChannel('#chan', 'alice');
526
+ await store?.flush();
527
+ store?.dropChannel('#chan');
528
+ await store?.flush();
529
+ expect(store?.getChannelFounder('#chan')).toBeUndefined();
530
+ });
531
+
532
+ it('skips query results with non-string pk/sk keys', async () => {
533
+ // queryChannelKeys guards each item's pk/sk; a row where pk is numeric
534
+ // is dropped (covers the `typeof === 'string'` false branch).
535
+ const badKeyClient = {
536
+ async send(cmd: { constructor: { name: string } }): Promise<unknown> {
537
+ switch (cmd.constructor.name) {
538
+ case 'ScanCommand':
539
+ return { Items: [] };
540
+ case 'QueryCommand':
541
+ return {
542
+ Items: [
543
+ { pk: 7, sk: 'x' },
544
+ { pk: 'CHAN:#c', sk: '#META' },
545
+ ],
546
+ };
547
+ case 'BatchWriteCommand':
548
+ return { UnprocessedItems: {} };
549
+ case 'UpdateCommand':
550
+ return {};
551
+ default:
552
+ throw new Error(`unhandled command ${cmd.constructor.name}`);
553
+ }
554
+ },
555
+ };
556
+ const store = await loadDynamoServicesStore(
557
+ asClient(badKeyClient),
558
+ 'Services',
559
+ new FakeClock(NOW),
560
+ );
561
+ store?.registerChannel('#c', 'alice');
562
+ await store?.flush();
563
+ // dropChannel queries the partition; the numeric-pk row is skipped, the
564
+ // valid row is deleted. No throw, channel founder gone afterwards.
565
+ store?.dropChannel('#c');
566
+ await expect(store?.flush()).resolves.toBeUndefined();
567
+ });
568
+ });
@@ -71,7 +71,11 @@ export default async function globalSetup(): Promise<(() => Promise<void>) | und
71
71
  const wantZip = strategy === 'auto' || strategy === 'zip';
72
72
 
73
73
  // Strategy 1: testcontainers (Docker).
74
- if (wantDocker) {
74
+ // Pre-probe the Docker daemon: `docker info` hangs indefinitely on macOS
75
+ // when Docker Desktop is installed but not running (the CLI waits for
76
+ // the daemon to spin up). Bound that wait so we fall through to the
77
+ // ZIP strategy instead of stalling the whole test run.
78
+ if (wantDocker && (await dockerDaemonReachable())) {
75
79
  try {
76
80
  const handle = await tryTestcontainers();
77
81
  if (handle !== null) {
@@ -85,6 +89,10 @@ export default async function globalSetup(): Promise<(() => Promise<void>) | und
85
89
  } catch (err: unknown) {
86
90
  console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
87
91
  }
92
+ } else if (wantDocker) {
93
+ console.warn(
94
+ '[aws-adapter global-setup] Docker daemon not reachable within probe window; skipping testcontainers.',
95
+ );
88
96
  }
89
97
 
90
98
  // Strategy 2: local ZIP + Java.
@@ -119,6 +127,25 @@ export default async function globalSetup(): Promise<(() => Promise<void>) | und
119
127
  // Strategy 1: testcontainers
120
128
  // ---------------------------------------------------------------------------
121
129
 
130
+ /**
131
+ * Bounds the `docker info` probe. The Docker CLI on macOS waits silently for
132
+ * the daemon to start when Docker Desktop is installed but stopped, which
133
+ * would otherwise hang the entire global setup. Treat anything that doesn't
134
+ * return within `DOCKER_PROBE_MS` as "unavailable" and fall through to the
135
+ * ZIP strategy.
136
+ */
137
+ const DOCKER_PROBE_MS = 5_000;
138
+
139
+ async function dockerDaemonReachable(): Promise<boolean> {
140
+ if (which('docker') === null) return false;
141
+ try {
142
+ await execAsync('docker info', { timeout: DOCKER_PROBE_MS });
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
122
149
  async function tryTestcontainers(): Promise<DdbHandle | null> {
123
150
  let mod: typeof import('testcontainers') | undefined;
124
151
  try {
@@ -10,10 +10,10 @@
10
10
 
11
11
  import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
- import { PutCommand } from '@aws-sdk/lib-dynamodb';
13
+ import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
14
14
  import { type Nick, type ParsedServerConfig, createConnection } from '@serverless-ircd/irc-core';
15
15
  import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
16
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
17
  import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
18
18
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
19
19
  import { createDynamoDocumentClient } from '../src/dynamo.js';
@@ -219,6 +219,25 @@ describe.skipIf(!available)('GoneException cleanup', () => {
219
219
  ).resolves.toBeUndefined();
220
220
  });
221
221
 
222
+ it('cleanupConnection reads the Connections row strongly consistently', async () => {
223
+ // $disconnect runs cleanupConnection immediately after the connection's
224
+ // own row was last written/updated. An eventually-consistent Get can miss
225
+ // that just-written row under replication lag and skip nick release +
226
+ // roster fanout. The Get MUST be strongly consistent.
227
+ await seedConnection(requireFx().client, requireFx().tables, 'cr', 'crnick', '#room');
228
+ const sendSpy = vi.spyOn(requireFx().client, 'send');
229
+ await cleanupConnection(requireFx().client, requireFx().tables, 'cr', null);
230
+ const conGets = sendSpy.mock.calls
231
+ .map((call) => call[0] as unknown)
232
+ .filter(
233
+ (cmd): cmd is GetCommand =>
234
+ cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
235
+ );
236
+ expect(conGets.length).toBeGreaterThan(0);
237
+ expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
238
+ sendSpy.mockRestore();
239
+ });
240
+
222
241
  it('broadcast() skips and cleans up gone members', async () => {
223
242
  await seedConnection(requireFx().client, requireFx().tables, 'gone3', 'eve', '#room');
224
243
  const mgmt: PostToConnection = {