serverless-ircd 0.8.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+ const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
8
+
9
+ // Pure-string assertions over landing/index.html. The file is copied verbatim
10
+ // to dist/index.html by scripts/build.mjs (verified separately by
11
+ // build-smoke.test.ts), so these checks run against the source in CI without
12
+ // paying the Kiwi SPA build cost.
13
+ const html = readFileSync(landingSrc, 'utf8');
14
+
15
+ describe('landing page "Connect" section', () => {
16
+ it('contains a "Connect" heading', () => {
17
+ // The project front door must tell visitors how to reach the IRC server.
18
+ expect(html).toMatch(/<h2[^>]*>\s*Connect\s*<\/h2>/i);
19
+ });
20
+
21
+ it('documents the WeeChat client', () => {
22
+ expect(html).toMatch(/WeeChat/i);
23
+ });
24
+
25
+ it('documents the HexChat client', () => {
26
+ expect(html).toMatch(/HexChat/i);
27
+ });
28
+
29
+ it('documents the irssi client', () => {
30
+ expect(html).toMatch(/irssi/i);
31
+ });
32
+
33
+ it('documents the IRCCloud client', () => {
34
+ expect(html).toMatch(/IRCCloud/i);
35
+ });
36
+
37
+ it('documents TheLounge client', () => {
38
+ expect(html).toMatch(/TheLounge/i);
39
+ });
40
+
41
+ it('mentions the wss (secure WebSocket) transport for the web client', () => {
42
+ expect(html).toMatch(/\bwss\b/i);
43
+ });
44
+
45
+ it('mentions port 443 for the wss transport', () => {
46
+ expect(html).toMatch(/443\b/);
47
+ });
48
+
49
+ it('mentions the irc+tls :6697 direct-connect transport', () => {
50
+ expect(html).toMatch(/6697\b/);
51
+ });
52
+
53
+ it('hides the low-level WebSocket subprotocol framing detail', () => {
54
+ // The subprotocol negotiation + 510-byte frame budget is IRC-wire-level
55
+ // detail that doesn't belong on the project front door. It lives in the
56
+ // docs; the page should not surface `text.ircv3.net` or the 510 figure.
57
+ expect(html).not.toMatch(/text\.ircv3\.net/i);
58
+ expect(html).not.toMatch(/\b510\b/);
59
+ });
60
+
61
+ it('links to the Web Client Guide doc', () => {
62
+ // Cross-references to the docs/ subtree keep the page terse while still
63
+ // pointing at full instructions. The docs are out-of-tree (git submodule)
64
+ // so the link is a relative repo path, not a served route.
65
+ expect(html).toMatch(/WebClientGuide/i);
66
+ });
67
+
68
+ it('references the tcp-ws-forwarder bridge for stock TCP clients', () => {
69
+ expect(html).toMatch(/tcp-ws-forwarder/i);
70
+ });
71
+
72
+ it('uses a generic hostname placeholder (no per-env host baked in)', () => {
73
+ // The landing page is built once and served across staging + prod, so it
74
+ // must NOT pin a concrete worker hostname.
75
+ expect(html).toMatch(/irc\.example\.com|{{\s*hostname\s*}}|your deployed Worker host/i);
76
+ });
77
+
78
+ it('includes a WeeChat /server add snippet', () => {
79
+ expect(html).toMatch(/\/server add/i);
80
+ });
81
+
82
+ it('renders the connect instructions as a radio-backed client tab picker', () => {
83
+ // A horizontal list of client "buttons" selects which instructions show.
84
+ // Radio inputs share one name so the picker works without JavaScript
85
+ // (the build-smoke suite asserts the page ships no <script> tags).
86
+ const tabRadios = html.match(/type=["']radio["'][^>]*name=["']client-tab["']/gi) ?? [];
87
+ expect(tabRadios.length).toBeGreaterThanOrEqual(4);
88
+ });
89
+
90
+ it('renders a tab label for each documented client', () => {
91
+ expect(html).toMatch(/<label[^>]*for=["']tab-web["']/i);
92
+ expect(html).toMatch(/<label[^>]*for=["']tab-weechat["']/i);
93
+ expect(html).toMatch(/<label[^>]*for=["']tab-hexchat["']/i);
94
+ expect(html).toMatch(/<label[^>]*for=["']tab-irccloud["']/i);
95
+ });
96
+
97
+ it('hides client panels by default and reveals the selected one via :checked', () => {
98
+ // Pure-CSS tabs: panels are display:none unless their radio is checked,
99
+ // then `:checked ~ #panel-x` flips them to display:block.
100
+ expect(html).toMatch(/\.panel\s*\{[^}]*display:\s*none/i);
101
+ expect(html).toMatch(/:checked\s*~\s*#panel-/i);
102
+ });
103
+ });
@@ -0,0 +1,198 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ statSync,
9
+ } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
+ import { buildDocs, renderMarkdown, rewriteDocLinks } from '../src/render-docs';
15
+
16
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const docsSrc = path.resolve(pkgRoot, '..', '..', 'docs');
18
+
19
+ // ============================================================================
20
+ // renderMarkdown — GFM rendering of markdown source
21
+ // ============================================================================
22
+ describe('renderMarkdown', () => {
23
+ it('renders an ATX H1 heading as <h1>', () => {
24
+ const html = renderMarkdown('# Title');
25
+ expect(html).toMatch(/<h1[^>]*>\s*Title\s*<\/h1>/);
26
+ });
27
+
28
+ it('renders a fenced code block as <pre><code>', () => {
29
+ const md = ['```ts', 'const x = 1;', '```'].join('\n');
30
+ const html = renderMarkdown(md);
31
+ expect(html).toMatch(/<pre[^>]*><code[^>]*>/);
32
+ });
33
+
34
+ it('renders GFM tables', () => {
35
+ const md = ['| a | b |', '| --- | --- |', '| 1 | 2 |'].join('\n');
36
+ const html = renderMarkdown(md);
37
+ expect(html).toContain('<table>');
38
+ expect(html).toContain('<th');
39
+ });
40
+
41
+ it('renders inline code', () => {
42
+ const html = renderMarkdown('Use `npm` to install.');
43
+ expect(html).toContain('<code>npm</code>');
44
+ });
45
+ });
46
+
47
+ // ============================================================================
48
+ // rewriteDocLinks — intra-docs link rewriting
49
+ // ============================================================================
50
+ describe('rewriteDocLinks', () => {
51
+ const known = new Set([
52
+ 'Services',
53
+ 'Home',
54
+ 'ADR-Index',
55
+ 'ADR-001-pure-reducers-and-effect-system',
56
+ ]);
57
+
58
+ it('rewrites a relative OtherDoc.md href to OtherDoc.html', () => {
59
+ const input = '<a href="./Services.md">Services</a>';
60
+ const out = rewriteDocLinks(input, known);
61
+ expect(out).toContain('href="Services.html"');
62
+ expect(out).not.toContain('Services.md');
63
+ });
64
+
65
+ it('rewrites a bare wikilink-style href (no extension) when the slug is a known doc', () => {
66
+ const input = '<a href="Services">Services</a>';
67
+ const out = rewriteDocLinks(input, known);
68
+ expect(out).toContain('href="Services.html"');
69
+ });
70
+
71
+ it('leaves unknown bare hrefs alone (could be an external path)', () => {
72
+ const input = '<a href="SomeUnknownPage">x</a>';
73
+ const out = rewriteDocLinks(input, new Set(['Services']));
74
+ expect(out).toContain('href="SomeUnknownPage"');
75
+ });
76
+
77
+ it('preserves an anchor fragment when rewriting', () => {
78
+ const input = '<a href="./Services.md#section">link</a>';
79
+ const out = rewriteDocLinks(input, known);
80
+ expect(out).toContain('href="Services.html#section"');
81
+ });
82
+
83
+ it('does not touch external (http/https) links', () => {
84
+ const input = '<a href="https://example.com/foo.md">external</a>';
85
+ const out = rewriteDocLinks(input, known);
86
+ expect(out).toContain('href="https://example.com/foo.md"');
87
+ });
88
+
89
+ it('does not touch pure-anchor (#section) links', () => {
90
+ const input = '<a href="#section">anchor</a>';
91
+ const out = rewriteDocLinks(input, known);
92
+ expect(out).toContain('href="#section"');
93
+ });
94
+
95
+ it('handles a trailing anchor on a bare wikilink-style href', () => {
96
+ const input = '<a href="Home#pages">Home</a>';
97
+ const out = rewriteDocLinks(input, known);
98
+ expect(out).toContain('href="Home.html#pages"');
99
+ });
100
+ });
101
+
102
+ // ============================================================================
103
+ // buildDocs — end-to-end render pipeline (against the real docs/ submodule)
104
+ // ============================================================================
105
+ describe('buildDocs against docs/ submodule', () => {
106
+ let outDir: string;
107
+
108
+ beforeEach(() => {
109
+ outDir = mkdtempSync(path.join(os.tmpdir(), 'render-docs-'));
110
+ });
111
+
112
+ afterEach(() => {
113
+ rmSync(outDir, { recursive: true, force: true });
114
+ });
115
+
116
+ it('throws a clear error when the docs source directory is missing', async () => {
117
+ await expect(buildDocs({ srcDir: path.join(outDir, 'nope'), outDir })).rejects.toThrow(
118
+ /docs.*missing|missing.*docs|submodule/iu,
119
+ );
120
+ });
121
+
122
+ it('throws a clear error when the docs source directory is present but empty', async () => {
123
+ // Mirrors the build-script guard: an empty `docs/` checkout (submodule
124
+ // init'd but not populated) must fail loudly with the recovery command,
125
+ // not silently produce a docs section with zero pages.
126
+ const empty = mkdirSync(path.join(outDir, 'empty-docs'), { recursive: true });
127
+ if (empty === undefined) {
128
+ throw new Error('test setup failed: could not create empty-docs dir');
129
+ }
130
+ await expect(buildDocs({ srcDir: empty, outDir })).rejects.toThrow(/empty|submodule/iu);
131
+ });
132
+
133
+ it('emits an index.html under the output directory', async () => {
134
+ await buildDocs({ srcDir: docsSrc, outDir });
135
+ const idx = path.join(outDir, 'index.html');
136
+ expect(existsSync(idx)).toBe(true);
137
+ expect(statSync(idx).size).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('emits one HTML file per markdown source (non-empty output directory)', async () => {
141
+ const result = await buildDocs({ srcDir: docsSrc, outDir });
142
+ expect(result.emitted.length).toBeGreaterThanOrEqual(20);
143
+ for (const f of result.emitted) {
144
+ expect(f.endsWith('.html')).toBe(true);
145
+ expect(existsSync(path.join(outDir, f))).toBe(true);
146
+ }
147
+ expect(readdirSync(outDir).filter((f) => f.endsWith('.html')).length).toBe(
148
+ result.emitted.length,
149
+ );
150
+ });
151
+
152
+ it('renders Services.html with an <h1> and at least one <code> block', async () => {
153
+ await buildDocs({ srcDir: docsSrc, outDir });
154
+ const html = readFileSync(path.join(outDir, 'Services.html'), 'utf8');
155
+ expect(html).toMatch(/<h1[^>]*>/);
156
+ expect(html).toMatch(/<pre[^>]*><code/);
157
+ // The raw markdown marker should not survive rendering.
158
+ expect(html).not.toMatch(/^# IRC Services Reference/m);
159
+ });
160
+
161
+ it('emits Home as the index (index.html)', async () => {
162
+ await buildDocs({ srcDir: docsSrc, outDir });
163
+ const html = readFileSync(path.join(outDir, 'index.html'), 'utf8');
164
+ // Home.md's "Welcome to the Wiki." lead-in should be present.
165
+ expect(html).toMatch(/Welcome to the Wiki/i);
166
+ });
167
+
168
+ it('rewrites intra-docs links to .html in the rendered output (no .md hrefs remain)', async () => {
169
+ await buildDocs({ srcDir: docsSrc, outDir });
170
+ const files = readdirSync(outDir).filter((f) => f.endsWith('.html'));
171
+ expect(files.length).toBeGreaterThan(0);
172
+ for (const f of files) {
173
+ const html = readFileSync(path.join(outDir, f), 'utf8');
174
+ // No intra-docs .md href may remain. (External https://…/foo.md is fine.)
175
+ const intraMd = html.match(/href="(?!https?:|mailto:|#)[^"]*\.md[^"]*"/g) ?? [];
176
+ expect(intraMd).toEqual([]);
177
+ }
178
+ });
179
+
180
+ it('emits a shared stylesheet (referenced by every page)', async () => {
181
+ await buildDocs({ srcDir: docsSrc, outDir });
182
+ const files = readdirSync(outDir).filter((f) => f.endsWith('.html'));
183
+ // Every page links the same stylesheet.
184
+ for (const f of files) {
185
+ const html = readFileSync(path.join(outDir, f), 'utf8');
186
+ expect(html).toMatch(/<link[^>]+rel=["']stylesheet["']/);
187
+ }
188
+ // And the stylesheet actually exists in the output.
189
+ const css = readdirSync(outDir).filter((f) => f.endsWith('.css'));
190
+ expect(css.length).toBeGreaterThanOrEqual(1);
191
+ });
192
+
193
+ it('emits an ADR-Index page that links each ADR via a rewritten .html href', async () => {
194
+ await buildDocs({ srcDir: docsSrc, outDir });
195
+ const html = readFileSync(path.join(outDir, 'ADR-Index.html'), 'utf8');
196
+ expect(html).toMatch(/ADR-001-pure-reducers-and-effect-system\.html/);
197
+ });
198
+ });
@@ -373,8 +373,9 @@ scheme (post-v1).
373
373
 
374
374
  │ all-or-nothing → races structurally impossible
375
375
 
376
-
377
- dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES
376
+
377
+ dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES,
378
+ and when a topic is set: 332 RPL_TOPIC, 333 RPL_TOPICWHOTIME
378
379
  ```
379
380
 
380
381
  A `CancellationException` from `TransactWriteItems` maps to the
package/docs/Services.md CHANGED
@@ -126,6 +126,38 @@ Notable conventions:
126
126
  `519` (ChanServ `+R` JOIN gate), `432` (OperServ JUPE), `404` (ChanServ
127
127
  `+R`/`+M` send gates), `472` (services-only channel-mode letters).
128
128
 
129
+ ### Shortcut aliases (`/NICKSERV`, `/NS`, `/CS`, …)
130
+
131
+ For compatibility with the Atheme/Anope services convention (and IRCd
132
+ alias modules such as InspIRCd `m_alias` / UnrealIRCd `alias{}`), every
133
+ service also accepts a **shortcut verb** that the daemon rewrites to the
134
+ canonical `PRIVMSG <ServiceNick> :<joined args>` before routing. These are
135
+ not RFC-standardized but are widely expected by clients and users.
136
+
137
+ | Shortcut verb(s) | Equivalent to |
138
+ |---|---|
139
+ | `NICKSERV`, `NS` | `PRIVMSG NickServ :<args>` |
140
+ | `CHANSERV`, `CS` | `PRIVMSG ChanServ :<args>` |
141
+ | `HOSTSERV`, `HS` | `PRIVMSG HostServ :<args>` |
142
+ | `MEMOSERV`, `MS` | `PRIVMSG MemoServ :<args>` |
143
+ | `OPERSERV`, `OS` | `PRIVMSG OperServ :<args>` |
144
+
145
+ The alias verb is the first token; everything after it is joined with
146
+ single spaces and becomes the `PRIVMSG` trailing body, so e.g.
147
+ `NICKSERV IDENTIFY hunter2` behaves identically to
148
+ `PRIVMSG NickServ :IDENTIFY hunter2`, and `NS REGISTER pw email` is the
149
+ same as `PRIVMSG NickServ :REGISTER pw email`. A colon-trailing form
150
+ (`NS :IDENTIFY hunter2`) is accepted too. The verbs are matched
151
+ case-insensitively. The alias → service nick table is the single source
152
+ of truth in `packages/irc-core/src/commands/service-aliases.ts`
153
+ (`SHORTCUT_TO_SERVICE`); both the dispatcher rewrite and the service-nick
154
+ routing consult it, so no service-reducer changes were needed.
155
+
156
+ When no `ServicesStore` is bound, a shortcut behaves exactly like a direct
157
+ `PRIVMSG` to the service nick — it falls through to the normal nick-target
158
+ path (a `401 ERR_NOSUCHNICK` for the unrecognised pseudo-client), never a
159
+ bare `421 ERR_UNKNOWNCOMMAND`.
160
+
129
161
  ---
130
162
 
131
163
  ## 4. NickServ — nick registration & identification
@@ -142,7 +174,7 @@ policy, and nick-info queries. A registered nick is an account.
142
174
  | `REGISTER` | `PRIVMSG NickServ :REGISTER <password> <email>` | Registers the *current* nick. Does **not** auto-identify (no `+r` set). Reply: `Nickname <nick> is now registered.` Already-registered → `Nickname <nick> is already registered.` |
143
175
  | `IDENTIFY` | `PRIVMSG NickServ :IDENTIFY [nick] <password>` | One-arg form identifies the current nick; two-arg form identifies a named nick. On success: sets `state.account`, sets user mode **`+r`**, fans `ACCOUNT` to `account-notify` peers, then delivers unread MemoServ memos. Reply: `You are now identified for nick <account>.` |
144
176
  | `DROP` | `PRIVMSG NickServ :DROP [nick]` | Requires prior identify. Drops the registration, clears `state.account` and `+r`, fans `ACCOUNT *`. Reply: `Nickname <nick> has been dropped.` |
145
- | `INFO` | `PRIVMSG NickServ :INFO [nick]` | Defaults to the current nick. Replies (NOTICE): `Nick:`, `Account:`, `Email:`. Unregistered → `Nick <target> is not registered.` |
177
+ | `INFO` | `PRIVMSG NickServ :INFO [nick]` | Defaults to the current nick. Replies (NOTICE): `Nick:`, `Account:`, and (only if the caller is identified as the owning account **or** is an oper) `Email:`. A non-owner sees `Nick:` / `Account:` only. Unregistered → `Nick <target> is not registered.` |
146
178
  | `SET ENFORCE` | `PRIVMSG NickServ :SET ENFORCE none|ghost|kill` | Requires identify as the owning account. See enforcement below. |
147
179
 
148
180
  Help / unknown command: `Available commands: REGISTER, IDENTIFY, DROP, INFO, SET`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "vite": "^7.3.6",
19
19
  "vitest": "^4.1.10",
20
- "@serverless-ircd/aws-adapter": "0.8.0"
20
+ "@serverless-ircd/aws-adapter": "0.9.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -459,10 +459,15 @@ export class AwsRuntime implements IrcRuntime {
459
459
  // -------------------------------------------------------------------------
460
460
 
461
461
  private async readConnectionRow(conn: ConnId): Promise<MarshalledConnection | null> {
462
+ // Strongly consistent: peer lookups (WHOIS, roster hydration) read a
463
+ // row that may have just been written by another frame. An eventually-
464
+ // consistent Get could present a stale peer snapshot under replication
465
+ // lag.
462
466
  const result = await this.dynamo.send(
463
467
  new GetCommand({
464
468
  TableName: this.tables.Connections,
465
469
  Key: { connectionId: conn },
470
+ ConsistentRead: true,
466
471
  }),
467
472
  );
468
473
  if (result.Item === undefined) return null;
@@ -723,7 +728,16 @@ export async function cleanupConnection(
723
728
  quitMessage = 'Client Quit',
724
729
  ): Promise<void> {
725
730
  const connRow = await dynamo.send(
726
- new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
731
+ // Strongly consistent: $disconnect (and the send()/broadcast()
732
+ // GoneException path) can fire on the very next frame after the
733
+ // connection's own row was last written/updated. An eventually-
734
+ // consistent Get can miss that just-written row under DynamoDB
735
+ // replication lag and skip nick release + roster fanout.
736
+ new GetCommand({
737
+ TableName: tables.Connections,
738
+ Key: { connectionId: connId },
739
+ ConsistentRead: true,
740
+ }),
727
741
  );
728
742
  const item = connRow.Item as unknown as MarshalledConnection | undefined;
729
743
  if (item === undefined) return; // idempotent — already cleaned up
@@ -321,14 +321,22 @@ function readHeader(event: NlbStreamEvent, name: string): string | undefined {
321
321
  return undefined;
322
322
  }
323
323
 
324
- /** Loads a raw Connections row (includes transportBuffer if present). */
324
+ /**
325
+ * Loads a raw Connections row (includes transportBuffer if present).
326
+ *
327
+ * Strongly consistent: the NLB handler has no `$connect` event — it writes
328
+ * the row itself on the first chunk and reads it back on the very next
329
+ * invocation. An eventually-consistent Get can miss that just-written row
330
+ * under DynamoDB replication lag and drop the first frame(s) of a
331
+ * raw-IRC-over-`:6697` connection.
332
+ */
325
333
  async function loadRow(
326
334
  dynamo: DynamoDBDocumentClient,
327
335
  tableName: string,
328
336
  connId: string,
329
337
  ): Promise<RawConnectionRow | null> {
330
338
  const result = await dynamo.send(
331
- new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
339
+ new GetCommand({ TableName: tableName, Key: { connectionId: connId }, ConsistentRead: true }),
332
340
  );
333
341
  if (result.Item === undefined) return null;
334
342
  return result.Item as unknown as RawConnectionRow;
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
+ import { GetCommand } from '@aws-sdk/lib-dynamodb';
10
11
  import {
11
12
  type ChannelDelta,
12
13
  type Clock,
@@ -15,7 +16,7 @@ import {
15
16
  type ServerConfig,
16
17
  createConnection,
17
18
  } from '@serverless-ircd/irc-core';
18
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
20
  import { AwsRuntime } from '../src/aws-runtime.js';
20
21
  import type { AwsRuntimeHandlers, PostToConnection } from '../src/aws-runtime.js';
21
22
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -235,6 +236,27 @@ describe.skipIf(!available)('AwsRuntime', () => {
235
236
  const got = await rt.getConnection('self');
236
237
  expect(got).toBe(state);
237
238
  });
239
+
240
+ it('getConnectionInfo reads a peer Connections row strongly consistently', async () => {
241
+ // Peer lookups (WHOIS, roster hydration via readConnectionRow) read
242
+ // another connection's row. Strong consistency avoids presenting a
243
+ // stale peer snapshot under DynamoDB replication lag.
244
+ const rt = makeRuntime('self', noopHandlers());
245
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
246
+ state.nick = 'Alice';
247
+ await rt.persistConnectionState(state);
248
+ const sendSpy = vi.spyOn(fx.client, 'send');
249
+ await rt.getConnectionInfo('c1');
250
+ const conGets = sendSpy.mock.calls
251
+ .map((call) => call[0] as unknown)
252
+ .filter(
253
+ (cmd): cmd is GetCommand =>
254
+ cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
255
+ );
256
+ expect(conGets.length).toBeGreaterThan(0);
257
+ expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
258
+ sendSpy.mockRestore();
259
+ });
238
260
  });
239
261
 
240
262
  // -------------------------------------------------------------------------
@@ -95,6 +95,23 @@ describe('incrementConnectionCount', () => {
95
95
  expect(stub.count).toBe(1);
96
96
  expect(CONNECTION_COUNT_META_ID).toContain('__meta:');
97
97
  });
98
+
99
+ it('returns 0 when the response carries no numeric count (defensive)', async () => {
100
+ // A malformed/empty response must not crash the connect path; the
101
+ // handler treats a missing count as a 0 reservation so admission still
102
+ // proceeds conservatively rather than throwing.
103
+ const dynamo = {
104
+ send: async () => ({ Attributes: { count: 'not-a-number' } }),
105
+ } as unknown as DynamoDBDocumentClient;
106
+ await expect(incrementConnectionCount(dynamo, 'Connections')).resolves.toBe(0);
107
+ });
108
+
109
+ it('returns 0 when the response carries no Attributes at all', async () => {
110
+ const dynamo = {
111
+ send: async () => ({}),
112
+ } as unknown as DynamoDBDocumentClient;
113
+ await expect(incrementConnectionCount(dynamo, 'Connections')).resolves.toBe(0);
114
+ });
98
115
  });
99
116
 
100
117
  describe('decrementConnectionCount', () => {
@@ -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 = {
@@ -9,9 +9,10 @@
9
9
  */
10
10
 
11
11
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
12
+ import { GetCommand } from '@aws-sdk/lib-dynamodb';
12
13
  import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
13
14
  import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
14
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
15
16
  import { AwsRuntime } from '../src/aws-runtime.js';
16
17
  import type { PostToConnection } from '../src/aws-runtime.js';
17
18
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -164,6 +165,33 @@ describe.skipIf(!available)('NLB stream handler — byte framing', () => {
164
165
  expect(info?.registration).toBe('pre-registration');
165
166
  });
166
167
 
168
+ it('reads the Connections row strongly consistently on every chunk', async () => {
169
+ // The NLB handler has no $connect event — it writes its own row on the
170
+ // first chunk and reads it back on every subsequent chunk. An eventually-
171
+ // consistent Get can miss the just-written row under replication lag and
172
+ // drop the first frame(s) of a raw-IRC-over-:6697 connection. The Get
173
+ // MUST be strongly consistent.
174
+ const f = requireFx();
175
+ const sendSpy = vi.spyOn(f.client, 'send');
176
+ await handleNlbStream(nlbEvent('10.0.0.50', '50050', 'PING :tok\r\n'), {
177
+ dynamo: f.client,
178
+ tables: f.tables,
179
+ serverConfig: SERVER_CONFIG,
180
+ motd: MOTD,
181
+ messages: bindMessageStore(SERVER_CONFIG),
182
+ managementApi: f.mgmt,
183
+ });
184
+ const conGets = sendSpy.mock.calls
185
+ .map((call) => call[0] as unknown)
186
+ .filter(
187
+ (cmd): cmd is GetCommand =>
188
+ cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
189
+ );
190
+ expect(conGets.length).toBeGreaterThan(0);
191
+ expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
192
+ sendSpy.mockRestore();
193
+ });
194
+
167
195
  it('returns a PONG in the response body for a single PING', async () => {
168
196
  const f = requireFx();
169
197
  // First chunk establishes the connection.